diff --git a/html-api-debugger/byte-preview.mjs b/html-api-debugger/byte-preview.mjs new file mode 100644 index 0000000..4044d63 --- /dev/null +++ b/html-api-debugger/byte-preview.mjs @@ -0,0 +1,808 @@ +/** + * Own exact-byte iframe document URLs. + */ +export class ByteDocumentPreview { + /** + * @param {{src: string}} iframe Iframe-like navigation target. + * @param {{createObjectURL: (blob: Blob) => string, revokeObjectURL: (url: string) => void}} [urlApi=URL] Object URL API. + * @param {typeof Blob} [BlobConstructor=Blob] Blob constructor. + */ + constructor( iframe, urlApi = URL, BlobConstructor = Blob ) { + this.iframe = iframe; + this.urlApi = urlApi; + this.BlobConstructor = BlobConstructor; + /** @type {string|null} */ + this.currentUrl = null; + } + + /** + * Navigate to a new UTF-8 HTML document made from exact bytes. + * + * @param {Uint8Array} bytes Exact document bytes. + * @returns {string} New object URL. + */ + load( bytes ) { + if ( ! ( bytes instanceof Uint8Array ) ) { + throw new TypeError( 'Expected a Uint8Array.' ); + } + + const buffer = new ArrayBuffer( bytes.byteLength ); + new Uint8Array( buffer ).set( bytes ); + const blob = new this.BlobConstructor( [ buffer ], { + type: 'text/html;charset=utf-8', + } ); + const nextUrl = this.urlApi.createObjectURL( blob ); + + try { + this.iframe.src = nextUrl; + } catch ( error ) { + this.urlApi.revokeObjectURL( nextUrl ); + throw error; + } + + const supersededUrl = this.currentUrl; + this.currentUrl = nextUrl; + if ( supersededUrl !== null ) { + this.urlApi.revokeObjectURL( supersededUrl ); + } + + return nextUrl; + } + + /** + * Determine whether a load event belongs to the current document. + * + * @param {string} url Loaded URL. + * @returns {boolean} Whether the URL is current. + */ + isCurrent( url ) { + return this.currentUrl !== null && url === this.currentUrl; + } + + /** Revoke the final owned URL. Safe to call repeatedly. */ + dispose() { + if ( this.currentUrl === null ) { + return; + } + const finalUrl = this.currentUrl; + this.currentUrl = null; + this.urlApi.revokeObjectURL( finalUrl ); + } +} + +/** + * Split bytes at an HTML API byte span. + * + * @param {Uint8Array} bytes Exact source bytes. + * @param {number} start Byte offset. + * @param {number} length Byte length. + * @returns {{before: Uint8Array, current: Uint8Array, after: Uint8Array}} Byte slices. + */ +export function splitByteSpan( bytes, start, length ) { + if ( ! ( bytes instanceof Uint8Array ) ) { + throw new TypeError( 'Expected a Uint8Array.' ); + } + if ( + ! Number.isFinite( start ) || + ! Number.isInteger( start ) || + ! Number.isFinite( length ) || + ! Number.isInteger( length ) + ) { + throw new TypeError( 'Byte span offsets must be finite integers.' ); + } + if ( start < 0 || length < 0 || start > bytes.length - length ) { + throw new RangeError( 'Byte span falls outside the source bytes.' ); + } + + const end = start + length; + return { + before: bytes.subarray( 0, start ), + current: bytes.subarray( start, end ), + after: bytes.subarray( end ), + }; +} + +/** + * Resolve the element whose native `innerHTML` setter parses a fragment. + * + * The parsed context document usually exposes its authored final element via + * the tree. Empty HEAD, BODY, and HTML contexts need the original authored + * context projection to distinguish the browser-created empty elements. + * + * @param {Document} document Parsed exact-byte context document. + * @param {string} contextText Safe Unicode projection of the context bytes. + * @returns {Element} Native fragment parsing context. + */ +export function resolveFragmentTarget( document, contextText ) { + if ( typeof contextText !== 'string' ) { + throw new TypeError( 'Fragment context text must be a string.' ); + } + + const authored = findAuthoredDocumentRoots( contextText ); + const body = document.body; + const head = document.head; + if ( body?.localName === 'frameset' ) { + return lastElementDescendant( body ); + } + if ( body !== null && ( authored.body || body.hasChildNodes() ) ) { + return lastElementDescendant( body ); + } + if ( head !== null && ( authored.head || head.hasChildNodes() ) ) { + return lastElementDescendant( head ); + } + return document.documentElement; +} + +const ASCII_WHITESPACE = new Set( [ '\t', '\n', '\f', '\r', ' ' ] ); +const RAW_TEXT_ELEMENTS = new Set( [ + 'iframe', + 'noembed', + 'noframes', + 'style', + 'xmp', +] ); +const RCDATA_ELEMENTS = new Set( [ 'textarea', 'title' ] ); + +/** @param {string} character */ +function isAsciiAlpha( character ) { + return /^[A-Za-z]$/u.test( character ); +} + +/** @param {string} character */ +function isTagDelimiter( character ) { + return character === '>' || character === '/' || ASCII_WHITESPACE.has( character ); +} + +/** + * Read a start or end tag through its closing angle bracket. + * + * @param {string} source Source text. + * @param {number} position Position immediately after `<` or `' ) { + return { name, end: position + 1 }; + } else if ( character === '=' ) { + state = 'attribute-name'; + ++position; + } else { + state = 'attribute-name'; + } + break; + case 'attribute-name': + if ( ASCII_WHITESPACE.has( character ) ) { + state = 'after-attribute-name'; + ++position; + } else if ( character === '/' ) { + state = 'self-closing'; + ++position; + } else if ( character === '=' ) { + state = 'before-attribute-value'; + ++position; + } else if ( character === '>' ) { + return { name, end: position + 1 }; + } else { + ++position; + } + break; + case 'after-attribute-name': + if ( ASCII_WHITESPACE.has( character ) ) { + ++position; + } else if ( character === '/' ) { + state = 'self-closing'; + ++position; + } else if ( character === '=' ) { + state = 'before-attribute-value'; + ++position; + } else if ( character === '>' ) { + return { name, end: position + 1 }; + } else { + state = 'attribute-name'; + } + break; + case 'before-attribute-value': + if ( ASCII_WHITESPACE.has( character ) ) { + ++position; + } else if ( character === '"' ) { + state = 'double-quoted-attribute-value'; + ++position; + } else if ( character === "'" ) { + state = 'single-quoted-attribute-value'; + ++position; + } else if ( character === '>' ) { + return { name, end: position + 1 }; + } else { + state = 'unquoted-attribute-value'; + } + break; + case 'double-quoted-attribute-value': + if ( character === '"' ) { + state = 'after-quoted-attribute-value'; + } + ++position; + break; + case 'single-quoted-attribute-value': + if ( character === "'" ) { + state = 'after-quoted-attribute-value'; + } + ++position; + break; + case 'unquoted-attribute-value': + if ( ASCII_WHITESPACE.has( character ) ) { + state = 'before-attribute-name'; + ++position; + } else if ( character === '>' ) { + return { name, end: position + 1 }; + } else { + ++position; + } + break; + case 'after-quoted-attribute-value': + if ( ASCII_WHITESPACE.has( character ) ) { + state = 'before-attribute-name'; + ++position; + } else if ( character === '/' ) { + state = 'self-closing'; + ++position; + } else if ( character === '>' ) { + return { name, end: position + 1 }; + } else { + state = 'before-attribute-name'; + } + break; + case 'self-closing': + if ( character === '>' ) { + return { name, end: position + 1 }; + } + state = 'before-attribute-name'; + break; + } + } + return null; +} + +/** @param {string} source @param {number} position */ +function consumeBogusComment( source, position ) { + const end = source.indexOf( '>', position ); + return end === -1 ? source.length : end + 1; +} + +/** + * Consume an HTML comment using the tokenizer's abrupt and nested end states. + * + * @param {string} source Source text. + * @param {number} position Position immediately after `x', + ), + titledHead.children[ 0 ], + 'comment text cannot author BODY', +); +assert.equal( + resolveFragmentTarget( + /** @type {any} */ ( titledDocument ), + 'x', + ), + titledDocument.body, + 'an explicit empty BODY outranks a populated HEAD', +); +assert.equal( + resolveFragmentTarget( + /** @type {any} */ ( titledDocument ), + '">', + '', + '', + '<body>', + '<template><body></template>', + '<!DOCTYPE html SYSTEM "x<body">', +] ) { + assert.equal( + resolveFragmentTarget( /** @type {any} */ ( emptyContextDocument ), context ), + emptyContextDocument.documentElement, + `${ context } cannot falsely author BODY`, + ); +} + +assert.equal( + resolveFragmentTarget( + /** @type {any} */ ( emptyContextDocument ), + '<!doctype html><!--><body>', + ), + emptyContextDocument.body, + 'an abrupt empty comment exposes the following authored BODY', +); +assert.equal( + resolveFragmentTarget( + /** @type {any} */ ( emptyContextDocument ), + '<noscript><body>', + ), + emptyContextDocument.body, + 'scripting-disabled NOSCRIPT exposes its BODY token', +); +assert.equal( + resolveFragmentTarget( + /** @type {any} */ ( emptyContextDocument ), + '<body a=b">', + ), + emptyContextDocument.body, + 'a quote in an unquoted attribute does not swallow the tag closer', +); +for ( const context of [ + '<body =">', + '<!doctype html><head><meta ="x><body></body><!--">', + '<!DOCTYPE html SYSTEM "x><body>">', +] ) { + assert.equal( + resolveFragmentTarget( /** @type {any} */ ( emptyContextDocument ), context ), + emptyContextDocument.body, + `${ context } consumes a leading equals sign as an attribute name`, + ); +} + +const scriptHeadDocument = fakeContextDocument( { + head: fakeElement( 'head', [ fakeElement( 'script' ) ] ), +} ); +for ( const context of [ + '<!doctype html><head><script><!x<script></script><body></script>', + '<!doctype html><head><script><!-x<script></script><body></script>', +] ) { + assert.equal( + resolveFragmentTarget( /** @type {any} */ ( scriptHeadDocument ), context ), + scriptHeadDocument.body, + `${ context } returns from escape start to script data`, + ); +} + +const textBodyDocument = fakeContextDocument( { + body: fakeElement( 'body', [], true ), +} ); +assert.equal( + resolveFragmentTarget( /** @type {any} */ ( textBodyDocument ), '0' ), + textBodyDocument.body, + 'implicit BODY text selects BODY', +); + +const templateLeaf = fakeElement( 'strong' ); +const template = fakeElement( 'template' ); +template.content = { children: [ fakeElement( 'em' ), templateLeaf ] }; +const foreignRealmTemplateDocument = fakeContextDocument( { + body: fakeElement( 'body', [ fakeElement( 'main' ), template ] ), +} ); +assert.equal( + resolveFragmentTarget( + /** @type {any} */ ( foreignRealmTemplateDocument ), + '<!doctype html><body><main></main><template><em></em><strong>', + ), + templateLeaf, + 'template content is traversed without realm-specific instanceof checks', +); + +const framesetDocument = fakeContextDocument( { + body: fakeElement( 'frameset' ), +} ); +assert.equal( + resolveFragmentTarget( + /** @type {any} */ ( framesetDocument ), + '<!doctype html><frameset>', + ), + framesetDocument.body, + 'empty FRAMESET remains the context root', +); +assert.throws( + () => + resolveFragmentTarget( + /** @type {any} */ ( emptyContextDocument ), + /** @type {any} */ ( null ), + ), + TypeError, +); + +console.log( 'All byte preview tests passed.' ); diff --git a/tests/byte-transport-regression.php b/tests/byte-transport-regression.php new file mode 100644 index 0000000..4f2ca0a --- /dev/null +++ b/tests/byte-transport-regression.php @@ -0,0 +1,125 @@ +<?php +/** + * Regression tests for byte-safe transport helpers. + * + * Run with: + * + * php tests/byte-transport-regression.php + * + * @package HtmlApiDebugger + */ + +// phpcs:disable +// This standalone CLI harness intentionally writes TAP-like output. + +require dirname( __DIR__ ) . '/html-api-debugger/byte-transport.php'; + +/** + * Fail the test process. + * + * @param string $message Failure details. + */ +function html_api_debugger_transport_fail( string $message ): void { + fwrite( STDERR, "not ok - {$message}\n" ); + exit( 1 ); +} + +/** + * Assert strict equality. + * + * @param string $label Test label. + * @param mixed $expected Expected value. + * @param mixed $actual Actual value. + */ +function html_api_debugger_transport_assert_same( string $label, $expected, $actual ): void { + if ( $expected !== $actual ) { + html_api_debugger_transport_fail( + $label . "\nExpected: " . var_export( $expected, true ) . "\nActual: " . var_export( $actual, true ) + ); + } + + echo "ok - {$label}\n"; +} + +/** + * Assert that canonical base64url decoding rejects a value. + * + * @param string $encoded Invalid encoded value. + */ +function html_api_debugger_transport_assert_rejected( string $encoded ): void { + try { + HTML_API_Debugger\decode_base64url( $encoded ); + } catch ( InvalidArgumentException $e ) { + echo 'ok - rejects ' . var_export( $encoded, true ) . "\n"; + return; + } + + html_api_debugger_transport_fail( 'accepted non-canonical base64url ' . var_export( $encoded, true ) ); +} + +$all_bytes = ''; +for ( $byte = 0; $byte <= 0xff; ++$byte ) { + $all_bytes .= chr( $byte ); +} + +foreach ( + array( + 'empty bytes' => '', + 'all byte values' => $all_bytes, + 'raw FF' => "\xff", + 'UTF-8 C3 BF' => "\xc3\xbf", + ) as $label => $bytes +) { + $encoded = HTML_API_Debugger\encode_base64url( $bytes ); + html_api_debugger_transport_assert_same( + "{$label} round trips", + $bytes, + HTML_API_Debugger\decode_base64url( $encoded ) + ); +} + +html_api_debugger_transport_assert_same( 'raw FF spelling', '_w', HTML_API_Debugger\encode_base64url( "\xff" ) ); +html_api_debugger_transport_assert_same( 'UTF-8 C3 BF spelling', 'w78', HTML_API_Debugger\encode_base64url( "\xc3\xbf" ) ); + +foreach ( array( 'Zg==', '+w', '/w', 'a', 'Zh', '*', "_w\n", '💣' ) as $invalid ) { + html_api_debugger_transport_assert_rejected( $invalid ); +} + +$source_object = new stdClass(); +$source_object->utf8 = "\xc3\xbf"; +$source_object->number = 7; + +$enveloped = HTML_API_Debugger\envelope_response_strings( + array( + 'ascii' => 'ok', + 'bad' => "\xff", + 'nested' => $source_object, + 'false' => false, + 'null' => null, + ) +); + +html_api_debugger_transport_assert_same( + 'recursively envelopes strings and preserves protocol keys and scalars', + array( + 'ascii' => array( '__bytesBase64url' => 'b2s' ), + 'bad' => array( '__bytesBase64url' => '_w' ), + 'nested' => array( + 'utf8' => array( '__bytesBase64url' => 'w78' ), + 'number' => 7, + ), + 'false' => false, + 'null' => null, + ), + $enveloped +); + +html_api_debugger_transport_assert_same( 'does not mutate source objects', "\xc3\xbf", $source_object->utf8 ); + +$json = json_encode( $enveloped ); +if ( false === $json ) { + html_api_debugger_transport_fail( 'enveloped malformed UTF-8 must JSON encode' ); +} +echo "ok - enveloped malformed UTF-8 JSON encodes\n"; + +echo "All byte transport regression tests passed.\n"; diff --git a/tests/byte-transport.mjs b/tests/byte-transport.mjs new file mode 100644 index 0000000..04248d2 --- /dev/null +++ b/tests/byte-transport.mjs @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; + +import { + decodeBase64url, + decodeUtf8, + encodeBase64url, + encodeUtf8, + formatByteRows, + isByteEnvelope, + isValidUtf8, + projectResponseStrings, + projectUtf8, +} from '../html-api-debugger/byte-transport.mjs'; + +const allBytes = Uint8Array.from( { length: 256 }, ( _, index ) => index ); + +for ( const [ label, bytes ] of [ + [ 'empty bytes', new Uint8Array() ], + [ 'all byte values', allBytes ], + [ 'raw FF', Uint8Array.of( 0xff ) ], + [ 'UTF-8 C3 BF', Uint8Array.of( 0xc3, 0xbf ) ], +] ) { + assert.deepEqual( + decodeBase64url( encodeBase64url( bytes ) ), + bytes, + `${ label } round trips`, + ); +} + +assert.equal( encodeBase64url( Uint8Array.of( 0xff ) ), '_w' ); +assert.equal( encodeBase64url( Uint8Array.of( 0xc3, 0xbf ) ), 'w78' ); + +for ( const invalid of [ + 'Zg==', + '+w', + '/w', + 'a', + 'Zh', + 'Zm9', + '*', + '_w\n', + '💣', +] ) { + assert.throws( + () => decodeBase64url( invalid ), + TypeError, + `rejects ${ JSON.stringify( invalid ) }`, + ); +} + +const bomText = '\ufeffA'; +const bomBytes = Uint8Array.of( 0xef, 0xbb, 0xbf, 0x41 ); +assert.equal( decodeUtf8( bomBytes ), bomText, 'UTF-8 decoding preserves BOM' ); +assert.deepEqual( encodeUtf8( decodeUtf8( bomBytes ) ), bomBytes ); +assert.equal( isValidUtf8( bomBytes ), true ); + +const malformed = Uint8Array.of( 0x41, 0xff, 0x42 ); +assert.equal( isValidUtf8( malformed ), false ); +assert.throws( () => decodeUtf8( malformed ), TypeError ); +assert.equal( projectUtf8( malformed ), 'A\ufffdB' ); +assert.notDeepEqual( encodeUtf8( projectUtf8( malformed ) ), malformed ); + +const rawResponse = { + ok: { __bytesBase64url: 'b2s' }, + bad: { __bytesBase64url: '_w' }, + nested: [ { __bytesBase64url: 'w78' }, 7, false, null ], +}; +const projectedResponse = projectResponseStrings( rawResponse ); +assert.deepEqual( projectedResponse, { + ok: 'ok', + bad: '\ufffd', + nested: [ 'ÿ', 7, false, null ], +} ); +assert.deepEqual( rawResponse, { + ok: { __bytesBase64url: 'b2s' }, + bad: { __bytesBase64url: '_w' }, + nested: [ { __bytesBase64url: 'w78' }, 7, false, null ], +} ); + +assert.equal( isByteEnvelope( { __bytesBase64url: '' } ), true ); +assert.equal( isByteEnvelope( { __bytesBase64url: '', extra: 1 } ), false ); +assert.equal( isByteEnvelope( { __bytesBase64url: 1 } ), false ); +assert.equal( isByteEnvelope( [ { __bytesBase64url: '' } ] ), false ); + +for ( const invalidResponse of [ + 'unenveloped', + { nested: 'unenveloped' }, + { __bytesBase64url: 1 }, + { __bytesBase64url: '', extra: null }, + { __bytesBase64url: '*' }, + new Uint8Array(), + undefined, + Number.NaN, +] ) { + assert.throws( + () => projectResponseStrings( invalidResponse ), + TypeError, + 'rejects a non-protocol response value', + ); +} + +const dangerous = JSON.parse( + '{"__proto__":{"__bytesBase64url":"b2s"},"safe":{"__bytesBase64url":"b2s"}}', +); +const projectedDangerous = projectResponseStrings( dangerous ); +assert.equal( Object.getPrototypeOf( projectedDangerous ), Object.prototype ); +assert.equal( + Object.prototype.hasOwnProperty.call( projectedDangerous, '__proto__' ), + true, +); +assert.equal( projectedDangerous.__proto__, 'ok' ); +assert.equal( projectedDangerous.safe, 'ok' ); + +assert.deepEqual( + formatByteRows( + Uint8Array.of( 0x20, 0x41, 0x7e, 0x1f, 0x80, 0xff ), + 4, + ), + [ + { offset: 0, hex: '20 41 7E 1F', gutter: ' A~\ufffd' }, + { offset: 4, hex: '80 FF', gutter: '\ufffd\ufffd' }, + ], +); + +for ( const invalidWidth of [ 0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY ] ) { + assert.throws( + () => formatByteRows( allBytes, invalidWidth ), + RangeError, + `rejects row width ${ invalidWidth }`, + ); +} + +console.log( 'All browser byte transport tests passed.' ); diff --git a/tests/canonical-url-browser.html b/tests/canonical-url-browser.html new file mode 100644 index 0000000..f90908b --- /dev/null +++ b/tests/canonical-url-browser.html @@ -0,0 +1,81 @@ +<!doctype html> +<meta charset="utf-8"> +<title>Canonical URL browser regression</title> +<pre id="result">RUNNING</pre> +<script type="module"> + import { + parseCanonicalUrl, + serializeCanonicalUrl, + } from '../html-api-debugger/canonical-url.mjs'; + import { ByteRuntimeController } from '../html-api-debugger/runtime-controller.mjs'; + + const output = document.getElementById( 'result' ); + const input = new URL( + 'http://localhost:8888/wp-admin/admin.php?page=html-api-debugger&format=v1&html64=PGZvbz4&context64&selector&opts', + ); + + try { + const parsed = parseCanonicalUrl( input ); + if ( new TextDecoder( 'utf-8', { fatal: true } ).decode( parsed.htmlBytes ) !== '<foo>' ) { + throw new Error( 'HTML bytes did not decode to <foo>' ); + } + if ( + parsed.contextBytes.length !== 0 || + parsed.selector !== '' || + parsed.opts !== '' || + parsed.needsCanonicalization !== true + ) { + throw new Error( 'bare empty fields were not accepted as noncanonical empty values' ); + } + + const canonical = serializeCanonicalUrl( input, parsed ); + if ( + canonical.href !== + 'http://localhost:8888/wp-admin/admin.php?page=html-api-debugger&format=v1&html64=PGZvbz4&context64=&selector=&opts=' + ) { + throw new Error( `unexpected canonical URL: ${ canonical.href }` ); + } + + const runtimeUrl = new URL( document.location.href ); + runtimeUrl.search = + '?format=v1&html64=PGZvbz4&context64&selector&opts'; + let replacements = 0; + const controller = new ByteRuntimeController( { + url: runtimeUrl, + supports: { create_fragment_advanced: true }, + request: async () => { + throw new Error( 'browser constructor regression must not request' ); + }, + replaceUrl: ( url ) => { + ++replacements; + window.history.replaceState( null, '', url.href ); + }, + confirmConversion: () => false, + } ); + if ( controller.urlError !== null ) { + throw new Error( `controller URL error: ${ controller.urlError }` ); + } + if ( replacements !== 1 ) { + throw new Error( `expected one history rewrite, got ${ replacements }` ); + } + if ( + document.location.search !== + '?format=v1&html64=PGZvbz4&context64=&selector=&opts=' + ) { + throw new Error( `unexpected rewritten location: ${ document.location.href }` ); + } + + document.body.dataset.result = 'pass'; + output.textContent = 'PASS'; + void fetch( '/__canonical_url_result__/pass', { mode: 'no-cors' } ).catch( + () => {}, + ); + } catch ( error ) { + document.body.dataset.result = 'fail'; + output.textContent = `FAIL\n${ error.stack ?? error }`; + void fetch( '/__canonical_url_result__/fail', { mode: 'no-cors' } ).catch( + () => {}, + ); + throw error; + } +</script> diff --git a/tests/canonical-url.mjs b/tests/canonical-url.mjs new file mode 100644 index 0000000..db7237b --- /dev/null +++ b/tests/canonical-url.mjs @@ -0,0 +1,195 @@ +import assert from 'node:assert/strict'; + +import { + CanonicalUrlError, + canonicalUrlPath, + parseCanonicalUrl, + serializeCanonicalUrl, +} from '../html-api-debugger/canonical-url.mjs'; + +const admin = 'https://example.test/wp-admin/admin.php?page=html-api-debugger'; +const emptyState = { + htmlBytes: new Uint8Array(), + contextBytes: new Uint8Array(), + selector: '', + opts: '', +}; + +const bare = parseCanonicalUrl( new URL( admin ) ); +assert.deepEqual( bare, { ...emptyState, needsCanonicalization: true } ); + +const canonicalEmpty = serializeCanonicalUrl( new URL( admin ), emptyState ); +assert.equal( + canonicalEmpty.href, + `${ admin }&format=v1&html64=&context64=&selector=&opts=`, +); +assert.deepEqual( parseCanonicalUrl( canonicalEmpty ), { + ...emptyState, + needsCanonicalization: false, +} ); + +const bareEmptyUrl = new URL( + `${ admin }&format=v1&html64=PGZvbz4&context64&selector&opts`, +); +const parsedBareEmpty = parseCanonicalUrl( bareEmptyUrl ); +assert.deepEqual( + parsedBareEmpty.htmlBytes, + new TextEncoder().encode( '<foo>' ), +); +assert.deepEqual( parsedBareEmpty.contextBytes, new Uint8Array() ); +assert.equal( parsedBareEmpty.selector, '' ); +assert.equal( parsedBareEmpty.opts, '' ); +assert.equal( parsedBareEmpty.needsCanonicalization, true ); +assert.equal( + serializeCanonicalUrl( bareEmptyUrl, parsedBareEmpty ).href, + `${ admin }&format=v1&html64=PGZvbz4&context64=&selector=&opts=`, +); + +const allBytes = Uint8Array.from( { length: 256 }, ( _, index ) => index ); +const state = { + htmlBytes: allBytes, + contextBytes: Uint8Array.of( 0xff ), + selector: '.emoji-💣 > [title="ÿ"]', + opts: 'CiV', +}; +const canonical = serializeCanonicalUrl( new URL( `${ admin }&unrelated=kept` ), state ); +const reparsed = parseCanonicalUrl( new URL( canonical.href ) ); +assert.deepEqual( reparsed.htmlBytes, allBytes ); +assert.deepEqual( reparsed.contextBytes, Uint8Array.of( 0xff ) ); +assert.equal( reparsed.selector, state.selector ); +assert.equal( reparsed.opts, state.opts ); +assert.equal( reparsed.needsCanonicalization, false ); +assert.equal( canonical.searchParams.get( 'unrelated' ), 'kept' ); + +const rawFf = serializeCanonicalUrl( new URL( admin ), { + ...emptyState, + htmlBytes: Uint8Array.of( 0xff ), +} ); +const utf8Ff = serializeCanonicalUrl( new URL( admin ), { + ...emptyState, + htmlBytes: Uint8Array.of( 0xc3, 0xbf ), +} ); +assert.equal( rawFf.searchParams.get( 'html64' ), '_w' ); +assert.equal( utf8Ff.searchParams.get( 'html64' ), 'w78' ); + +for ( const opts of [ '', 'C', 'c', 'I', 'i', 'V', 'v', 'CIV', 'civ', 'CiV' ] ) { + const url = serializeCanonicalUrl( new URL( admin ), { ...emptyState, opts } ); + assert.equal( parseCanonicalUrl( url ).opts, opts ); +} + +for ( const opts of [ 'IC', 'CC', 'Ii', 'X', 'C V' ] ) { + assert.throws( + () => + serializeCanonicalUrl( new URL( admin ), { + ...emptyState, + opts, + } ), + CanonicalUrlError, + ); +} + +for ( const invalidQuery of [ + 'html64=&context64=&selector=&opts=', + 'format=v2&html64=&context64=&selector=&opts=', + 'format=v%31&html64=&context64=&selector=&opts=', + 'format=v1&html64=&context64=&selector=&opts=&html64=', + 'format=v1&html64=&context64=&selector=&opts=&html=x', + 'format=v1&html64=Zg%3D%3D&context64=&selector=&opts=', + 'format=v1&html64=%5Fw&context64=&selector=&opts=', + 'format=v1&html64=+w&context64=&selector=&opts=', + 'format=v1&html64=Zh&context64=&selector=&opts=', + 'format=v1&html64=&context64=%5Fw&selector=&opts=', + 'format=v1&ht%6Dl64=&context64=&selector=&opts=', + 'format=v1&html64=&context64&context64=&selector=&opts=', + 'format=v1&html64=&context64=&selector=&opts=%43', + 'format=v1&html64=&context64=&selector=&opts=IC', + 'html=x', + 'contextHTML=x', + 'html-opts=C', + 'selector=.x', +] ) { + assert.throws( + () => parseCanonicalUrl( new URL( `${ admin }&${ invalidQuery }` ) ), + CanonicalUrlError, + invalidQuery, + ); +} + +for ( const rawSelector of [ + '%', + '%0', + '%GG', + '%FF', + '%C3', + '%C0%AF', + '%ED%A0%80', + '%f0%9f%92%a3', +] ) { + assert.throws( + () => + parseCanonicalUrl( + new URL( + `${ admin }&format=v1&html64=&context64=&selector=${ rawSelector }&opts=`, + ), + ), + CanonicalUrlError, + rawSelector, + ); +} + +const intentionalReplacement = parseCanonicalUrl( + new URL( + `${ admin }&format=v1&html64=&context64=&selector=%EF%BF%BD&opts=`, + ), +); +assert.equal( intentionalReplacement.selector, '\ufffd' ); + +assert.throws( + () => + serializeCanonicalUrl( new URL( admin ), { + ...emptyState, + selector: '\ud800', + } ), + CanonicalUrlError, +); +assert.throws( + () => + serializeCanonicalUrl( new URL( admin ), { + ...emptyState, + selector: '\udc00', + } ), + CanonicalUrlError, +); + +const dirtyBase = new URL( + `${ admin }&html=old&contextHTML=old&html-opts=C&keep=yes`, +); +const cleaned = serializeCanonicalUrl( dirtyBase, emptyState ); +assert.equal( cleaned.searchParams.has( 'html' ), false ); +assert.equal( cleaned.searchParams.has( 'contextHTML' ), false ); +assert.equal( cleaned.searchParams.has( 'html-opts' ), false ); +assert.equal( cleaned.searchParams.get( 'keep' ), 'yes' ); +assert.match( + cleaned.search, + /keep=yes&format=v1&html64=&context64=&selector=&opts=$/u, +); + +const playground = new URL( + 'https://playground.wordpress.net/?plugin=html-api-debugger', +); +playground.searchParams.set( 'url', canonicalUrlPath( rawFf ) ); +const nestedPath = playground.searchParams.get( 'url' ); +assert.equal( nestedPath, canonicalUrlPath( rawFf ) ); +const nested = parseCanonicalUrl( new URL( nestedPath, 'https://example.test' ) ); +assert.deepEqual( nested.htmlBytes, Uint8Array.of( 0xff ) ); +assert.equal( + new URL( nestedPath, 'https://example.test' ).searchParams.get( 'html64' ), + '_w', +); + +assert.deepEqual( + parseCanonicalUrl( new URL( `${ admin }&unrelated=only` ) ), + { ...emptyState, needsCanonicalization: true }, +); + +console.log( 'All canonical URL tests passed.' ); diff --git a/tests/fragment-context-browser.html b/tests/fragment-context-browser.html new file mode 100644 index 0000000..cf5b7d4 --- /dev/null +++ b/tests/fragment-context-browser.html @@ -0,0 +1,152 @@ +<!doctype html> +<meta charset="utf-8"> +<title>Fragment context browser regression</title> +<pre id="result">RUNNING</pre> +<script type="module"> + import { resolveFragmentTarget } from '../html-api-debugger/byte-preview.mjs'; + + const output = document.getElementById( 'result' ); + + function assertName( documentToTest, source, expected, label ) { + const actual = resolveFragmentTarget( documentToTest, source ).nodeName; + if ( actual !== expected ) { + throw new Error( `${ label }: expected ${ expected }, got ${ actual }` ); + } + } + + function parseDetached( source, Parser = DOMParser ) { + return new Parser().parseFromString( source, 'text/html' ); + } + + async function parseNavigated( source ) { + const iframe = document.createElement( 'iframe' ); + iframe.hidden = true; + iframe.setAttribute( 'sandbox', 'allow-same-origin' ); + const loaded = new Promise( ( resolve ) => { + iframe.addEventListener( 'load', resolve, { once: true } ); + } ); + iframe.srcdoc = source; + document.body.append( iframe ); + await loaded; + if ( iframe.contentDocument === null ) { + throw new Error( 'sandboxed document is not inspectable' ); + } + return iframe; + } + + try { + const detachedCases = [ + [ '<!doctype html><head>', 'HEAD', 'empty HEAD' ], + [ '<!doctype html><body>', 'BODY', 'empty BODY' ], + [ '<!doctype html><html>', 'HTML', 'empty HTML' ], + [ '0', 'BODY', 'implicit BODY text' ], + [ + '<!doctype html><head><!-- <body> --><title>x</title>', + 'TITLE', + 'comment cannot author BODY', + ], + [ + '<!doctype html><head><title>x</title></head><body>', + 'BODY', + 'authored empty BODY outranks populated HEAD', + ], + [ + '<!doctype html><body><template><main><template><span>', + 'SPAN', + 'nested template content', + ], + [ + '<!doctype html><body><template><span></template><main>', + 'MAIN', + 'later sibling after template', + ], + ]; + for ( const [ source, expected, label ] of detachedCases ) { + assertName( parseDetached( source ), source, expected, label ); + } + + const realmIframe = await parseNavigated( '<!doctype html>' ); + const ForeignParser = realmIframe.contentWindow.DOMParser; + const foreignSource = '<!doctype html><body><template><em></em><strong>'; + assertName( + parseDetached( foreignSource, ForeignParser ), + foreignSource, + 'STRONG', + 'foreign-realm template content', + ); + realmIframe.remove(); + + const liveCases = [ + [ '<noscript><body>', 'BODY', 'scripting-disabled NOSCRIPT' ], + [ + '<!doctype html><head><script><!--<script><\/script><body><\/script>', + 'SCRIPT', + 'double-escaped SCRIPT data', + ], + [ + '<!doctype html><head><script><!x<script><\/script><body><\/script>', + 'BODY', + 'SCRIPT escape-start fallback', + ], + [ + '<!doctype html><head><script><!-x<script><\/script><body><\/script>', + 'BODY', + 'SCRIPT escape-start-dash fallback', + ], + [ '<!doctype html><!--><body>', 'BODY', 'abrupt empty comment' ], + [ '<body a=b">', 'BODY', 'quote in unquoted attribute value' ], + [ '<body =">', 'BODY', 'equals-sign attribute name' ], + [ + '<title></title x="</title><body>', + 'TITLE', + 'incomplete appropriate RCDATA end tag', + ], + [ + '<!doctype html><head><meta ="x><body></body><!--">', + 'BODY', + 'equals-sign attribute does not swallow BODY', + ], + [ + '<!DOCTYPE html SYSTEM "x><body>">', + 'BODY', + 'abrupt quoted DOCTYPE system identifier', + ], + [ + '<!DOCTYPE html SYSTEM "x<body">', + 'HTML', + 'markup text inside quoted DOCTYPE system identifier', + ], + [ '<!doctype html><template><body></template>', 'TEMPLATE', 'BODY token in TEMPLATE' ], + [ '<!doctype html><frameset>', 'FRAMESET', 'empty FRAMESET' ], + ]; + for ( const [ source, expected, label ] of liveCases ) { + const iframe = await parseNavigated( source ); + assertName( iframe.contentDocument, source, expected, label ); + iframe.remove(); + } + + delete window.__htmlApiDebuggerSandboxEscape; + const hostileIframe = await parseNavigated( + '<scr' + + 'ipt>parent.__htmlApiDebuggerSandboxEscape = true;</scr' + + 'ipt>', + ); + if ( window.__htmlApiDebuggerSandboxEscape === true ) { + throw new Error( 'sandboxed input executed script in the parent origin' ); + } + hostileIframe.remove(); + + document.body.dataset.result = 'pass'; + output.textContent = 'PASS'; + void fetch( '/__fragment_context_result__/pass', { mode: 'no-cors' } ).catch( + () => {}, + ); + } catch ( error ) { + document.body.dataset.result = 'fail'; + output.textContent = `FAIL\n${ error.stack ?? error }`; + void fetch( '/__fragment_context_result__/fail', { mode: 'no-cors' } ).catch( + () => {}, + ); + throw error; + } +</script> diff --git a/tests/html-api-integration-regression.php b/tests/html-api-integration-regression.php index 6dfc1b0..0d9abe9 100644 --- a/tests/html-api-integration-regression.php +++ b/tests/html-api-integration-regression.php @@ -155,6 +155,47 @@ function html_api_debugger_assert_tree( string $label, string $html, ?string $co $body_context = '<!DOCTYPE html><body>'; +try { + HTML_API_Debugger\HTML_API_Integration\get_tree( + 'x', + array( + 'context_html' => '0', + 'selector' => null, + ) + ); + echo "not ok - zero context enters fragment processing\n"; + exit( 1 ); +} catch ( Exception $error ) { + if ( 'Could not create processor from context HTML.' !== $error->getMessage() ) { + throw $error; + } + echo "ok - zero context enters fragment processing\n"; +} + +if ( + null !== HTML_API_Debugger\HTML_API_Integration\get_normalized_html( + 'x', + array( 'context_html' => '0' ) + ) +) { + echo "not ok - zero context controls normalized fragment processing\n"; + exit( 1 ); +} +echo "ok - zero context controls normalized fragment processing\n"; + +$noscript_context_result = HTML_API_Debugger\HTML_API_Integration\get_tree( + 'x', + array( + 'context_html' => '<noscript><body>', + 'selector' => null, + ) +); +if ( 'BODY' !== $noscript_context_result['contextNode'] ) { + echo "not ok - scripting-disabled NOSCRIPT context selects BODY\n"; + exit( 1 ); +} +echo "ok - scripting-disabled NOSCRIPT context selects BODY\n"; + html_api_debugger_assert_tree( 'full parser preserves document nesting', '<div><p>a</p>b</div>c', diff --git a/tests/legacy-url-regression.php b/tests/legacy-url-regression.php new file mode 100644 index 0000000..d55a866 --- /dev/null +++ b/tests/legacy-url-regression.php @@ -0,0 +1,212 @@ +<?php +/** + * Regression tests for legacy URL migration. + * + * Run with: + * + * php tests/legacy-url-regression.php + * + * @package HtmlApiDebugger + */ + +// phpcs:disable +// This standalone CLI harness intentionally defines a WordPress stub and writes TAP-like output. + +function wp_unslash( $value ) { + if ( is_array( $value ) ) { + return array_map( 'wp_unslash', $value ); + } + return stripslashes( $value ); +} + +require dirname( __DIR__ ) . '/html-api-debugger/byte-transport.php'; +require dirname( __DIR__ ) . '/html-api-debugger/legacy-url.php'; + +function html_api_debugger_legacy_fail( string $message ): void { + fwrite( STDERR, "not ok - {$message}\n" ); + exit( 1 ); +} + +function html_api_debugger_legacy_assert_same( string $label, $expected, $actual ): void { + if ( $expected !== $actual ) { + html_api_debugger_legacy_fail( + $label . "\nExpected: " . var_export( $expected, true ) . "\nActual: " . var_export( $actual, true ) + ); + } + echo "ok - {$label}\n"; +} + +function html_api_debugger_legacy_slash( string $value ): string { + return addslashes( $value ); +} + +function html_api_debugger_legacy_migrate( array $query ): ?array { + foreach ( $query as $key => $value ) { + if ( is_string( $value ) ) { + $query[ $key ] = html_api_debugger_legacy_slash( $value ); + } + } + return HTML_API_Debugger\get_legacy_redirect_params( $query ); +} + +$raw_ff = html_api_debugger_legacy_migrate( array( 'html' => "\xff" ) ); +$utf8_ff = html_api_debugger_legacy_migrate( array( 'html' => "\xc3\xbf" ) ); +html_api_debugger_legacy_assert_same( 'raw FF becomes _w', '_w', $raw_ff['html64'] ); +html_api_debugger_legacy_assert_same( 'UTF-8 C3 BF becomes w78', 'w78', $utf8_ff['html64'] ); + +$acceptance_bytes = hex2bin( '3c696672616d653e41ff423c2f696672616d653e' ); +if ( false === $acceptance_bytes ) { + html_api_debugger_legacy_fail( 'could not construct acceptance bytes' ); +} +$acceptance = html_api_debugger_legacy_migrate( array( 'html' => $acceptance_bytes ) ); +html_api_debugger_legacy_assert_same( + 'acceptance bytes survive legacy migration', + $acceptance_bytes, + HTML_API_Debugger\decode_base64url( $acceptance['html64'] ) +); + +$slashes_and_nul = "a\\b\0c"; +$escaped = html_api_debugger_legacy_migrate( array( 'html' => $slashes_and_nul ) ); +html_api_debugger_legacy_assert_same( + 'literal backslashes and NUL survive exactly one unslash', + $slashes_and_nul, + HTML_API_Debugger\decode_base64url( $escaped['html64'] ) +); + +$complete = html_api_debugger_legacy_migrate( + array( + 'html' => '', + 'contextHTML' => " \n", + 'selector' => '.emoji-💣', + 'html-opts' => 'CicVvI?c', + ) +); +html_api_debugger_legacy_assert_same( + 'canonical migration has exactly five ordered fields', + array( 'format', 'html64', 'context64', 'selector', 'opts' ), + array_keys( $complete ) +); +html_api_debugger_legacy_assert_same( 'empty HTML is preserved', '', $complete['html64'] ); +html_api_debugger_legacy_assert_same( + 'context whitespace is preserved', + " \n", + HTML_API_Debugger\decode_base64url( $complete['context64'] ) +); +html_api_debugger_legacy_assert_same( 'Unicode selector is preserved', '.emoji-💣', $complete['selector'] ); +html_api_debugger_legacy_assert_same( 'legacy options use last-wins canonical order', 'cIv', $complete['opts'] ); + +$selector_only = html_api_debugger_legacy_migrate( array( 'selector' => '.x' ) ); +html_api_debugger_legacy_assert_same( 'selector-only URL migrates', '.x', $selector_only['selector'] ); +html_api_debugger_legacy_assert_same( 'selector-only URL gets empty byte fields', '', $selector_only['html64'] . $selector_only['context64'] ); + +$options_only = html_api_debugger_legacy_migrate( array( 'html-opts' => 'vCCi' ) ); +html_api_debugger_legacy_assert_same( 'html-opts-only URL migrates', 'Civ', $options_only['opts'] ); + +html_api_debugger_legacy_assert_same( + 'bare query does not migrate', + null, + HTML_API_Debugger\get_legacy_redirect_params( array() ) +); + +foreach ( + array( + array( 'format' => 'v2' ), + array( 'format' => 'v1', 'html' => 'x' ), + array( 'html64' => 'eA' ), + array( 'context64' => '', 'contextHTML' => 'x' ), + array( 'opts' => 'C' ), + array( 'html' => 'x', 'opts' => 'C' ), + array( 'selector' => '.x', 'opts' => 'C' ), + array( 'html-opts' => 'V', 'opts' => 'C' ), + ) as $canonical_or_mixed +) { + html_api_debugger_legacy_assert_same( + 'canonical or mixed input never falls back', + null, + HTML_API_Debugger\get_legacy_redirect_params( $canonical_or_mixed ) + ); +} + +$invalid_queries = array( + null, + array( 'html' => array( 'x' ) ), + array( 'contextHTML' => array() ), + array( 'selector' => array( '.x' ) ), + array( 'html-opts' => false ), + array( 'selector' => "\xff" ), +); +foreach ( $invalid_queries as $invalid_query ) { + try { + HTML_API_Debugger\get_legacy_redirect_params( $invalid_query ); + html_api_debugger_legacy_fail( 'invalid legacy input was accepted' ); + } catch ( InvalidArgumentException $e ) { + echo "ok - invalid legacy input is rejected\n"; + } +} + +$redirect_params = HTML_API_Debugger\get_legacy_redirect_params( + array( + 'html' => html_api_debugger_legacy_slash( "\xff" ), + 'contextHTML' => html_api_debugger_legacy_slash( "\xc3\xbf" ), + 'selector' => html_api_debugger_legacy_slash( '*~ !()💣' ), + 'html-opts' => html_api_debugger_legacy_slash( 'CiV' ), + ) +); +$redirect_url = HTML_API_Debugger\build_canonical_admin_url( + 'https://example.test/wp-admin/admin.php', + 'html-api-debugger', + $redirect_params +); +html_api_debugger_legacy_assert_same( + 'canonical redirect spelling matches browser form encoding', + 'https://example.test/wp-admin/admin.php?page=html-api-debugger&format=v1&html64=_w&context64=w78&selector=*%7E+%21%28%29%F0%9F%92%A3&opts=CiV', + $redirect_url +); + +$query = array(); +parse_str( (string) parse_url( $redirect_url, PHP_URL_QUERY ), $query ); +html_api_debugger_legacy_assert_same( 'redirect query round-trips raw FF', "\xff", HTML_API_Debugger\decode_base64url( $query['html64'] ) ); +html_api_debugger_legacy_assert_same( 'redirect query round-trips UTF-8 C3 BF', "\xc3\xbf", HTML_API_Debugger\decode_base64url( $query['context64'] ) ); +html_api_debugger_legacy_assert_same( 'redirect query round-trips selector', '*~ !()💣', $query['selector'] ); + +$redirect_with_base_query = HTML_API_Debugger\build_canonical_admin_url( + 'https://example.test/wp-admin/admin.php?unrelated=a%20b', + 'html-api-debugger', + $redirect_params +); +html_api_debugger_legacy_assert_same( + 'unrelated base query spelling is preserved', + true, + 0 === strpos( $redirect_with_base_query, 'https://example.test/wp-admin/admin.php?unrelated=a%20b&page=' ) +); + +$invalid_redirects = array( + array( 'https://example.test/wp-admin/admin.php', 'x&format=v2', $redirect_params ), + array( 'https://example.test/wp-admin/admin.php', 'x y', $redirect_params ), + array( 'https://example.test/wp-admin/admin.php', 'x%0A', $redirect_params ), + array( "https://example.test/wp-admin/admin.php\r\n", 'html-api-debugger', $redirect_params ), + array( 'https://example.test/wp admin/admin.php', 'html-api-debugger', $redirect_params ), + array( 'ftp://example.test/admin.php', 'html-api-debugger', $redirect_params ), + array( 'https:///admin.php', 'html-api-debugger', $redirect_params ), + array( 'https://example.test/admin.php#fragment', 'html-api-debugger', $redirect_params ), + array( 'https://example.test/admin.php?format=v2', 'html-api-debugger', $redirect_params ), + array( 'https://example.test/admin.php?html64=bad', 'html-api-debugger', $redirect_params ), + array( 'https://example.test/admin.php?ht%6Dl64=bad', 'html-api-debugger', $redirect_params ), + array( 'https://example.test/admin.php?page=other', 'html-api-debugger', $redirect_params ), + array( 'https://example.test/admin.php?%GG=x', 'html-api-debugger', $redirect_params ), + array( 'https://example.test/admin.php', 'html-api-debugger', array_merge( $redirect_params, array( 'extra' => '' ) ) ), + array( 'https://example.test/admin.php', 'html-api-debugger', array_merge( $redirect_params, array( 'format' => 'v2' ) ) ), + array( 'https://example.test/admin.php', 'html-api-debugger', array_merge( $redirect_params, array( 'html64' => 'Zg==' ) ) ), + array( 'https://example.test/admin.php', 'html-api-debugger', array_merge( $redirect_params, array( 'selector' => "\xff" ) ) ), + array( 'https://example.test/admin.php', 'html-api-debugger', array_merge( $redirect_params, array( 'opts' => 'IC' ) ) ), +); +foreach ( $invalid_redirects as $invalid_redirect ) { + try { + HTML_API_Debugger\build_canonical_admin_url( $invalid_redirect[0], $invalid_redirect[1], $invalid_redirect[2] ); + html_api_debugger_legacy_fail( 'invalid canonical redirect was accepted' ); + } catch ( InvalidArgumentException $e ) { + echo "ok - invalid canonical redirect is rejected\n"; + } +} + +echo "All legacy URL migration tests passed.\n"; diff --git a/tests/main-wiring.mjs b/tests/main-wiring.mjs new file mode 100644 index 0000000..f8be177 --- /dev/null +++ b/tests/main-wiring.mjs @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; + +const plugin = new URL( '../html-api-debugger/', import.meta.url ); + +async function source( name ) { + return readFile( new URL( name, plugin ), 'utf8' ); +} + +function runtimeRelativeImports( text ) { + return [ + ...text.matchAll( + /^\s*import\s+(?:[^'"\n]+?\s+from\s+)?['"](\.[^'"]+)['"];?$/gmu, + ), + ].map( ( match ) => match[ 1 ] ); +} + +const graph = { + 'main.mjs': await source( 'main.mjs' ), + 'runtime-controller.mjs': await source( 'runtime-controller.mjs' ), + 'canonical-url.mjs': await source( 'canonical-url.mjs' ), + 'response-transport.mjs': await source( 'response-transport.mjs' ), + 'runtime-wiring.mjs': await source( 'runtime-wiring.mjs' ), + 'ui-transactions.mjs': await source( 'ui-transactions.mjs' ), + 'byte-preview.mjs': await source( 'byte-preview.mjs' ), + 'byte-transport.mjs': await source( 'byte-transport.mjs' ), +}; + +assert.deepEqual( runtimeRelativeImports( graph[ 'main.mjs' ] ), [ + './byte-preview.mjs?ver=3.4', + './byte-transport.mjs?ver=3.4', + './runtime-controller.mjs?ver=3.4', + './runtime-wiring.mjs?ver=3.4', + './ui-transactions.mjs?ver=3.4', +] ); +assert.deepEqual( runtimeRelativeImports( graph[ 'runtime-controller.mjs' ] ), [ + './canonical-url.mjs?ver=3.4', + './byte-preview.mjs?ver=3.4', + './byte-transport.mjs?ver=3.4', + './response-transport.mjs?ver=3.4', +] ); +assert.deepEqual( runtimeRelativeImports( graph[ 'canonical-url.mjs' ] ), [ + './byte-transport.mjs?ver=3.4', +] ); +assert.deepEqual( runtimeRelativeImports( graph[ 'response-transport.mjs' ] ), [ + './byte-transport.mjs?ver=3.4', +] ); +assert.deepEqual( runtimeRelativeImports( graph[ 'runtime-wiring.mjs' ] ), [] ); +assert.deepEqual( runtimeRelativeImports( graph[ 'ui-transactions.mjs' ] ), [] ); +assert.deepEqual( runtimeRelativeImports( graph[ 'byte-preview.mjs' ] ), [] ); +assert.deepEqual( runtimeRelativeImports( graph[ 'byte-transport.mjs' ] ), [] ); + +for ( const [ file, text ] of Object.entries( graph ) ) { + for ( const specifier of runtimeRelativeImports( text ) ) { + assert.match( specifier, /\?ver=3\.4$/u, `${ file } has an unversioned live relative import` ); + } +} + +const main = graph[ 'main.mjs' ]; +assert.match( main, /new ByteRequestBoundary\s*\(/u ); +assert.match( main, /new ByteRuntimeController\s*\(/u ); +assert.match( main, /request:\s*\( body \) => requestBoundary\.request\( body \)/u ); +assert.match( + main, + /window\.history\.replaceState\( null, '', url\.href \)/u, +); +assert.match( main, /new ByteDocumentPreview\s*\( RENDERED_IFRAME \)/u ); +assert.match( main, /new BytePreviewCoordinator\s*\(/u ); +assert.match( main, /previewCoordinator\.render\s*\(/u ); +assert.match( main, /controller\.getPreviewPlan\s*\(/u ); +assert.match( main, /requestBoundary\.dispose\s*\(/u ); +assert.match( main, /function beginPendingResponse\s*\(\)/u ); +assert.match( main, /store\.state\.playbackPoint\s*=\s*null;[\s\S]+?store\.state\.htmlapiResponse\s*=\s*\{/u ); +assert.match( main, /async function settleControllerOperation\s*\([^)]*\)\s*\{\s*if \( controller\.isProcessing \) \{\s*beginPendingResponse\(\);/u ); +assert.match( main, /beginUiOperation\(\s*\(\) => controller\.editSource\( 'html', text \),[\s\S]+?store\.state\.playbackPoint = null;[\s\S]+?if \( ! started\.started \) \{[\s\S]+?settleControllerOperation\( started\.value \)/u ); +assert.match( main, /beginUiOperation\(\s*\(\) => controller\.setSelector\( selector \),[\s\S]+?store\.state\.selector = controller\.selector;[\s\S]+?settleControllerOperation\( started\.value \)/u ); +assert.match( main, /const previousOverride = booleanConfigurationOverrides\[ stateKey \];[\s\S]+?beginUiOperation\([\s\S]+?controller\.setOpts\( getExplicitHtmlOptions\(\) \);[\s\S]+?booleanConfigurationOverrides\[ stateKey \] = previousOverride;[\s\S]+?store\.state\[ stateKey \] = checked;/u ); +assert.match( main, /const conversionStarted =[\s\S]+?wasMalformed && isValidUtf8\( sourceBytes\( kind \) \);[\s\S]+?store\.state\[ `\$\{ kind \}View` \] = 'text';[\s\S]+?renderPreview\(\);[\s\S]+?const applied = await settleUiConversion/u ); +assert.match( main, /settleUiConversion\( operation,[\s\S]+?applyControllerResponse\(\);[\s\S]+?if \( ! applied \) \{/u ); +assert.match( main, /watch\(\)\s*\{\s*renderHtmlApiOutput\(\);\s*redrawCurrentDomTree\(\);/u ); +assert.match( main, /resolveFragmentTarget\(\s*document,\s*projectUtf8\( controller\.contextBytes \)/u ); +assert.doesNotMatch( main, /\.document\.write\s*\(|\.write\s*\(\s*html/u ); +assert.doesNotMatch( main, /searchParams\.(?:set|get|has|delete)\(\s*['"](?:html|contextHTML|html-opts)['"]/u ); +assert.doesNotMatch( main, /RENDERED_IFRAME\.src\s*=/u ); + +console.log( 'All main wiring tests passed.' ); diff --git a/tests/plugin-cutover-regression.php b/tests/plugin-cutover-regression.php new file mode 100644 index 0000000..507d82f --- /dev/null +++ b/tests/plugin-cutover-regression.php @@ -0,0 +1,283 @@ +<?php +/** + * Regression tests for the atomic byte-safe plugin cutover. + * + * Run with: + * + * php tests/plugin-cutover-regression.php + * + * @package HtmlApiDebugger + */ + +// phpcs:disable +// This standalone CLI harness intentionally defines WordPress stubs and writes TAP-like output. + +class WP_HTML_Processor { +} + +$test_hooks = array(); +$test_routes = array(); +$test_modules = array(); +$test_styles = array(); +$test_enqueued_modules = array(); +$test_menu_page_callback = null; +$test_interactivity_config = array(); +$test_interactivity_state = array(); + +function add_action( $hook, $callback, $priority = 10, $accepted_args = 1 ) { + global $test_hooks; + $test_hooks[ $hook ][ $priority ][] = array( $callback, $accepted_args ); +} + +function do_action( $hook, ...$args ) { + global $test_hooks; + $priorities = $test_hooks[ $hook ] ?? array(); + ksort( $priorities ); + foreach ( $priorities as $callbacks ) { + foreach ( $callbacks as $registered ) { + call_user_func_array( $registered[0], array_slice( $args, 0, $registered[1] ) ); + } + } +} + +function register_rest_route( $namespace, $route, $options ) { + global $test_routes; + $test_routes[] = array( $namespace, $route, $options ); +} + +function current_user_can( $capability ) { + return 'edit_posts' === $capability; +} + +function wp_register_script_module( $id, $src, $dependencies = array(), $version = false ) { + global $test_modules; + $test_modules[ $id ] = array( + 'src' => $src, + 'dependencies' => $dependencies, + 'version' => $version, + ); +} + +function wp_enqueue_script_module( $id ) { + global $test_enqueued_modules; + $test_enqueued_modules[] = $id; +} + +function wp_enqueue_style( $id, $src, $dependencies = array(), $version = false ) { + global $test_styles; + $test_styles[ $id ] = array( + 'src' => $src, + 'dependencies' => $dependencies, + 'version' => $version, + ); +} + +function plugins_url( $path, $file ) { + return 'https://example.test/wp-content/plugins/html-api-debugger/' . $path; +} + +function add_menu_page( $page_title, $menu_title, $capability, $slug, $callback, $icon ) { + global $test_menu_page_callback; + $test_menu_page_callback = $callback; + return 'toplevel_page_' . $slug; +} + +function rest_url( $path ) { + return 'https://example.test/wp-json/' . $path; +} + +function wp_create_nonce( $action ) { + return 'test-nonce-' . $action; +} + +function wp_interactivity_config( $namespace, $config ) { + global $test_interactivity_config; + $test_interactivity_config[ $namespace ] = $config; +} + +function wp_interactivity_state( $namespace, $state ) { + global $test_interactivity_state; + $test_interactivity_state[ $namespace ] = $state; +} + +function wp_interactivity_process_directives( $html ) { + return $html; +} + +function esc_attr( $value ) { + return htmlspecialchars( $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8' ); +} + +function wp_unslash( $value ) { + if ( is_array( $value ) ) { + return array_map( 'wp_unslash', $value ); + } + return stripslashes( $value ); +} + +function admin_url( $path ) { + return 'https://example.test/wp-admin/' . $path; +} + +function wp_safe_redirect( $location, $status = 302, $application = '' ) { + echo 'REDIRECT:', $status, ':', $application, ':', $location; + return true; +} + +function wp_die( $message, $title = '', $args = array() ) { + echo 'DIE:', $args['response'] ?? 500, ':', $title, ':', $message; + exit; +} + +function html_api_debugger_cutover_fail( string $message ): void { + fwrite( STDERR, "not ok - {$message}\n" ); + exit( 1 ); +} + +function html_api_debugger_cutover_assert_same( string $label, $expected, $actual ): void { + if ( $expected !== $actual ) { + html_api_debugger_cutover_fail( + $label . "\nExpected: " . var_export( $expected, true ) . "\nActual: " . var_export( $actual, true ) + ); + } + echo "ok - {$label}\n"; +} + +$mode = $argv[1] ?? ''; +if ( 'redirect' === $mode ) { + $_GET = array( + 'page' => 'html-api-debugger', + 'html' => addslashes( "\xff" ), + ); + require dirname( __DIR__ ) . '/html-api-debugger/html-api-debugger.php'; + do_action( 'init' ); + do_action( 'admin_init' ); + html_api_debugger_cutover_fail( 'legacy redirect callback returned' ); +} + +if ( 'invalid' === $mode ) { + $_GET = array( + 'page' => 'html-api-debugger', + 'html' => array( 'invalid' ), + ); + require dirname( __DIR__ ) . '/html-api-debugger/html-api-debugger.php'; + do_action( 'init' ); + do_action( 'admin_init' ); + html_api_debugger_cutover_fail( 'invalid legacy callback returned' ); +} + +require dirname( __DIR__ ) . '/html-api-debugger/html-api-debugger.php'; + +html_api_debugger_cutover_assert_same( 'plugin version constant is cache-busted', '3.4', HTML_API_Debugger\VERSION ); + +do_action( 'init' ); + +$admin_init_callbacks = $test_hooks['admin_init'][0] ?? array(); +html_api_debugger_cutover_assert_same( 'legacy redirect is registered at admin_init priority zero', 1, count( $admin_init_callbacks ) ); +html_api_debugger_cutover_assert_same( + 'legacy redirect hook uses the named callback', + 'HTML_API_Debugger\\maybe_redirect_legacy_url', + $admin_init_callbacks[0][0] +); + +do_action( 'rest_api_init' ); +html_api_debugger_cutover_assert_same( 'exactly one REST route is registered', 1, count( $test_routes ) ); +html_api_debugger_cutover_assert_same( 'only the v2 REST namespace is active', 'html-api-debugger/v2', $test_routes[0][0] ); +html_api_debugger_cutover_assert_same( 'v2 REST route is POST-only', 'POST', $test_routes[0][2]['methods'] ); +html_api_debugger_cutover_assert_same( + 'v2 REST route uses the byte handler', + 'HTML_API_Debugger\\handle_byte_htmlapi_request', + $test_routes[0][2]['callback'] +); + +foreach ( $test_modules as $id => $module ) { + html_api_debugger_cutover_assert_same( "module {$id} uses version 3.4", '3.4', $module['version'] ); +} +html_api_debugger_cutover_assert_same( + 'main module is registered', + true, + isset( $test_modules['@html-api-debugger/main'] ) +); + +do_action( 'admin_enqueue_scripts', 'toplevel_page_html-api-debugger' ); +html_api_debugger_cutover_assert_same( 'debugger stylesheet uses version 3.4', '3.4', $test_styles['html-api-debugger']['version'] ); +html_api_debugger_cutover_assert_same( 'main module is enqueued on the debugger page', array( '@html-api-debugger/main' ), $test_enqueued_modules ); + +do_action( 'admin_menu' ); +html_api_debugger_cutover_assert_same( 'admin page callback is registered', true, is_callable( $test_menu_page_callback ) ); + +function html_api_debugger_render_shell( array $query ): array { + global $test_menu_page_callback, $test_interactivity_config, $test_interactivity_state; + $_GET = $query; + $test_interactivity_config = array(); + $test_interactivity_state = array(); + ob_start(); + call_user_func( $test_menu_page_callback ); + $html = ob_get_clean(); + return array( $html, $test_interactivity_config, $test_interactivity_state ); +} + +$first_shell = html_api_debugger_render_shell( + array( + 'page' => 'html-api-debugger', + 'format' => 'v1', + 'html64' => '_w', + 'context64' => '', + ) +); +$second_shell = html_api_debugger_render_shell( + array( + 'page' => 'html-api-debugger', + 'format' => 'future', + 'html64' => array( 'hostile' ), + 'contextHTML' => "\xff", + ) +); +html_api_debugger_cutover_assert_same( 'application shell is independent of every query value', $first_shell, $second_shell ); +html_api_debugger_cutover_assert_same( + 'shell config points only at the v2 byte endpoint', + 'https://example.test/wp-json/html-api-debugger/v2/htmlapi', + $first_shell[1]['html-api-debugger']['restEndpoint'] +); +html_api_debugger_cutover_assert_same( + 'shell contains byte inspection controls', + true, + false !== strpos( $first_shell[0], 'Exact REST response envelopes' ) && false !== strpos( $first_shell[0], 'Convert and edit as UTF-8' ) +); +html_api_debugger_cutover_assert_same( + 'rendered input is inspectable but has no active sandbox capabilities', + true, + false !== strpos( $first_shell[0], 'sandbox="allow-same-origin"' ) && + false === strpos( $first_shell[0], 'allow-scripts' ) && + false === strpos( $first_shell[0], 'allow-forms' ) && + false === strpos( $first_shell[0], 'allow-modals' ) && + false === strpos( $first_shell[0], 'allow-popups' ) +); + +$page_reflection = new ReflectionFunction( 'HTML_API_Debugger\\Interactivity\\generate_page' ); +html_api_debugger_cutover_assert_same( 'shell generator accepts no input', 0, $page_reflection->getNumberOfParameters() ); + +$redirect_command = escapeshellarg( PHP_BINARY ) . ' ' . escapeshellarg( __FILE__ ) . ' redirect'; +$redirect_output = array(); +$redirect_status = null; +exec( $redirect_command, $redirect_output, $redirect_status ); +$redirect_text = implode( "\n", $redirect_output ); +html_api_debugger_cutover_assert_same( 'activated legacy redirect exits successfully', 0, $redirect_status ); +html_api_debugger_cutover_assert_same( + 'activated redirect preserves raw FF as canonical _w before rendering', + true, + false !== strpos( $redirect_text, 'format=v1&html64=_w&context64=&selector=&opts=' ) +); + +$invalid_command = escapeshellarg( PHP_BINARY ) . ' ' . escapeshellarg( __FILE__ ) . ' invalid'; +$invalid_output = array(); +$invalid_status = null; +exec( $invalid_command, $invalid_output, $invalid_status ); +html_api_debugger_cutover_assert_same( 'invalid legacy URL exits through wp_die', 0, $invalid_status ); +html_api_debugger_cutover_assert_same( + 'invalid legacy URL produces a generic visible 400', + true, + false !== strpos( implode( "\n", $invalid_output ), 'DIE:400:Invalid HTML API Debugger URL:Invalid legacy HTML API Debugger URL.' ) +); + +echo "All plugin cutover regression tests passed.\n"; diff --git a/tests/response-transport.mjs b/tests/response-transport.mjs new file mode 100644 index 0000000..246c24b --- /dev/null +++ b/tests/response-transport.mjs @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; + +import { encodeBase64url } from '../html-api-debugger/byte-transport.mjs'; +import { decodeHtmlApiResponse } from '../html-api-debugger/response-transport.mjs'; + +/** @param {Uint8Array} bytes */ +const envelope = ( bytes ) => ( { + __bytesBase64url: encodeBase64url( bytes ), +} ); +/** @param {string} text */ +const textEnvelope = ( text ) => envelope( new TextEncoder().encode( text ) ); + +const rawFf = Uint8Array.of( 0xff ); +const utf8Replacement = Uint8Array.of( 0xef, 0xbf, 0xbd ); +const playbackSource = Uint8Array.of( 0xc3, 0xbf, 0xff, 0x41 ); +const raw = { + supports: { + create_fragment_advanced: true, + selectors: true, + }, + html: envelope( rawFf ), + error: envelope( utf8Replacement ), + normalizedHtml: envelope( rawFf ), + result: { + tree: { + nodeType: 9, + nodeName: textEnvelope( '#document' ), + childNodes: [ + { + nodeType: 3, + nodeName: textEnvelope( '#text' ), + nodeValue: envelope( rawFf ), + _span: { start: 0, length: 1 }, + }, + ], + }, + playback: [ + [ envelope( playbackSource ), { childNodes: [] } ], + [ envelope( rawFf ), { childNodes: [] } ], + ], + warnings: [ textEnvelope( 'warning' ) ], + }, +}; +const snapshot = JSON.stringify( raw ); +const decoded = decodeHtmlApiResponse( raw ); + +assert.equal( decoded.raw, raw ); +assert.equal( JSON.stringify( raw ), snapshot ); +assert.match( snapshot, /"__bytesBase64url"/u ); +assert.deepEqual( decoded.htmlBytes, rawFf ); +assert.deepEqual( decoded.normalizedBytes, rawFf ); +assert.deepEqual( decoded.errorBytes, utf8Replacement ); +assert.deepEqual( decoded.playbackBytes, [ playbackSource, rawFf ] ); +assert.equal( decoded.projected.html, '\ufffd' ); +assert.equal( decoded.projected.normalizedHtml, '\ufffd' ); +assert.equal( decoded.projected.error, '\ufffd' ); +assert.equal( decoded.projected.result.tree.nodeName, '#document' ); +assert.equal( decoded.projected.result.tree.childNodes[0].nodeValue, '\ufffd' ); +assert.equal( decoded.projected.result.playback[0][0], 'ÿ\ufffdA' ); +assert.equal( decoded.projected.result.warnings[0], 'warning' ); +assert.notDeepEqual( decoded.normalizedBytes, decoded.errorBytes ); + +const nullResponse = { + supports: {}, + html: textEnvelope( '' ), + error: null, + normalizedHtml: null, + result: null, +}; +const decodedNull = decodeHtmlApiResponse( nullResponse ); +assert.equal( decodedNull.normalizedBytes, null ); +assert.equal( decodedNull.errorBytes, null ); +assert.deepEqual( decodedNull.playbackBytes, [] ); + +const base = () => ( { + supports: {}, + html: textEnvelope( '' ), + error: null, + normalizedHtml: null, + result: null, +} ); + +const invalidResponses = [ + 'unenveloped', + { ...base(), html: 'bare' }, + { ...base(), html: { __bytesBase64url: '*' } }, + { ...base(), html: null }, + ( () => { + const value = base(); + delete value.html; + return value; + } )(), + { ...base(), supports: [] }, + { ...base(), result: [] }, + { ...base(), result: {} }, + { ...base(), result: { playback: [ [ textEnvelope( '' ) ] ] } }, + { ...base(), result: { playback: [ [ null, {} ] ] } }, + { ...base(), result: { playback: [ [ textEnvelope( '' ), {}, null ] ] } }, + { ...base(), extra: new Date() }, +]; + +for ( const invalid of invalidResponses ) { + assert.throws( + () => decodeHtmlApiResponse( invalid ), + TypeError, + 'rejects malformed response', + ); +} + +console.log( 'All response transport tests passed.' ); diff --git a/tests/rest-api-regression.php b/tests/rest-api-regression.php new file mode 100644 index 0000000..410435d --- /dev/null +++ b/tests/rest-api-regression.php @@ -0,0 +1,205 @@ +<?php +/** + * Regression tests for the byte-safe REST API transport. + * + * Run with: + * + * php tests/rest-api-regression.php + * + * @package HtmlApiDebugger + */ + +// phpcs:disable +// This standalone CLI harness intentionally defines WordPress stubs and writes TAP-like output. + +namespace { + class WP_REST_Request { + private $params; + + public function __construct( $params ) { + $this->params = $params; + } + + public function get_json_params() { + return $this->params; + } + } + + class WP_Error { + public $code; + public $message; + public $data; + + public function __construct( $code, $message, $data ) { + $this->code = $code; + $this->message = $message; + $this->data = $data; + } + } +} + +namespace HTML_API_Debugger { + $test_processing_calls = array(); + $test_response_object = null; + $test_use_non_ascii_key = false; + + function prepare_html_result_object( string $html, ?array $options = null ): array { + global $test_processing_calls, $test_response_object, $test_use_non_ascii_key; + + $test_processing_calls[] = array( $html, $options ); + + $test_response_object = new \stdClass(); + $test_response_object->label = "\xff"; + $test_response_object->start = 0; + + $response = array( + 'supports' => array( 'selectors' => true ), + 'html' => $html, + 'error' => null, + 'result' => array( + 'message' => 'ok', + 'span' => $test_response_object, + ), + 'normalizedHtml' => "\xff", + ); + + if ( $test_use_non_ascii_key ) { + $response[ "bad\xc3\xa9" ] = 'no'; + } + + return $response; + } +} + +namespace { + require dirname( __DIR__ ) . '/html-api-debugger/byte-transport.php'; + require dirname( __DIR__ ) . '/html-api-debugger/rest-api.php'; + + function html_api_debugger_rest_fail( string $message ): void { + fwrite( STDERR, "not ok - {$message}\n" ); + exit( 1 ); + } + + function html_api_debugger_rest_assert_same( string $label, $expected, $actual ): void { + if ( $expected !== $actual ) { + html_api_debugger_rest_fail( + $label . "\nExpected: " . var_export( $expected, true ) . "\nActual: " . var_export( $actual, true ) + ); + } + echo "ok - {$label}\n"; + } + + function html_api_debugger_rest_request( string $html, string $context = '', string $selector = '' ): WP_REST_Request { + return new WP_REST_Request( + array( + 'html64' => HTML_API_Debugger\encode_base64url( $html ), + 'context64' => HTML_API_Debugger\encode_base64url( $context ), + 'selector' => $selector, + ) + ); + } + + function html_api_debugger_rest_assert_enveloped( $value ): void { + if ( ! is_array( $value ) ) { + if ( is_string( $value ) || is_object( $value ) ) { + html_api_debugger_rest_fail( 'response contains an unenveloped string or object' ); + } + return; + } + + if ( array( '__bytesBase64url' ) === array_keys( $value ) ) { + if ( ! is_string( $value['__bytesBase64url'] ) ) { + html_api_debugger_rest_fail( 'byte envelope payload is not a string' ); + } + HTML_API_Debugger\decode_base64url( $value['__bytesBase64url'] ); + return; + } + + foreach ( $value as $key => $item ) { + if ( is_string( $key ) && 1 !== preg_match( '/\A[\x20-\x7E]+\z/D', $key ) ) { + html_api_debugger_rest_fail( 'response contains a non-ASCII key' ); + } + html_api_debugger_rest_assert_enveloped( $item ); + } + } + + $acceptance_bytes = hex2bin( '3c696672616d653e41ff423c2f696672616d653e' ); + if ( false === $acceptance_bytes ) { + html_api_debugger_rest_fail( 'could not construct acceptance bytes' ); + } + + $response = HTML_API_Debugger\handle_byte_htmlapi_request( + html_api_debugger_rest_request( $acceptance_bytes ) + ); + html_api_debugger_rest_assert_same( + 'acceptance bytes reach processing unchanged', + $acceptance_bytes, + $test_processing_calls[0][0] + ); + html_api_debugger_rest_assert_same( + 'response input envelope round trips acceptance bytes', + $acceptance_bytes, + HTML_API_Debugger\decode_base64url( $response['html']['__bytesBase64url'] ) + ); + + HTML_API_Debugger\handle_byte_htmlapi_request( html_api_debugger_rest_request( "\xff" ) ); + HTML_API_Debugger\handle_byte_htmlapi_request( html_api_debugger_rest_request( "\xc3\xbf" ) ); + html_api_debugger_rest_assert_same( 'raw FF reaches processing', "\xff", $test_processing_calls[1][0] ); + html_api_debugger_rest_assert_same( 'UTF-8 C3 BF reaches processing', "\xc3\xbf", $test_processing_calls[2][0] ); + + HTML_API_Debugger\handle_byte_htmlapi_request( + html_api_debugger_rest_request( 'x', " \n", ' div ' ) + ); + html_api_debugger_rest_assert_same( 'context whitespace is preserved', " \n", $test_processing_calls[3][1]['context_html'] ); + html_api_debugger_rest_assert_same( 'selector whitespace is preserved', ' div ', $test_processing_calls[3][1]['selector'] ); + + HTML_API_Debugger\handle_byte_htmlapi_request( html_api_debugger_rest_request( 'x', '0' ) ); + html_api_debugger_rest_assert_same( 'zero context remains fragment context', '0', $test_processing_calls[4][1]['context_html'] ); + + HTML_API_Debugger\handle_byte_htmlapi_request( html_api_debugger_rest_request( 'x' ) ); + html_api_debugger_rest_assert_same( 'empty context means document mode', null, $test_processing_calls[5][1]['context_html'] ); + html_api_debugger_rest_assert_same( 'empty selector means no selector', null, $test_processing_calls[5][1]['selector'] ); + + $valid_params = array( + 'html64' => 'eA', + 'context64' => '', + 'selector' => '', + ); + $invalid_requests = array( + null, + array(), + array( 'html64' => 'eA', 'context64' => '' ), + array_merge( $valid_params, array( 'extra' => '' ) ), + array_merge( $valid_params, array( 'html64' => 'Zg==' ) ), + array_merge( $valid_params, array( 'context64' => '*' ) ), + array_merge( $valid_params, array( 'html64' => array() ) ), + array_merge( $valid_params, array( 'selector' => "\xff" ) ), + ); + + foreach ( $invalid_requests as $invalid_request ) { + $call_count = count( $test_processing_calls ); + $error = HTML_API_Debugger\handle_byte_htmlapi_request( new WP_REST_Request( $invalid_request ) ); + html_api_debugger_rest_assert_same( 'invalid request returns WP_Error', true, $error instanceof WP_Error ); + html_api_debugger_rest_assert_same( 'invalid request error code is stable', 'html_api_debugger_invalid_byte_request', $error->code ); + html_api_debugger_rest_assert_same( 'invalid request error message is stable', 'Invalid byte transport request.', $error->message ); + html_api_debugger_rest_assert_same( 'invalid request status is 400', array( 'status' => 400 ), $error->data ); + html_api_debugger_rest_assert_same( 'invalid request is not processed', $call_count, count( $test_processing_calls ) ); + } + + html_api_debugger_rest_assert_enveloped( $response ); + html_api_debugger_rest_assert_same( 'response object was not mutated', "\xff", $test_response_object->label ); + if ( false === json_encode( $response ) ) { + html_api_debugger_rest_fail( 'enveloped malformed response must JSON encode' ); + } + echo "ok - complete malformed response is byte-enveloped and JSON-safe\n"; + + $test_use_non_ascii_key = true; + try { + HTML_API_Debugger\handle_byte_htmlapi_request( html_api_debugger_rest_request( 'x' ) ); + html_api_debugger_rest_fail( 'non-ASCII response key was accepted' ); + } catch ( UnexpectedValueException $e ) { + echo "ok - non-ASCII response keys are rejected\n"; + } + + echo "All byte-safe REST API regression tests passed.\n"; +} diff --git a/tests/runtime-controller.mjs b/tests/runtime-controller.mjs new file mode 100644 index 0000000..ec738a2 --- /dev/null +++ b/tests/runtime-controller.mjs @@ -0,0 +1,440 @@ +import assert from 'node:assert/strict'; + +import { + decodeBase64url, + encodeBase64url, +} from '../html-api-debugger/byte-transport.mjs'; +import { ByteRuntimeController } from '../html-api-debugger/runtime-controller.mjs'; + +const admin = 'https://example.test/wp-admin/admin.php?page=html-api-debugger'; + +/** @param {Uint8Array} bytes */ +const envelope = ( bytes ) => ( { __bytesBase64url: encodeBase64url( bytes ) } ); +/** @param {string} text */ +const textEnvelope = ( text ) => envelope( new TextEncoder().encode( text ) ); + +/** + * @param {{html64: string, context64: string, selector: string}} body + * @param {string} [label='current'] + */ +function responseFor( body, label = 'current' ) { + const html = decodeBase64url( body.html64 ); + return { + supports: { create_fragment_advanced: true, selectors: true }, + html: envelope( html ), + error: null, + normalizedHtml: envelope( html ), + result: { + tree: { + nodeType: 9, + nodeName: textEnvelope( label ), + childNodes: [], + }, + playback: [ + [ envelope( html ), { childNodes: [] } ], + [ envelope( Uint8Array.of( 0xc3, 0xbf, 0xff, 0x41 ) ), { childNodes: [] } ], + ], + warnings: [], + }, + }; +} + +const bareRequests = []; +const replacedUrls = []; +const bareController = new ByteRuntimeController( { + url: new URL( admin ), + supports: { create_fragment_advanced: true }, + request: async ( body ) => { + bareRequests.push( body ); + return responseFor( body ); + }, + replaceUrl: ( url ) => replacedUrls.push( url ), + confirmConversion: () => false, +} ); +assert.equal( replacedUrls.length, 1 ); +assert.equal( replacedUrls[0].searchParams.get( 'format' ), 'v1' ); +await bareController.start(); +assert.equal( bareRequests.length, 1 ); +assert.deepEqual( bareRequests[0], { html64: '', context64: '', selector: '' } ); + +const bareEmptyRequests = []; +const bareEmptyReplacements = []; +const bareEmptyController = new ByteRuntimeController( { + url: new URL( + `${ admin }&format=v1&html64=PGZvbz4&context64&selector&opts`, + ), + supports: { create_fragment_advanced: true }, + request: async ( body ) => { + bareEmptyRequests.push( body ); + return responseFor( body ); + }, + replaceUrl: ( url ) => bareEmptyReplacements.push( url ), + confirmConversion: () => false, +} ); +assert.equal( bareEmptyController.urlError, null ); +assert.equal( bareEmptyReplacements.length, 1 ); +assert.equal( + bareEmptyReplacements[ 0 ].href, + `${ admin }&format=v1&html64=PGZvbz4&context64=&selector=&opts=`, +); +await bareEmptyController.start(); +assert.deepEqual( bareEmptyRequests, [ + { html64: 'PGZvbz4', context64: '', selector: '' }, +] ); + +for ( const suffix of [ + '&format=v2&html64=&context64=&selector=&opts=', + '&format=v1&html64=Zg==&context64=&selector=&opts=', + '&html=%FF', +] ) { + let requests = 0; + const invalid = new ByteRuntimeController( { + url: new URL( `${ admin }${ suffix }` ), + supports: { create_fragment_advanced: true }, + request: async () => { + ++requests; + return null; + }, + replaceUrl: () => {}, + confirmConversion: () => false, + } ); + assert.equal( typeof invalid.urlError, 'string' ); + await invalid.start(); + assert.equal( requests, 0 ); +} + +const acceptanceBytes = Uint8Array.from( + Buffer.from( '3c696672616d653e41ff423c2f696672616d653e', 'hex' ), +); +const acceptanceUrl = new URL( + `${ admin }&format=v1&html64=${ encodeBase64url( acceptanceBytes ) }&context64=&selector=&opts=`, +); +const acceptanceBodies = []; +const acceptanceController = new ByteRuntimeController( { + url: acceptanceUrl, + supports: { create_fragment_advanced: true }, + request: async ( body ) => { + acceptanceBodies.push( body ); + return responseFor( body ); + }, + replaceUrl: () => {}, + confirmConversion: () => false, +} ); +await acceptanceController.start(); +assert.deepEqual( + decodeBase64url( acceptanceBodies[0].html64 ), + acceptanceBytes, +); +assert.deepEqual( acceptanceController.getProcessedBytes(), acceptanceBytes ); +assert.ok( acceptanceController.rawResponse.html.__bytesBase64url ); + +const returnedHtml = acceptanceController.htmlBytes; +returnedHtml[0] = 0; +assert.deepEqual( acceptanceController.htmlBytes, acceptanceBytes ); +const returnedProcessed = acceptanceController.getProcessedBytes(); +returnedProcessed[0] = 0; +assert.deepEqual( acceptanceController.getProcessedBytes(), acceptanceBytes ); +assert.equal( '_htmlBytes' in acceptanceController, false ); +assert.equal( '_contextBytes' in acceptanceController, false ); +assert.equal( '_decodedResponse' in acceptanceController, false ); + +let allowConversion = false; +const conversionRequests = []; +const conversionUrls = []; +const conversionController = new ByteRuntimeController( { + url: new URL( + `${ admin }&format=v1&html64=_w&context64=&selector=&opts=`, + ), + supports: { create_fragment_advanced: true }, + request: async ( body ) => { + conversionRequests.push( body ); + return responseFor( body ); + }, + replaceUrl: ( url ) => conversionUrls.push( url ), + confirmConversion: () => allowConversion, +} ); +await conversionController.start(); +assert.equal( await conversionController.requestTextEditing( 'html' ), null ); +assert.deepEqual( conversionController.htmlBytes, Uint8Array.of( 0xff ) ); +assert.equal( conversionRequests.length, 1 ); +allowConversion = true; +assert.equal( await conversionController.requestTextEditing( 'html' ), '\ufffd' ); +assert.deepEqual( + conversionController.htmlBytes, + Uint8Array.of( 0xef, 0xbf, 0xbd ), +); +assert.equal( conversionRequests.at( -1 ).html64, '77-9' ); +assert.equal( conversionUrls.at( -1 ).searchParams.get( 'html64' ), '77-9' ); + +await conversionController.editSource( 'html', 'ÿ' ); +assert.equal( conversionRequests.at( -1 ).html64, 'w78' ); +await conversionController.editSource( 'context', " \n" ); +assert.deepEqual( + decodeBase64url( conversionRequests.at( -1 ).context64 ), + new TextEncoder().encode( " \n" ), +); +await conversionController.setSelector( '.x' ); +assert.equal( conversionRequests.at( -1 ).selector, '.x' ); +const requestsBeforeOpts = conversionRequests.length; +conversionController.setOpts( 'CiV' ); +assert.equal( conversionRequests.length, requestsBeforeOpts ); +assert.equal( conversionController.getCanonicalUrl().searchParams.get( 'opts' ), 'CiV' ); + +const fragmentBytes = Uint8Array.of( 0x41, 0xff, 0x42 ); +const contextBytes = new TextEncoder().encode( '<!DOCTYPE html><body>' ); +const fragmentUrl = new URL( + `${ admin }&format=v1&html64=${ encodeBase64url( fragmentBytes ) }&context64=${ encodeBase64url( contextBytes ) }&selector=&opts=`, +); +const supported = new ByteRuntimeController( { + url: fragmentUrl, + supports: { create_fragment_advanced: true }, + request: async ( body ) => responseFor( body ), + replaceUrl: () => {}, + confirmConversion: () => false, +} ); +const supportedPlan = supported.getPreviewPlan(); +assert.deepEqual( supportedPlan.documentBytes, contextBytes ); +assert.deepEqual( supportedPlan.fragment.bytes, fragmentBytes ); +assert.equal( supportedPlan.fragment.text, 'A\ufffdB' ); +assert.equal( supportedPlan.fragment.lossy, true ); +supportedPlan.documentBytes[0] = 0; +supportedPlan.fragment.bytes[0] = 0; +assert.deepEqual( supported.contextBytes, contextBytes ); +assert.deepEqual( supported.getProcessedBytes(), fragmentBytes ); +const returnedContext = supported.contextBytes; +returnedContext[0] = 0; +assert.deepEqual( supported.contextBytes, contextBytes ); + +const unsupported = new ByteRuntimeController( { + url: fragmentUrl, + supports: { create_fragment_advanced: false }, + request: async ( body ) => responseFor( body ), + replaceUrl: () => {}, + confirmConversion: () => false, +} ); +const unsupportedPlan = unsupported.getPreviewPlan(); +assert.deepEqual( unsupportedPlan.documentBytes, fragmentBytes ); +assert.equal( unsupportedPlan.fragment, null ); +assert.equal( + unsupported.getCanonicalUrl().searchParams.get( 'context64' ), + encodeBase64url( contextBytes ), +); + +await supported.start(); +assert.deepEqual( + supported.getProcessedBytes( 1 ), + Uint8Array.of( 0xc3, 0xbf, 0xff, 0x41 ), +); +const returnedPlayback = supported.getProcessedBytes( 1 ); +returnedPlayback[0] = 0; +assert.deepEqual( + supported.getProcessedBytes( 1 ), + Uint8Array.of( 0xc3, 0xbf, 0xff, 0x41 ), +); +const span = supported.splitProcessedSpan( 2, 1, 1 ); +assert.deepEqual( span.before, Uint8Array.of( 0xc3, 0xbf ) ); +assert.deepEqual( span.current, Uint8Array.of( 0xff ) ); +assert.deepEqual( span.after, Uint8Array.of( 0x41 ) ); +span.before[0] = 0; +span.current[0] = 0; +span.after[0] = 0; +const freshSpan = supported.splitProcessedSpan( 2, 1, 1 ); +assert.deepEqual( freshSpan.before, Uint8Array.of( 0xc3, 0xbf ) ); +assert.deepEqual( freshSpan.current, Uint8Array.of( 0xff ) ); +assert.deepEqual( freshSpan.after, Uint8Array.of( 0x41 ) ); + +const playground = supported.getPlaygroundUrl( + new URL( 'https://playground.wordpress.net/?plugin=html-api-debugger' ), + 'CIV', + 'nightly', +); +assert.equal( playground.searchParams.get( 'wp' ), 'nightly' ); +const nested = new URL( + playground.searchParams.get( 'url' ), + 'https://example.test', +); +assert.equal( nested.searchParams.get( 'html64' ), encodeBase64url( fragmentBytes ) ); +assert.equal( nested.searchParams.get( 'context64' ), encodeBase64url( contextBytes ) ); +assert.equal( nested.searchParams.get( 'opts' ), 'CIV' ); +for ( const field of [ 'format', 'html64', 'context64', 'selector', 'opts' ] ) { + assert.equal( nested.searchParams.getAll( field ).length, 1 ); +} + +assert.equal( supported.isUrlUnusuallyLong( 1 ), true ); +assert.throws( () => supported.isUrlUnusuallyLong( 0 ), RangeError ); + +/** @returns {{promise: Promise<unknown>, resolve: (value: unknown) => void, reject: (error: unknown) => void}} */ +function deferred() { + let resolve; + let reject; + const promise = new Promise( ( resolvePromise, rejectPromise ) => { + resolve = resolvePromise; + reject = rejectPromise; + } ); + return { promise, resolve, reject }; +} + +const deferredRequests = []; +const raceController = new ByteRuntimeController( { + url: new URL( `${ admin }&format=v1&html64=QQ&context64=&selector=&opts=` ), + supports: { create_fragment_advanced: true }, + request: ( body ) => { + const wait = deferred(); + deferredRequests.push( { body, wait } ); + return wait.promise; + }, + replaceUrl: () => {}, + confirmConversion: () => false, +} ); +const firstProcess = raceController.start(); +const secondProcess = raceController.editSource( 'html', 'B' ); +assert.equal( raceController.rawResponse, null ); +assert.deepEqual( raceController.getProcessedBytes(), Uint8Array.of( 0x42 ) ); +assert.deepEqual( raceController.getPreviewPlan().documentBytes, Uint8Array.of( 0x42 ) ); + +deferredRequests[1].wait.resolve( responseFor( deferredRequests[1].body, 'new' ) ); +await secondProcess; +assert.equal( raceController.projectedResponse.result.tree.nodeName, 'new' ); +deferredRequests[0].wait.resolve( responseFor( deferredRequests[0].body, 'old' ) ); +await firstProcess; +assert.equal( raceController.projectedResponse.result.tree.nodeName, 'new' ); +assert.deepEqual( raceController.getProcessedBytes(), Uint8Array.of( 0x42 ) ); + +const rejectedRequests = []; +const staleFailureController = new ByteRuntimeController( { + url: new URL( `${ admin }&format=v1&html64=QQ&context64=&selector=&opts=` ), + supports: { create_fragment_advanced: true }, + request: ( body ) => { + const wait = deferred(); + rejectedRequests.push( { body, wait } ); + return wait.promise; + }, + replaceUrl: () => {}, + confirmConversion: () => false, +} ); +const staleFailure = staleFailureController.start(); +const currentSuccess = staleFailureController.editSource( 'html', 'B' ); +rejectedRequests[1].wait.resolve( + responseFor( rejectedRequests[1].body, 'new-after-failure' ), +); +await currentSuccess; +rejectedRequests[0].wait.reject( new Error( 'stale failure' ) ); +assert.equal( await staleFailure, null ); +assert.equal( + staleFailureController.projectedResponse.result.tree.nodeName, + 'new-after-failure', +); + +let rejectUrlWrite = false; +const transactionalRequests = []; +const transactionalUrl = new URL( + `${ admin }&format=v1&html64=QQ&context64=&selector=&opts=`, +); +const transactionalController = new ByteRuntimeController( { + url: transactionalUrl, + supports: { create_fragment_advanced: true }, + request: ( body ) => { + const wait = deferred(); + transactionalRequests.push( { body, wait } ); + return wait.promise; + }, + replaceUrl: () => { + if ( rejectUrlWrite ) { + throw new Error( 'history rejected URL' ); + } + }, + confirmConversion: () => true, +} ); +const existingRequest = transactionalController.start(); +rejectUrlWrite = true; +assert.throws( + () => transactionalController.editSource( 'html', 'B' ), + /history rejected URL/u, +); +assert.deepEqual( transactionalController.htmlBytes, Uint8Array.of( 0x41 ) ); +assert.equal( transactionalController.getCanonicalUrl().href, transactionalUrl.href ); +assert.equal( transactionalRequests.length, 1 ); +assert.equal( transactionalController.isProcessing, true ); +assert.throws( + () => transactionalController.editSource( 'context', '<body>' ), + /history rejected URL/u, +); +assert.deepEqual( transactionalController.contextBytes, new Uint8Array() ); +assert.throws( + () => transactionalController.setSelector( '.failed' ), + /history rejected URL/u, +); +assert.equal( transactionalController.selector, '' ); +assert.throws( + () => transactionalController.setOpts( 'C' ), + /history rejected URL/u, +); +assert.equal( transactionalController.opts, '' ); +assert.equal( transactionalRequests.length, 1 ); +transactionalRequests[0].wait.resolve( + responseFor( transactionalRequests[0].body, 'existing' ), +); +await existingRequest; + +const malformedWriteFailure = new ByteRuntimeController( { + url: new URL( `${ admin }&format=v1&html64=_w&context64=&selector=&opts=` ), + supports: { create_fragment_advanced: true }, + request: async () => { + throw new Error( 'must not request' ); + }, + replaceUrl: () => { + throw new Error( 'history rejected conversion' ); + }, + confirmConversion: () => true, +} ); +assert.throws( + () => malformedWriteFailure.requestTextEditing( 'html' ), + /history rejected conversion/u, +); +assert.deepEqual( malformedWriteFailure.htmlBytes, Uint8Array.of( 0xff ) ); +assert.equal( + malformedWriteFailure.getCanonicalUrl().searchParams.get( 'html64' ), + '_w', +); +assert.equal( malformedWriteFailure.isProcessing, false ); + +const canonicalizationWriteFailure = new ByteRuntimeController( { + url: new URL( admin ), + supports: { create_fragment_advanced: true }, + request: async () => null, + replaceUrl: () => { + throw new Error( 'history rejected canonical URL' ); + }, + confirmConversion: () => false, +} ); +assert.match( canonicalizationWriteFailure.urlError, /history rejected canonical URL/u ); + +const conversionRaceRequests = []; +const conversionRaceController = new ByteRuntimeController( { + url: new URL( `${ admin }&format=v1&html64=_w&context64=&selector=&opts=` ), + supports: { create_fragment_advanced: true }, + request: ( body ) => { + const wait = deferred(); + conversionRaceRequests.push( { body, wait } ); + return wait.promise; + }, + replaceUrl: () => {}, + confirmConversion: () => true, +} ); +const staleConversion = conversionRaceController.requestTextEditing( 'html' ); +const conversionWinningEdit = conversionRaceController.editSource( 'html', 'B' ); +conversionRaceRequests[1].wait.resolve( + responseFor( conversionRaceRequests[1].body, 'edit-after-conversion' ), +); +await conversionWinningEdit; +conversionRaceRequests[0].wait.resolve( + responseFor( conversionRaceRequests[0].body, 'stale-conversion' ), +); +assert.equal( await staleConversion, null ); +assert.equal( + conversionRaceController.projectedResponse.result.tree.nodeName, + 'edit-after-conversion', +); +assert.deepEqual( conversionRaceController.htmlBytes, Uint8Array.of( 0x42 ) ); + +console.log( 'All runtime controller tests passed.' ); diff --git a/tests/runtime-wiring-browser.html b/tests/runtime-wiring-browser.html new file mode 100644 index 0000000..b3aba27 --- /dev/null +++ b/tests/runtime-wiring-browser.html @@ -0,0 +1,72 @@ +<!doctype html> +<meta charset="utf-8"> +<title>Runtime wiring browser regression</title> +<pre id="result">RUNNING</pre> +<script type="module"> + import { + ByteRequestBoundary, + SupersededRuntimeOperationError, + } from '../html-api-debugger/runtime-wiring.mjs'; + + const output = document.getElementById( 'result' ); + try { + const fetchBodies = []; + const boundary = new ByteRequestBoundary( { + endpoint: '/html-api-debugger/v2/htmlapi', + nonce: 'browser-nonce', + fetch: async ( url, options ) => { + fetchBodies.push( { url, body: JSON.parse( options.body ) } ); + return new Response( JSON.stringify( { current: true } ), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } ); + }, + AbortController, + delay: 0, + } ); + + const first = boundary.request( { + html64: 'QQ', + context64: '', + selector: '', + } ); + const firstRejected = first.then( + () => false, + ( error ) => error instanceof SupersededRuntimeOperationError, + ); + const secondBody = { + html64: 'Qg', + context64: 'IA', + selector: '.x', + }; + const second = boundary.request( secondBody ); + if ( ! ( await firstRejected ) ) { + throw new Error( 'superseded default timer did not reject correctly' ); + } + const result = await second; + if ( result.current !== true ) { + throw new Error( 'current default-timer request did not resolve' ); + } + if ( + fetchBodies.length !== 1 || + fetchBodies[ 0 ].url !== '/html-api-debugger/v2/htmlapi' || + JSON.stringify( fetchBodies[ 0 ].body ) !== JSON.stringify( secondBody ) + ) { + throw new Error( 'default timer did not preserve the newest request' ); + } + boundary.dispose(); + + document.body.dataset.result = 'pass'; + output.textContent = 'PASS'; + void fetch( '/__runtime_wiring_result__/pass', { mode: 'no-cors' } ).catch( + () => {}, + ); + } catch ( error ) { + document.body.dataset.result = 'fail'; + output.textContent = `FAIL\n${ error.stack ?? error }`; + void fetch( '/__runtime_wiring_result__/fail', { mode: 'no-cors' } ).catch( + () => {}, + ); + throw error; + } +</script> diff --git a/tests/runtime-wiring.mjs b/tests/runtime-wiring.mjs new file mode 100644 index 0000000..b3409c0 --- /dev/null +++ b/tests/runtime-wiring.mjs @@ -0,0 +1,436 @@ +import assert from 'node:assert/strict'; + +import { ByteDocumentPreview } from '../html-api-debugger/byte-preview.mjs'; +import { + BytePreviewCoordinator, + ByteRequestBoundary, + DisposedRuntimeBoundaryError, + SupersededRuntimeOperationError, +} from '../html-api-debugger/runtime-wiring.mjs'; + +function deferred() { + let resolve; + let reject; + const promise = new Promise( ( res, rej ) => { + resolve = res; + reject = rej; + } ); + return { promise, resolve, reject }; +} + +function fakeResponse( value, { nonce = null, ok = true } = {} ) { + return { + ok, + headers: { + get( name ) { + return name === 'X-WP-Nonce' ? nonce : null; + }, + }, + async json() { + return value; + }, + }; +} + +function createClock() { + let nextId = 1; + const tasks = new Map(); + return { + setTimer( callback, delay ) { + assert.equal( delay, 150 ); + const id = nextId++; + tasks.set( id, callback ); + return id; + }, + clearTimer( id ) { + tasks.delete( id ); + }, + run() { + const callbacks = [ ...tasks.values() ]; + tasks.clear(); + for ( const callback of callbacks ) { + callback(); + } + }, + get size() { + return tasks.size; + }, + }; +} + +class FakeAbortController { + constructor() { + this.signal = { aborted: false, reason: undefined }; + } + + abort( reason ) { + this.signal.aborted = true; + this.signal.reason = reason; + } +} + +const clock = createClock(); +const fetches = []; +const requestBoundary = new ByteRequestBoundary( { + endpoint: 'https://example.test/wp-json/html-api-debugger/v2/htmlapi', + nonce: 'nonce-1', + fetch: ( url, options ) => { + const response = deferred(); + fetches.push( { url, options, response } ); + return response.promise; + }, + AbortController: FakeAbortController, + setTimer: clock.setTimer, + clearTimer: clock.clearTimer, +} ); + +const bodyA = { html64: '_w', context64: '', selector: '' }; +const bodyB = { html64: 'w78', context64: 'IA', selector: '.x' }; +const pendingA = requestBoundary.request( bodyA ); +const pendingARejection = assert.rejects( + pendingA, + SupersededRuntimeOperationError, +); +const pendingB = requestBoundary.request( bodyB ); +await pendingARejection; +assert.equal( clock.size, 1, 'replacement keeps only one debounce timer' ); +clock.run(); +assert.equal( fetches.length, 1, 'debounced requests coalesce to one fetch' ); +assert.equal( fetches[ 0 ].url.includes( '/v2/' ), true ); +assert.deepEqual( JSON.parse( fetches[ 0 ].options.body ), bodyB ); +assert.deepEqual( fetches[ 0 ].options.headers, { + 'Content-Type': 'application/json', + 'X-WP-Nonce': 'nonce-1', +} ); +fetches[ 0 ].response.resolve( fakeResponse( { newest: true }, { nonce: 'nonce-2' } ) ); +assert.deepEqual( await pendingB, { newest: true } ); + +const reverseOld = requestBoundary.request( bodyA ); +clock.run(); +const oldFetch = fetches.at( -1 ); +const reverseNew = requestBoundary.request( bodyB ); +assert.equal( oldFetch.options.signal.aborted, true, 'replacement aborts an active fetch immediately' ); +clock.run(); +const newFetch = fetches.at( -1 ); +newFetch.response.resolve( fakeResponse( { generation: 'new' }, { nonce: 'nonce-new' } ) ); +assert.deepEqual( await reverseNew, { generation: 'new' } ); +oldFetch.response.resolve( fakeResponse( { generation: 'old' }, { nonce: 'nonce-old' } ) ); +await assert.rejects( reverseOld, SupersededRuntimeOperationError ); + +const nonceProbe = requestBoundary.request( bodyA ); +clock.run(); +const probeFetch = fetches.at( -1 ); +assert.equal( probeFetch.options.headers[ 'X-WP-Nonce' ], 'nonce-new', 'late aborted success cannot overwrite the newest nonce' ); + +const replacementProbe = requestBoundary.request( bodyB ); +assert.equal( probeFetch.options.signal.aborted, true, 'late cleanup cannot clear the newer active controller' ); +clock.run(); +const replacementFetch = fetches.at( -1 ); +replacementFetch.response.resolve( fakeResponse( { replacement: true } ) ); +await replacementProbe; +probeFetch.response.resolve( fakeResponse( { stale: true } ) ); +await assert.rejects( nonceProbe, SupersededRuntimeOperationError ); + +const staleNonOk = requestBoundary.request( bodyA ); +clock.run(); +const staleNonOkFetch = fetches.at( -1 ); +const currentAfterNonOk = requestBoundary.request( bodyB ); +clock.run(); +const currentAfterNonOkFetch = fetches.at( -1 ); +currentAfterNonOkFetch.response.resolve( fakeResponse( { current: true }, { nonce: 'nonce-current' } ) ); +await currentAfterNonOk; +staleNonOkFetch.response.resolve( fakeResponse( null, { nonce: 'nonce-stale-error', ok: false } ) ); +await assert.rejects( staleNonOk, SupersededRuntimeOperationError ); + +const currentNonOk = requestBoundary.request( bodyA ); +clock.run(); +const currentNonOkFetch = fetches.at( -1 ); +const nonOkResponse = fakeResponse( null, { nonce: 'nonce-error', ok: false } ); +currentNonOkFetch.response.resolve( nonOkResponse ); +await assert.rejects( currentNonOk, ( error ) => error === nonOkResponse ); +const nonceAfterError = requestBoundary.request( bodyB ); +clock.run(); +const nonceAfterErrorFetch = fetches.at( -1 ); +assert.equal( nonceAfterErrorFetch.options.headers[ 'X-WP-Nonce' ], 'nonce-error', 'a current non-ok response refreshes the nonce' ); +nonceAfterErrorFetch.response.resolve( fakeResponse( {} ) ); +await nonceAfterError; + +const disposedPending = requestBoundary.request( bodyA ); +requestBoundary.dispose(); +await assert.rejects( disposedPending, DisposedRuntimeBoundaryError ); +await assert.rejects( + requestBoundary.request( bodyA ), + DisposedRuntimeBoundaryError, +); + +const originalSetTimeout = globalThis.setTimeout; +const originalClearTimeout = globalThis.clearTimeout; +let defaultSetTimerCalls = 0; +let defaultClearTimerCalls = 0; +globalThis.setTimeout = function ( callback, delay ) { + assert.equal( this, globalThis, 'default setTimeout keeps its global receiver' ); + ++defaultSetTimerCalls; + return originalSetTimeout( callback, delay ); +}; +globalThis.clearTimeout = function ( timer ) { + assert.equal( this, globalThis, 'default clearTimeout keeps its global receiver' ); + ++defaultClearTimerCalls; + return originalClearTimeout( timer ); +}; +try { + const defaultBoundary = new ByteRequestBoundary( { + endpoint: 'https://example.test/wp-json/html-api-debugger/v2/htmlapi', + nonce: 'default-nonce', + fetch: async () => fakeResponse( { defaultTimers: true } ), + AbortController: FakeAbortController, + delay: 0, + } ); + const replaced = defaultBoundary.request( bodyA ); + const replacedRejection = assert.rejects( + replaced, + SupersededRuntimeOperationError, + ); + const current = defaultBoundary.request( bodyB ); + await replacedRejection; + assert.deepEqual( await current, { defaultTimers: true } ); + assert.equal( defaultSetTimerCalls, 2 ); + assert.equal( defaultClearTimerCalls, 1 ); +} finally { + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; +} + +class FakeIframe { + constructor() { + this.listeners = new Map(); + this.failAssignment = false; + this._src = 'about:blank'; + this.locationValue = 'about:blank'; + this.locationThrows = false; + this.contentWindow = { + document: { marker: 'document' }, + location: {}, + }; + Object.defineProperty( this.contentWindow.location, 'href', { + get: () => { + if ( this.locationThrows ) { + throw new Error( 'location failed' ); + } + return this.locationValue; + }, + } ); + } + + get src() { + return this._src; + } + + set src( value ) { + if ( this.failAssignment ) { + throw new Error( 'src assignment failed' ); + } + this._src = value; + } + + addEventListener( type, listener ) { + const listeners = this.listeners.get( type ) ?? new Set(); + listeners.add( listener ); + this.listeners.set( type, listeners ); + } + + removeEventListener( type, listener ) { + this.listeners.get( type )?.delete( listener ); + } + + dispatchLoad( url ) { + this.locationValue = url; + for ( const listener of [ ...( this.listeners.get( 'load' ) ?? [] ) ] ) { + listener( { type: 'load' } ); + } + } + + listenerCount( type ) { + return this.listeners.get( type )?.size ?? 0; + } +} + +class FakeEventTarget { + constructor() { + this.listeners = new Map(); + } + + addEventListener( type, listener ) { + this.listeners.set( type, listener ); + } + + removeEventListener( type, listener ) { + if ( this.listeners.get( type ) === listener ) { + this.listeners.delete( type ); + } + } + + dispatch( type ) { + this.listeners.get( type )?.( { type } ); + } +} + +function plan( documentBytes, fragmentBytes = null, text = '', lossy = false ) { + return { + documentBytes: Uint8Array.from( documentBytes ), + fragment: + fragmentBytes === null + ? null + : { + bytes: Uint8Array.from( fragmentBytes ), + text, + lossy, + }, + }; +} + +const iframe = new FakeIframe(); +const pagehideTarget = new FakeEventTarget(); +const revoked = []; +let nextUrl = 1; +let failCreation = false; +const urlApi = { + createObjectURL() { + if ( failCreation ) { + throw new Error( 'object URL failed' ); + } + return `blob:preview-${ nextUrl++ }`; + }, + revokeObjectURL( url ) { + revoked.push( url ); + }, +}; +const preview = new ByteDocumentPreview( iframe, urlApi, Blob ); +const order = []; +const restored = []; +const contextElement = { innerHTML: '' }; +let resolveFailure = false; +let assignmentFailure = false; +let observerFailure = false; +const fragmentTarget = {}; +Object.defineProperty( fragmentTarget, 'innerHTML', { + get() { + return contextElement.innerHTML; + }, + set( value ) { + order.push( `fragment:${ value }` ); + if ( assignmentFailure ) { + throw new Error( 'fragment assignment failed' ); + } + contextElement.innerHTML = value; + }, +} ); +const coordinator = new BytePreviewCoordinator( { + preview, + iframe, + resolveFragmentTarget() { + order.push( 'resolve' ); + if ( resolveFailure ) { + throw new Error( 'resolve failed' ); + } + return fragmentTarget; + }, + onCurrentDocument( details ) { + order.push( `observe:${ details.url }` ); + if ( observerFailure ) { + throw new Error( 'observer failed' ); + } + }, + restoreCurrentDocument( details ) { + restored.push( details.url ); + }, + disconnectObserver() { + order.push( 'disconnect' ); + }, + pagehideTarget, +} ); + +const fragmentPlanA = plan( [ 0x3c, 0x62, 0x6f, 0x64, 0x79, 0x3e ], [ 0xff ], '\ufffd', true ); +assert.equal( coordinator.render( fragmentPlanA ), true ); +const urlA = iframe.src; +assert.equal( coordinator.render( fragmentPlanA ), false, 'identical pending plan does not navigate twice' ); +assert.equal( iframe.src, urlA ); +assert.equal( iframe.listenerCount( 'load' ), 1 ); +iframe.dispatchLoad( 'about:blank' ); +assert.equal( iframe.listenerCount( 'load' ), 1, 'mismatched load retains the rightful pending handler' ); +assert.equal( contextElement.innerHTML, '' ); +iframe.dispatchLoad( urlA ); +assert.equal( contextElement.innerHTML, '\ufffd' ); +assert.deepEqual( order.slice( -3 ), [ 'resolve', 'fragment:\ufffd', `observe:${ urlA }` ], 'fragment is applied once before observation' ); +assert.equal( iframe.listenerCount( 'load' ), 0 ); +contextElement.innerHTML = 'mutated'; +assert.equal( coordinator.render( fragmentPlanA ), false, 'committed identical plan does not reset mutations' ); +assert.equal( contextElement.innerHTML, 'mutated' ); + +const documentPlan = plan( [ 0x41 ] ); +assert.equal( coordinator.render( documentPlan ), true ); +const documentUrl = iframe.src; +assert.deepEqual( revoked, [ urlA ], 'a superseded object URL is revoked' ); +iframe.dispatchLoad( urlA ); +assert.equal( iframe.listenerCount( 'load' ), 1, 'stale old URL cannot consume the new handler' ); +iframe.dispatchLoad( documentUrl ); +assert.equal( iframe.listenerCount( 'load' ), 0 ); + +failCreation = true; +assert.throws( () => coordinator.render( plan( [ 0x42 ] ) ), /object URL failed/ ); +failCreation = false; +assert.equal( coordinator.render( documentPlan ), false, 'pre-navigation creation failure retains a true prior commit' ); +assert.equal( restored.at( -1 ), documentUrl ); + +iframe.failAssignment = true; +assert.throws( () => coordinator.render( plan( [ 0x43 ] ) ), /src assignment failed/ ); +iframe.failAssignment = false; +assert.equal( coordinator.render( documentPlan ), false, 'pre-navigation assignment failure retains a true prior commit' ); +assert.equal( iframe.listenerCount( 'load' ), 0, 'failed load leaks no handler' ); + +resolveFailure = true; +const failingFragment = plan( [ 0x44 ], [ 0x45 ], 'E' ); +assert.equal( coordinator.render( failingFragment ), true ); +const failingUrl = iframe.src; +iframe.dispatchLoad( failingUrl ); +resolveFailure = false; +assert.equal( restored.at( -1 ), failingUrl ); +assert.equal( coordinator.render( documentPlan ), true, 'old plan navigates after post-navigation failure invalidates its old commit' ); +const retriedOldUrl = iframe.src; +iframe.dispatchLoad( retriedOldUrl ); + +const pendingPlanA = plan( [ 0x50 ] ); +const pendingPlanB = plan( [ 0x51 ] ); +assert.equal( coordinator.render( pendingPlanA ), true ); +const pendingUrlA = iframe.src; +failCreation = true; +assert.throws( () => coordinator.render( pendingPlanB ), /object URL failed/ ); +failCreation = false; +assert.equal( coordinator.render( pendingPlanA ), true, 'failed supersession leaves prior pending plan retryable' ); +assert.notEqual( iframe.src, pendingUrlA ); +iframe.dispatchLoad( iframe.src ); +assert.equal( coordinator.render( pendingPlanB ), true, 'failed superseding plan is independently retryable' ); +iframe.dispatchLoad( iframe.src ); + +for ( const failure of [ 'location', 'assignment', 'observer' ] ) { + const failurePlan = plan( [ failure.length ], [ 0x58 ], failure ); + iframe.locationThrows = failure === 'location'; + assignmentFailure = failure === 'assignment'; + observerFailure = failure === 'observer'; + assert.equal( coordinator.render( failurePlan ), true ); + const failureUrl = iframe.src; + iframe.dispatchLoad( failureUrl ); + iframe.locationThrows = false; + assignmentFailure = false; + observerFailure = false; + assert.equal( coordinator.render( failurePlan ), true, `${ failure } failure leaves the plan retryable` ); + iframe.dispatchLoad( iframe.src ); +} + +const finalUrl = iframe.src; +pagehideTarget.dispatch( 'pagehide' ); +assert.equal( revoked.filter( ( url ) => url === finalUrl ).length, 1, 'pagehide revokes the final URL exactly once' ); +coordinator.dispose(); +assert.equal( revoked.filter( ( url ) => url === finalUrl ).length, 1, 'dispose is idempotent' ); +assert.throws( () => coordinator.render( documentPlan ), DisposedRuntimeBoundaryError ); + +console.log( 'All runtime wiring tests passed.' ); diff --git a/tests/ui-transactions.mjs b/tests/ui-transactions.mjs new file mode 100644 index 0000000..a0b6d5f --- /dev/null +++ b/tests/ui-transactions.mjs @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; + +import { ByteRuntimeController } from '../html-api-debugger/runtime-controller.mjs'; +import { + beginUiOperation, + settleUiConversion, +} from '../html-api-debugger/ui-transactions.mjs'; + +const admin = + 'https://example.test/wp-admin/admin.php?page=html-api-debugger&format=v1&html64=QQ&context64=&selector=&opts='; + +let resolveOlderRequest; +const requests = []; +let rejectUrlWrite = false; +const controller = new ByteRuntimeController( { + url: new URL( admin ), + supports: { create_fragment_advanced: true }, + request: ( body ) => { + requests.push( body ); + return new Promise( ( resolve ) => { + resolveOlderRequest = resolve; + } ); + }, + replaceUrl: () => { + if ( rejectUrlWrite ) { + throw new Error( 'URL write failed' ); + } + }, + confirmConversion: () => false, +} ); + +const olderRequest = controller.start(); +const projectedResponse = { result: { tree: 'older tree' } }; +const state = { + view: 'bytes', + playback: 1, + projectedResponse, + error: null, +}; +rejectUrlWrite = true; +const failedStart = beginUiOperation( + () => controller.editSource( 'html', 'B' ), + () => { + state.view = 'text'; + state.playback = null; + state.projectedResponse = null; + }, + ( error ) => { + state.error = error; + }, +); +assert.deepEqual( failedStart, { started: false } ); +assert.equal( state.view, 'bytes' ); +assert.equal( state.playback, 1 ); +assert.equal( state.projectedResponse, projectedResponse ); +assert.match( state.error.message, /URL write failed/u ); +assert.equal( requests.length, 1 ); +assert.equal( controller.isProcessing, true ); +resolveOlderRequest( null ); +await assert.rejects( olderRequest, TypeError ); + +const newerResponse = { result: { tree: 'newer tree' } }; +const conversionState = { + playback: 7, + projectedResponse: newerResponse, + applications: 0, +}; +assert.equal( + await settleUiConversion( Promise.resolve( null ), () => { + conversionState.playback = null; + conversionState.projectedResponse = { result: { tree: 'stale tree' } }; + ++conversionState.applications; + } ), + false, +); +assert.equal( conversionState.playback, 7 ); +assert.equal( conversionState.projectedResponse, newerResponse ); +assert.equal( conversionState.applications, 0 ); + +assert.equal( + await settleUiConversion( Promise.resolve( 'converted' ), ( value ) => { + assert.equal( value, 'converted' ); + conversionState.playback = null; + ++conversionState.applications; + } ), + true, +); +assert.equal( conversionState.playback, null ); +assert.equal( conversionState.applications, 1 ); + +let uiErrorWasMisreported = false; +assert.throws( + () => + beginUiOperation( + () => 'started', + () => { + throw new Error( 'UI commit failed' ); + }, + () => { + uiErrorWasMisreported = true; + }, + ), + /UI commit failed/u, +); +assert.equal( uiErrorWasMisreported, false ); + +console.log( 'All UI transaction tests passed.' );