From 8be93f1e544c8bd75a7fde3e484ec66354112cbf Mon Sep 17 00:00:00 2001 From: priethor <27339341+priethor@users.noreply.github.com> Date: Fri, 2 Jan 2026 15:36:04 +0100 Subject: [PATCH] Tests: Add structural assertions to JSX parser tests Adds structural assertions to jsx-parser tests that verify the parser produces correct React element output, rather than just checking existence. - jsx-parser.test.js: 35 tests with structural assertions - setup-tests.js: Extended wp/acf mocks for parser dependencies - mocks/jquery.js: DOM parsing mock using DOMParser - jest.config.js: jQuery module mapping --- jest.config.js | 1 + tests/js/blocks-v3/jsx-parser.test.js | 413 +++++++++++++++++--------- tests/js/mocks/jquery.js | 22 ++ tests/js/setup-tests.js | 132 +++++++- 4 files changed, 430 insertions(+), 138 deletions(-) create mode 100644 tests/js/mocks/jquery.js diff --git a/jest.config.js b/jest.config.js index 9bd076cc..db66a9d9 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,6 +8,7 @@ module.exports = { testEnvironment: 'jsdom', moduleNameMapper: { '\\.(css|less|scss|sass)$': 'identity-obj-proxy', + '^jquery$': '/tests/js/mocks/jquery.js', }, collectCoverageFrom: [ 'assets/src/js/**/*.{js,jsx}', diff --git a/tests/js/blocks-v3/jsx-parser.test.js b/tests/js/blocks-v3/jsx-parser.test.js index 449c8d20..c8f255cd 100644 --- a/tests/js/blocks-v3/jsx-parser.test.js +++ b/tests/js/blocks-v3/jsx-parser.test.js @@ -1,173 +1,316 @@ +/* global acf, DOMParser */ /** * Unit tests for JSX Parser - * Tests the HTML to React/JSX conversion for block previews - * Important: This component has XSS risk considerations + * Tests HTML parsing, attribute handling, and edge cases */ -import React from 'react'; - -// Setup mocks before importing the module -const mockCreateElement = jest.fn( ( type, props, ...children ) => - React.createElement( type, props, ...children ) -); - -const mockCreateRef = jest.fn( () => ( { current: null } ) ); - -// Mock @wordpress/element -jest.mock( - '@wordpress/element', - () => ( { - createElement: mockCreateElement, - createRef: mockCreateRef, - } ), - { virtual: true } -); - -// Mock wp global for useInnerBlocksProps -global.wp = { - blockEditor: { - useInnerBlocksProps: jest.fn( ( props ) => ( { - ...props, - children: null, - } ) ), - }, -}; - -// Setup acf global -global.acf = { - debug: jest.fn(), - isget: jest.fn( ( obj, ...keys ) => { - // Simple implementation of isget for jsxNameReplacements - if ( keys[ 0 ] === 'jsxNameReplacements' ) { - const replacements = { - for: 'htmlFor', - tabindex: 'tabIndex', - colspan: 'colSpan', - rowspan: 'rowSpan', - autocomplete: 'autoComplete', - autofocus: 'autoFocus', - readonly: 'readOnly', - }; - return replacements[ keys[ 1 ] ] || undefined; - } - return undefined; - } ), - strCamelCase: jest.fn( ( str ) => { - return str.replace( /-([a-z])/g, ( g ) => g[ 1 ].toUpperCase() ); - } ), - applyFilters: jest.fn( ( hook, value ) => value ), - arrayArgs: jest.fn( ( collection ) => { - if ( ! collection ) { - return []; - } - return Array.from( collection ); - } ), - blockEdit: { - setCurrentInlineEditingElementUid: jest.fn(), - setCurrentInlineEditingElement: jest.fn(), - setCurrentContentEditableElement: jest.fn(), - getBlockFieldInfo: jest.fn( () => [ - { name: 'title', type: 'text', label: 'Title' }, - { name: 'description', type: 'textarea', label: 'Description' }, - ] ), - }, -}; - -// Mock jQuery -const createMockElement = ( html ) => { - const div = document.createElement( 'div' ); - div.innerHTML = html; - return div; -}; - -global.jQuery = jest.fn( ( html ) => { - if ( typeof html === 'string' ) { - return [ createMockElement( html ) ]; - } - return [ html ]; -} ); +import '@testing-library/jest-dom'; + +import { parseJSX } from '../../../assets/src/js/pro/blocks-v3/components/jsx-parser'; -describe( 'JSX Parser DOM Integration', () => { +describe( 'parseJSX', () => { beforeEach( () => { jest.clearAllMocks(); } ); - describe( 'DOM element attribute handling', () => { - test( 'preserves acf-inline-fields data attributes for inline editing', () => { - const div = document.createElement( 'div' ); - div.setAttribute( - 'data-acf-inline-fields', - '["field_1","field_2"]' + describe( 'Basic HTML Parsing', () => { + test( 'parses simple div with text content', () => { + const result = parseJSX( '
Hello
' ); + expect( result.type ).toBe( 'div' ); + expect( result.props.children ).toBe( 'Hello' ); + } ); + + test( 'parses paragraph element', () => { + const result = parseJSX( '

Some text content

' ); + expect( result.type ).toBe( 'p' ); + expect( result.props.children ).toBe( 'Some text content' ); + } ); + + test( 'parses nested elements', () => { + const result = parseJSX( '
Nested
' ); + expect( result.type ).toBe( 'div' ); + expect( result.props.children.type ).toBe( 'span' ); + expect( result.props.children.props.children ).toBe( 'Nested' ); + } ); + + test( 'parses multiple siblings as array', () => { + const result = parseJSX( '
First
Second
' ); + expect( Array.isArray( result ) ).toBe( true ); + expect( result ).toHaveLength( 2 ); + expect( result[ 0 ].type ).toBe( 'div' ); + expect( result[ 0 ].props.children ).toBe( 'First' ); + expect( result[ 1 ].type ).toBe( 'div' ); + expect( result[ 1 ].props.children ).toBe( 'Second' ); + } ); + + test( 'returns undefined for empty string', () => { + const result = parseJSX( '' ); + expect( result ).toBeUndefined(); + } ); + + test( 'parses text-only content', () => { + const result = parseJSX( 'Just text' ); + expect( result ).toBe( 'Just text' ); + } ); + + test( 'parses whitespace-only content', () => { + const result = parseJSX( ' ' ); + expect( result ).toBe( ' ' ); + } ); + } ); + + describe( 'Attribute Handling', () => { + test( 'converts class to className', () => { + const result = parseJSX( '
Content
' ); + expect( result.props.className ).toBe( 'my-class' ); + expect( result.props.class ).toBeUndefined(); + } ); + + test( 'converts style string to object with camelCase properties', () => { + parseJSX( + '
Content
' + ); + expect( acf.strCamelCase ).toHaveBeenCalledWith( + 'background-color' ); - div.setAttribute( 'data-acf-inline-fields-uid', 'uid-123' ); + expect( acf.strCamelCase ).toHaveBeenCalledWith( 'font-weight' ); + } ); - expect( div.getAttribute( 'data-acf-inline-fields' ) ).toBe( - '["field_1","field_2"]' + test( 'converts for attribute to htmlFor', () => { + parseJSX( '' ); + expect( acf.isget ).toHaveBeenCalledWith( + acf, + 'jsxNameReplacements', + 'for' ); - expect( div.getAttribute( 'data-acf-inline-fields-uid' ) ).toBe( - 'uid-123' + } ); + + test( 'preserves data- attributes as-is', () => { + const result = parseJSX( '
Content
' ); + expect( result.props[ 'data-custom' ] ).toBe( 'value' ); + } ); + + test( 'calls applyFilters for custom attribute handling', () => { + parseJSX( '
Content
' ); + expect( acf.applyFilters ).toHaveBeenCalledWith( + 'acf_blocks_parse_node_attr', + false, + expect.objectContaining( { name: expect.any( String ) } ) ); } ); - test( 'preserves contenteditable attributes for inline editing', () => { - const div = document.createElement( 'div' ); - div.setAttribute( 'data-acf-inline-contenteditable', 'true' ); - div.setAttribute( - 'data-acf-inline-contenteditable-field-slug', - 'title' + test( 'uses filter result when provided', () => { + acf.applyFilters.mockImplementationOnce( ( hook, value, attr ) => { + if ( attr.name === 'custom-attr' ) { + return { name: 'customAttr', value: 'transformed' }; + } + return value; + } ); + + const result = parseJSX( + '
Content
' ); + expect( result.props.customAttr ).toBe( 'transformed' ); + } ); - expect( - div.getAttribute( 'data-acf-inline-contenteditable' ) - ).toBe( 'true' ); - expect( - div.getAttribute( 'data-acf-inline-contenteditable-field-slug' ) - ).toBe( 'title' ); + test( 'converts boolean string "true" to boolean true', () => { + const result = parseJSX( '' ); + expect( result.props.disabled ).toBe( true ); + } ); + + test( 'converts boolean string "false" to boolean false', () => { + const result = parseJSX( '' ); + expect( result.props.disabled ).toBe( false ); } ); } ); - describe( 'InnerBlocks HTML transformation', () => { - test( 'self-closing InnerBlocks regex replacement', () => { - const htmlString = - '
'; - const result = htmlString.replace( - /]+)?\/>/, - '' + describe( 'JSON Attribute Parsing', () => { + test( 'parses valid JSON array attribute', () => { + const result = parseJSX( + '
Content
' ); + expect( result.props.items ).toEqual( [ 'a', 'b', 'c' ] ); + } ); - expect( result ).toBe( - '
' + test( 'parses valid JSON object attribute', () => { + const result = parseJSX( + '
Content
' ); + expect( result.props.config ).toEqual( { key: 'value' } ); + } ); + + test( 'throws SyntaxError on invalid JSON array attribute', () => { + expect( () => { + parseJSX( '
Content
' ); + } ).toThrow( SyntaxError ); + } ); + + test( 'throws SyntaxError on invalid JSON object attribute', () => { + expect( () => { + parseJSX( '
Content
' ); + } ).toThrow( SyntaxError ); } ); + } ); - test( 'handles InnerBlocks without attributes', () => { - const htmlString = '
'; - const result = htmlString.replace( - /]+)?\/>/, - '' + describe( 'InnerBlocks Handling', () => { + test( 'converts InnerBlocks to ACFInnerBlocksComponent', () => { + const result = parseJSX( '' ); + // ACFInnerBlocksComponent is a function component + expect( typeof result.type ).toBe( 'function' ); + expect( result.type.name ).toBe( 'ACFInnerBlocksComponent' ); + } ); + + test( 'passes attributes to InnerBlocks component', () => { + const result = parseJSX( + '' ); + // DOM parser lowercases attribute names, so allowedBlocks becomes allowedblocks + expect( result.props.allowedblocks ).toEqual( [ + 'core/paragraph', + ] ); + } ); - expect( result ).toBe( '
' ); + test( 'handles InnerBlocks inside container', () => { + const result = parseJSX( + '
' + ); + expect( result.type ).toBe( 'div' ); + expect( result.props.className ).toBe( 'container' ); + expect( typeof result.props.children.type ).toBe( 'function' ); + } ); + } ); + + describe( 'Script Tag Handling', () => { + test( 'converts script tags to ScriptComponent', () => { + const result = parseJSX( '' ); + // ScriptComponent is a class component + expect( typeof result.type ).toBe( 'function' ); + expect( result.type.name ).toBe( 'ScriptComponent' ); + } ); + + test( 'passes script content as children', () => { + const result = parseJSX( '' ); + expect( result.props.children ).toBe( 'var x = 1;' ); + } ); + } ); + + describe( 'Malformed Input Handling', () => { + test( 'handles unclosed tags gracefully', () => { + const result = parseJSX( '

Unclosed' ); + expect( result.type ).toBe( 'div' ); + // Browser's DOMParser auto-closes tags + expect( result.props.children.type ).toBe( 'p' ); + } ); + + test( 'handles deeply nested elements (50 levels)', () => { + const html = + '

'.repeat( 50 ) + 'Content' + '
'.repeat( 50 ); + const result = parseJSX( html ); + expect( result.type ).toBe( 'div' ); } ); - test( 'does not affect non-self-closing InnerBlocks', () => { - const htmlString = '
'; - const result = htmlString.replace( - /]+)?\/>/, - '' + test( 'handles null bytes in content', () => { + const result = parseJSX( + '
Content\u0000with\u0000nulls
' + ); + expect( result.type ).toBe( 'div' ); + // Null bytes are preserved in the content + expect( result.props.children ).toContain( 'Content' ); + } ); + } ); + + describe( 'XSS Vector Documentation', () => { + // These tests document that parseJSX processes XSS vectors without sanitizing. + // Sanitization must happen at the PHP/template level before HTML reaches the parser. + + test( 'processes javascript: URL (sanitization happens elsewhere)', () => { + const result = parseJSX( + 'Click' ); + expect( result.type ).toBe( 'a' ); + expect( result.props.href ).toBe( 'javascript:alert(1)' ); + } ); + + test( 'processes event handlers (sanitization happens elsewhere)', () => { + const result = parseJSX( '' ); + expect( result.type ).toBe( 'img' ); + expect( result.props.onerror ).toBe( 'alert(1)' ); + } ); + } ); - expect( result ).toBe( '
' ); + describe( 'Custom jQuery Instance', () => { + test( 'accepts and uses custom jQuery instance', () => { + const customJQuery = jest.fn( ( html ) => { + const parser = new DOMParser(); + const doc = parser.parseFromString( html, 'text/html' ); + return [ doc.body.firstChild ]; + } ); + + const result = parseJSX( '
Test
', customJQuery ); + + expect( customJQuery ).toHaveBeenCalled(); + expect( result.type ).toBe( 'div' ); + expect( result.props.children ).toBe( 'Test' ); + } ); + } ); + + describe( 'ACF Global Integration', () => { + test( 'parseJSX is exposed on acf global object', () => { + expect( acf.parseJSX ).toBe( parseJSX ); } ); } ); - describe( 'XSS security considerations', () => { - test( 'script content exists but should not execute in test environment', () => { - const maliciousHtml = ''; - const div = document.createElement( 'div' ); - div.innerHTML = maliciousHtml; + describe( 'ACF Inline Editing Attributes', () => { + beforeEach( () => { + acf.blockEdit = { + setCurrentInlineEditingElementUid: jest.fn(), + setCurrentInlineEditingElement: jest.fn(), + setCurrentContentEditableElement: jest.fn(), + getBlockFieldInfo: jest.fn( () => [ + { name: 'title', type: 'text' }, + ] ), + }; + } ); + + afterEach( () => { + acf.blockEdit = null; + } ); + + test( 'adds interaction props for inline editing elements', () => { + const result = parseJSX( + '
Content
' + ); + expect( result.props.role ).toBe( 'button' ); + expect( result.props.tabIndex ).toBe( 0 ); + expect( typeof result.props.onFocus ).toBe( 'function' ); + expect( typeof result.props.onClick ).toBe( 'function' ); + expect( typeof result.props.onKeyDown ).toBe( 'function' ); + } ); + + test( 'adds pointerEvents style for inline editing elements', () => { + const result = parseJSX( + '
Content
' + ); + expect( result.props.style.pointerEvents ).toBe( 'all' ); + } ); - expect( div.querySelector( 'script' ) ).not.toBeNull(); + test( 'adds contentEditable for valid editable fields', () => { + const result = parseJSX( + 'Text' + ); + expect( result.props.contentEditable ).toBe( true ); + expect( result.props.suppressContentEditableWarning ).toBe( true ); + } ); + + test( 'removes contentEditable attrs for non-existent fields', () => { + acf.blockEdit.getBlockFieldInfo.mockReturnValue( [] ); + const result = parseJSX( + 'Text' + ); + expect( result.props.contentEditable ).toBeUndefined(); + expect( + result.props[ 'data-acf-inline-contenteditable' ] + ).toBeUndefined(); } ); } ); } ); diff --git a/tests/js/mocks/jquery.js b/tests/js/mocks/jquery.js new file mode 100644 index 00000000..5163685e --- /dev/null +++ b/tests/js/mocks/jquery.js @@ -0,0 +1,22 @@ +/* global DOMParser */ +/** + * jQuery mock for Jest tests + * Provides minimal DOM parsing functionality needed by jsx-parser + * + * @param {string} html - HTML string to parse + * @return {Array} Array containing the parsed DOM element + */ +const jQuery = ( html ) => { + if ( typeof html === 'string' ) { + const parser = new DOMParser(); + const doc = parser.parseFromString( html, 'text/html' ); + const element = doc.body.firstChild || doc.body; + return [ element ]; + } + return []; +}; + +jQuery.fn = jQuery.prototype = {}; +jQuery.extend = Object.assign; + +export default jQuery; diff --git a/tests/js/setup-tests.js b/tests/js/setup-tests.js index 1c576833..18ec59f9 100644 --- a/tests/js/setup-tests.js +++ b/tests/js/setup-tests.js @@ -7,22 +7,148 @@ import React from 'react'; global.React = React; -// Mock jQuery for parseJSX tests +// Mock jQuery for parseJSX tests - includes DOM parsing capability global.jQuery = jest.fn( ( html ) => { if ( typeof html === 'string' ) { - // Simple mock that returns an array-like object - return [ { innerHTML: html, tagName: 'DIV' } ]; + // Parse HTML string to DOM + const parser = new DOMParser(); + const doc = parser.parseFromString( html, 'text/html' ); + const element = doc.body.firstChild || doc.body; + return [ element ]; } return []; } ); global.$ = global.jQuery; +// Mock wp global for blocks-v3 components +global.wp = { + element: { + Fragment: React.Fragment, + Component: React.Component, + createElement: React.createElement, + createRef: React.createRef, + }, + blockEditor: { + useInnerBlocksProps: jest.fn( ( props ) => ( { + ...props, + children: null, + } ) ), + __experimentalUseInnerBlocksProps: jest.fn( ( props ) => ( { + ...props, + children: null, + } ) ), + BlockControls: ( { children } ) => + React.createElement( + 'div', + { 'data-testid': 'block-controls' }, + children + ), + BlockVerticalAlignmentToolbar: ( { value, onChange } ) => + React.createElement( 'button', { + 'data-testid': 'vertical-alignment-toolbar', + 'data-value': value, + onClick: () => onChange( 'center' ), + } ), + __experimentalBlockAlignmentMatrixControl: ( { value, onChange } ) => + React.createElement( 'button', { + 'data-testid': 'alignment-matrix-control', + 'data-value': value, + onClick: () => onChange( 'center center' ), + } ), + BlockAlignmentMatrixControl: ( { value, onChange } ) => + React.createElement( 'button', { + 'data-testid': 'alignment-matrix-control', + 'data-value': value, + onClick: () => onChange( 'center center' ), + } ), + AlignmentToolbar: ( { value, onChange } ) => + React.createElement( 'button', { + 'data-testid': 'alignment-toolbar', + 'data-value': value, + onClick: () => onChange( 'center' ), + } ), + __experimentalBlockFullHeightAligmentControl: ( { + isActive, + onToggle, + } ) => + React.createElement( 'button', { + 'data-testid': 'full-height-control', + 'data-active': isActive ? 'true' : 'false', + onClick: () => onToggle( ! isActive ), + } ), + BlockFullHeightAlignmentControl: ( { isActive, onToggle } ) => + React.createElement( 'button', { + 'data-testid': 'full-height-control', + 'data-active': isActive ? 'true' : 'false', + onClick: () => onToggle( ! isActive ), + } ), + InspectorControls: 'InspectorControls', + useBlockBindingsUtils: jest.fn(), + }, + data: { + dispatch: jest.fn( ( store ) => { + if ( store === 'core/editor' ) { + return { + lockPostSaving: jest.fn(), + unlockPostSaving: jest.fn(), + }; + } + return null; + } ), + select: jest.fn( ( store ) => { + if ( store === 'core/editor' ) { + return { + isPostSavingLocked: jest.fn( () => false ), + }; + } + return null; + } ), + }, +}; + // Mock ACF global for field type tests global.acf = { Field: { extend: jest.fn( ( def ) => def ), }, registerFieldType: jest.fn(), + __: jest.fn( ( text ) => text ), + get: jest.fn( ( key ) => { + if ( key === 'rtl' ) return false; + return undefined; + } ), + isget: jest.fn( ( obj, ...keys ) => { + let value = obj; + for ( const key of keys ) { + if ( value && typeof value === 'object' && key in value ) { + value = value[ key ]; + } else { + return undefined; + } + } + return value; + } ), + applyFilters: jest.fn( ( hook, value ) => value ), + arrayArgs: jest.fn( ( arrayLike ) => + arrayLike ? Array.from( arrayLike ) : [] + ), + strCamelCase: jest.fn( ( str ) => { + return str.replace( /-([a-z])/g, ( _, letter ) => letter.toUpperCase() ); + } ), + debug: jest.fn(), + blockEdit: null, + jsxNameReplacements: { + for: 'htmlFor', + class: 'className', + tabindex: 'tabIndex', + readonly: 'readOnly', + maxlength: 'maxLength', + colspan: 'colSpan', + rowspan: 'rowSpan', + cellpadding: 'cellPadding', + cellspacing: 'cellSpacing', + autocomplete: 'autoComplete', + }, }; // Mock WordPress packages that are externalized