From 14a36ac698de1a752457574fc421485ad3b5fc2a Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 15 Jul 2026 13:49:11 +0200 Subject: [PATCH 01/16] Transport bytes without lying --- html-api-debugger/byte-transport.php | 71 +++++++++++++++ tests/byte-transport-regression.php | 125 +++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 html-api-debugger/byte-transport.php create mode 100644 tests/byte-transport-regression.php diff --git a/html-api-debugger/byte-transport.php b/html-api-debugger/byte-transport.php new file mode 100644 index 0000000..4524f24 --- /dev/null +++ b/html-api-debugger/byte-transport.php @@ -0,0 +1,71 @@ + encode_base64url( $value ) ); + } + + if ( is_object( $value ) ) { + $value = get_object_vars( $value ); + } + + if ( is_array( $value ) ) { + foreach ( $value as $key => $item ) { + $value[ $key ] = envelope_response_strings( $item ); + } + } + + return $value; +} 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 @@ + '', + '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"; From b253ab7aa8362ef626a651755086037309591621 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 15 Jul 2026 14:00:36 +0200 Subject: [PATCH 02/16] Keep browser bytes honest --- html-api-debugger/byte-transport.mjs | 273 +++++++++++++++++++++++++++ tests/byte-transport.mjs | 133 +++++++++++++ 2 files changed, 406 insertions(+) create mode 100644 html-api-debugger/byte-transport.mjs create mode 100644 tests/byte-transport.mjs diff --git a/html-api-debugger/byte-transport.mjs b/html-api-debugger/byte-transport.mjs new file mode 100644 index 0000000..b18f8ed --- /dev/null +++ b/html-api-debugger/byte-transport.mjs @@ -0,0 +1,273 @@ +const BASE64URL_ALPHABET = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + +const BYTE_ENVELOPE_KEY = '__bytesBase64url'; + +/** + * Encode bytes as canonical unpadded base64url. + * + * @param {Uint8Array} bytes Bytes to encode. + * @returns {string} Canonical base64url. + */ +export function encodeBase64url( bytes ) { + if ( ! ( bytes instanceof Uint8Array ) ) { + throw new TypeError( 'Expected a Uint8Array.' ); + } + + let encoded = ''; + for ( let offset = 0; offset < bytes.length; offset += 3 ) { + const first = /** @type {number} */ ( bytes[ offset ] ); + const second = bytes[ offset + 1 ]; + const third = bytes[ offset + 2 ]; + + encoded += BASE64URL_ALPHABET[ first >> 2 ]; + encoded += BASE64URL_ALPHABET[ + ( ( first & 0x03 ) << 4 ) | ( ( second ?? 0 ) >> 4 ) + ]; + if ( second !== undefined ) { + encoded += BASE64URL_ALPHABET[ + ( ( second & 0x0f ) << 2 ) | ( ( third ?? 0 ) >> 6 ) + ]; + } + if ( third !== undefined ) { + encoded += BASE64URL_ALPHABET[ third & 0x3f ]; + } + } + + return encoded; +} + +/** + * Decode canonical unpadded base64url. + * + * @param {string} encoded Canonical base64url. + * @returns {Uint8Array} Decoded bytes. + */ +export function decodeBase64url( encoded ) { + if ( + typeof encoded !== 'string' || + ! /^[A-Za-z0-9_-]*$/u.test( encoded ) || + encoded.length % 4 === 1 + ) { + throw new TypeError( 'Expected canonical unpadded base64url.' ); + } + + const bytes = new Uint8Array( Math.floor( ( encoded.length * 6 ) / 8 ) ); + let byteOffset = 0; + for ( let offset = 0; offset < encoded.length; offset += 4 ) { + const first = decodeBase64urlCharacter( encoded.charCodeAt( offset ) ); + const second = decodeBase64urlCharacter( encoded.charCodeAt( offset + 1 ) ); + const third = + offset + 2 < encoded.length + ? decodeBase64urlCharacter( encoded.charCodeAt( offset + 2 ) ) + : 0; + const fourth = + offset + 3 < encoded.length + ? decodeBase64urlCharacter( encoded.charCodeAt( offset + 3 ) ) + : 0; + + bytes[ byteOffset++ ] = ( first << 2 ) | ( second >> 4 ); + if ( offset + 2 < encoded.length ) { + bytes[ byteOffset++ ] = ( second << 4 ) | ( third >> 2 ); + } + if ( offset + 3 < encoded.length ) { + bytes[ byteOffset++ ] = ( third << 6 ) | fourth; + } + } + + if ( encodeBase64url( bytes ) !== encoded ) { + throw new TypeError( 'Expected canonical unpadded base64url.' ); + } + + return bytes; +} + +/** + * Encode Unicode text as UTF-8 bytes. + * + * @param {string} text Unicode text. + * @returns {Uint8Array} UTF-8 bytes. + */ +export function encodeUtf8( text ) { + return new TextEncoder().encode( text ); +} + +/** + * Decode valid UTF-8 without consuming a leading byte-order mark. + * + * @param {Uint8Array} bytes UTF-8 bytes. + * @returns {string} Unicode text. + */ +export function decodeUtf8( bytes ) { + return new TextDecoder( 'utf-8', { + fatal: true, + ignoreBOM: true, + } ).decode( bytes ); +} + +/** + * Project arbitrary bytes to Unicode without consuming a leading byte-order mark. + * + * Invalid UTF-8 is replaced by the Encoding Standard's replacement algorithm. + * + * @param {Uint8Array} bytes Arbitrary bytes. + * @returns {string} A potentially lossy Unicode projection. + */ +export function projectUtf8( bytes ) { + return new TextDecoder( 'utf-8', { ignoreBOM: true } ).decode( bytes ); +} + +/** + * Determine whether a complete byte sequence is valid UTF-8. + * + * @param {Uint8Array} bytes Bytes to validate. + * @returns {boolean} Whether the bytes are valid UTF-8. + */ +export function isValidUtf8( bytes ) { + try { + decodeUtf8( bytes ); + return true; + } catch { + return false; + } +} + +/** + * Determine whether a value is an exact byte envelope. + * + * @param {unknown} value Value to inspect. + * @returns {value is {__bytesBase64url: string}} Whether the value is an envelope. + */ +export function isByteEnvelope( value ) { + if ( ! isPlainObject( value ) ) { + return false; + } + + const keys = Object.keys( value ); + return ( + keys.length === 1 && + keys[ 0 ] === BYTE_ENVELOPE_KEY && + typeof value[ BYTE_ENVELOPE_KEY ] === 'string' + ); +} + +/** + * Create a Unicode projection of a byte-enveloped REST response. + * + * The source response is not mutated. Bare strings and malformed reserved + * markers are rejected so a server cannot silently violate the wire contract. + * + * @param {unknown} value Byte-enveloped JSON value. + * @returns {unknown} Deep Unicode projection. + */ +export function projectResponseStrings( value ) { + if ( typeof value === 'string' ) { + throw new TypeError( 'Response strings must use byte envelopes.' ); + } + + if ( value === null || typeof value === 'boolean' ) { + return value; + } + + if ( typeof value === 'number' ) { + if ( ! Number.isFinite( value ) ) { + throw new TypeError( 'Expected a JSON value.' ); + } + return value; + } + + if ( Array.isArray( value ) ) { + return value.map( projectResponseStrings ); + } + + if ( ! isPlainObject( value ) ) { + throw new TypeError( 'Expected a JSON value.' ); + } + + if ( Object.prototype.hasOwnProperty.call( value, BYTE_ENVELOPE_KEY ) ) { + if ( ! isByteEnvelope( value ) ) { + throw new TypeError( 'Malformed byte envelope.' ); + } + return projectUtf8( decodeBase64url( value[ BYTE_ENVELOPE_KEY ] ) ); + } + + /** @type {Record} */ + const projected = {}; + for ( const [ key, item ] of Object.entries( value ) ) { + Object.defineProperty( projected, key, { + value: projectResponseStrings( item ), + enumerable: true, + configurable: true, + writable: true, + } ); + } + return projected; +} + +/** + * Format bytes as fixed-width hexadecimal inspection rows. + * + * @param {Uint8Array} bytes Bytes to format. + * @param {number} [rowWidth=16] Bytes per row. + * @returns {Array<{offset: number, hex: string, gutter: string}>} Byte rows. + */ +export function formatByteRows( bytes, rowWidth = 16 ) { + if ( ! ( bytes instanceof Uint8Array ) ) { + throw new TypeError( 'Expected a Uint8Array.' ); + } + if ( ! Number.isFinite( rowWidth ) || ! Number.isInteger( rowWidth ) || rowWidth <= 0 ) { + throw new RangeError( 'Row width must be a positive integer.' ); + } + + const rows = []; + for ( let offset = 0; offset < bytes.length; offset += rowWidth ) { + const row = bytes.subarray( offset, offset + rowWidth ); + rows.push( { + offset, + hex: Array.from( row, ( byte ) => + byte.toString( 16 ).toUpperCase().padStart( 2, '0' ), + ).join( ' ' ), + gutter: Array.from( row, ( byte ) => + byte >= 0x20 && byte <= 0x7e + ? String.fromCharCode( byte ) + : '\ufffd', + ).join( '' ), + } ); + } + return rows; +} + +/** + * Decode one base64url alphabet character. + * + * The public decoder validates the complete alphabet before calling this. + * + * @param {number} character ASCII character code. + * @returns {number} Six-bit value. + */ +function decodeBase64urlCharacter( character ) { + if ( character >= 0x41 && character <= 0x5a ) { + return character - 0x41; + } + if ( character >= 0x61 && character <= 0x7a ) { + return character - 0x61 + 26; + } + if ( character >= 0x30 && character <= 0x39 ) { + return character - 0x30 + 52; + } + return character === 0x2d ? 62 : 63; +} + +/** + * Determine whether a value has a JSON-object-compatible prototype. + * + * @param {unknown} value Value to inspect. + * @returns {value is Record} Whether the value is a plain object. + */ +function isPlainObject( value ) { + if ( value === null || typeof value !== 'object' || Array.isArray( value ) ) { + return false; + } + const prototype = Object.getPrototypeOf( value ); + return prototype === Object.prototype || prototype === null; +} 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.' ); From 9010d5a37e68bc61042de1f90f932b1d9660f5d1 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 15 Jul 2026 14:05:45 +0200 Subject: [PATCH 03/16] Stop REST from mangling bytes --- html-api-debugger/html-api-debugger.php | 14 ++ html-api-debugger/rest-api.php | 95 +++++++++++ tests/rest-api-regression.php | 202 ++++++++++++++++++++++++ 3 files changed, 311 insertions(+) create mode 100644 html-api-debugger/rest-api.php create mode 100644 tests/rest-api-regression.php diff --git a/html-api-debugger/html-api-debugger.php b/html-api-debugger/html-api-debugger.php index 5f80484..77236a1 100644 --- a/html-api-debugger/html-api-debugger.php +++ b/html-api-debugger/html-api-debugger.php @@ -20,6 +20,8 @@ use Exception; require_once __DIR__ . '/html-api-integration.php'; +require_once __DIR__ . '/byte-transport.php'; +require_once __DIR__ . '/rest-api.php'; const SLUG = 'html-api-debugger'; const VERSION = '2.9'; @@ -35,6 +37,18 @@ function init() { add_action( 'rest_api_init', function () { + register_rest_route( + SLUG . '/v2', + '/htmlapi', + array( + 'methods' => 'POST', + 'callback' => __NAMESPACE__ . '\\handle_byte_htmlapi_request', + 'permission_callback' => function () { + return current_user_can( 'edit_posts' ); + }, + ) + ); + register_rest_route( SLUG . '/v1', '/htmlapi', diff --git a/html-api-debugger/rest-api.php b/html-api-debugger/rest-api.php new file mode 100644 index 0000000..d1a7a27 --- /dev/null +++ b/html-api-debugger/rest-api.php @@ -0,0 +1,95 @@ + '' === $context_html ? null : $context_html, + 'selector' => '' === $params['selector'] ? null : $params['selector'], + ), + ); +} + +/** + * Assert that response object keys are printable ASCII protocol keys. + * + * @param mixed $value Response value. + * @throws \UnexpectedValueException When a protocol key is not printable ASCII. + */ +function assert_ascii_protocol_keys( $value ): void { + if ( is_object( $value ) ) { + $value = get_object_vars( $value ); + } + + if ( ! is_array( $value ) ) { + return; + } + + foreach ( $value as $key => $item ) { + if ( is_string( $key ) && 1 !== preg_match( '/\A[\x20-\x7E]+\z/D', $key ) ) { + throw new \UnexpectedValueException( 'Response contains a non-ASCII protocol key.' ); + } + assert_ascii_protocol_keys( $item ); + } +} + +/** + * Process a byte-safe HTML API REST request. + * + * @param \WP_REST_Request $request REST request. + * @return array|\WP_Error Byte-enveloped response or request error. + */ +function handle_byte_htmlapi_request( \WP_REST_Request $request ) { + try { + list( $html, $options ) = decode_byte_htmlapi_request( $request->get_json_params() ); + } catch ( \InvalidArgumentException $e ) { + return new \WP_Error( + 'html_api_debugger_invalid_byte_request', + 'Invalid byte transport request.', + array( 'status' => 400 ) + ); + } + + $response = prepare_html_result_object( $html, $options ); + assert_ascii_protocol_keys( $response ); + + return envelope_response_strings( $response ); +} diff --git a/tests/rest-api-regression.php b/tests/rest-api-regression.php new file mode 100644 index 0000000..c8b6f20 --- /dev/null +++ b/tests/rest-api-regression.php @@ -0,0 +1,202 @@ +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' ) ); + html_api_debugger_rest_assert_same( 'empty context means document mode', null, $test_processing_calls[4][1]['context_html'] ); + html_api_debugger_rest_assert_same( 'empty selector means no selector', null, $test_processing_calls[4][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"; +} From 20771bfa1714ec10a3b14349817763fa5df642ed Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 15 Jul 2026 14:12:13 +0200 Subject: [PATCH 04/16] Make byte URLs unambiguous --- html-api-debugger/canonical-url.mjs | 283 ++++++++++++++++++++++++++++ tests/canonical-url.mjs | 178 +++++++++++++++++ 2 files changed, 461 insertions(+) create mode 100644 html-api-debugger/canonical-url.mjs create mode 100644 tests/canonical-url.mjs diff --git a/html-api-debugger/canonical-url.mjs b/html-api-debugger/canonical-url.mjs new file mode 100644 index 0000000..409cd4d --- /dev/null +++ b/html-api-debugger/canonical-url.mjs @@ -0,0 +1,283 @@ +import { + decodeBase64url, + decodeUtf8, + encodeBase64url, +} from './byte-transport.mjs'; + +const CANONICAL_PARAMETERS = /** @type {const} */ ( [ + 'format', + 'html64', + 'context64', + 'selector', + 'opts', +] ); + +const LEGACY_PARAMETERS = /** @type {const} */ ( [ + 'html', + 'contextHTML', + 'html-opts', +] ); + +const ALL_TRANSPORT_PARAMETERS = [ + ...CANONICAL_PARAMETERS, + ...LEGACY_PARAMETERS, +]; + +/** Error raised when a URL does not use the canonical transport grammar. */ +export class CanonicalUrlError extends Error { + constructor( message = 'The URL is not a canonical v1 HTML API Debugger URL.' ) { + super( message ); + this.name = 'CanonicalUrlError'; + } +} + +/** + * @typedef CanonicalUrlState + * @property {Uint8Array} htmlBytes + * @property {Uint8Array} contextBytes + * @property {string} selector + * @property {string} opts + * @property {boolean} needsCanonicalization + */ + +/** + * Parse a canonical debugger URL without accepting normalized aliases. + * + * @param {URL} url URL to parse. + * @returns {CanonicalUrlState} Exact byte and Unicode state. + */ +export function parseCanonicalUrl( url ) { + assertUrl( url ); + + const hasTransportParameter = ALL_TRANSPORT_PARAMETERS.some( ( name ) => + url.searchParams.has( name ), + ); + if ( ! hasTransportParameter ) { + return { + htmlBytes: new Uint8Array(), + contextBytes: new Uint8Array(), + selector: '', + opts: '', + needsCanonicalization: true, + }; + } + + if ( LEGACY_PARAMETERS.some( ( name ) => url.searchParams.has( name ) ) ) { + throw new CanonicalUrlError( 'The legacy URL was not migrated.' ); + } + + const raw = getCanonicalRawValues( url ); + if ( raw.format !== 'v1' ) { + throw new CanonicalUrlError( 'Unknown or missing URL format.' ); + } + if ( ! /^[A-Za-z0-9_-]*$/u.test( raw.html64 ) ) { + throw new CanonicalUrlError(); + } + if ( ! /^[A-Za-z0-9_-]*$/u.test( raw.context64 ) ) { + throw new CanonicalUrlError(); + } + if ( ! /^[Cc]?[Ii]?[Vv]?$/u.test( raw.opts ) ) { + throw new CanonicalUrlError( 'Invalid URL options.' ); + } + + const selector = decodeCanonicalSelector( raw.selector ); + if ( encodeFormValue( selector ) !== raw.selector ) { + throw new CanonicalUrlError( 'Selector is not canonically encoded.' ); + } + + try { + return { + htmlBytes: decodeBase64url( raw.html64 ), + contextBytes: decodeBase64url( raw.context64 ), + selector, + opts: raw.opts, + needsCanonicalization: false, + }; + } catch { + throw new CanonicalUrlError( 'Invalid canonical byte field.' ); + } +} + +/** + * Serialize exact state into the one canonical v1 URL spelling. + * + * @param {URL} url Base URL whose unrelated parameters are preserved. + * @param {{htmlBytes: Uint8Array, contextBytes: Uint8Array, selector: string, opts: string}} state Exact state. + * @returns {URL} Canonical URL clone. + */ +export function serializeCanonicalUrl( url, state ) { + assertUrl( url ); + assertWellFormedUnicode( state.selector ); + if ( ! /^[Cc]?[Ii]?[Vv]?$/u.test( state.opts ) ) { + throw new CanonicalUrlError( 'Invalid URL options.' ); + } + + const canonical = new URL( url.href ); + for ( const name of ALL_TRANSPORT_PARAMETERS ) { + canonical.searchParams.delete( name ); + } + canonical.searchParams.append( 'format', 'v1' ); + canonical.searchParams.append( 'html64', encodeBase64url( state.htmlBytes ) ); + canonical.searchParams.append( + 'context64', + encodeBase64url( state.contextBytes ), + ); + canonical.searchParams.append( 'selector', state.selector ); + canonical.searchParams.append( 'opts', state.opts ); + + return canonical; +} + +/** + * Return the canonical path used inside a WordPress Playground URL. + * + * @param {URL} url Canonical admin URL. + * @returns {string} Path and query without an origin or fragment. + */ +export function canonicalUrlPath( url ) { + assertUrl( url ); + return `${ url.pathname }${ url.search }`; +} + +/** + * Extract exact raw values for all required canonical parameters. + * + * Decoded-name counts are compared with literal-name counts so encoded aliases + * such as `ht%6Dl64` cannot hide behind URLSearchParams normalization. + * + * @param {URL} url URL to inspect. + * @returns {Record<(typeof CANONICAL_PARAMETERS)[number], string>} Raw values. + */ +function getCanonicalRawValues( url ) { + const pairs = url.search.length === 0 ? [] : url.search.slice( 1 ).split( '&' ); + /** @type {Partial>} */ + const values = {}; + + for ( const name of CANONICAL_PARAMETERS ) { + const prefix = `${ name }=`; + const literalValues = pairs + .filter( ( pair ) => pair === name || pair.startsWith( prefix ) ) + .map( ( pair ) => ( pair.startsWith( prefix ) ? pair.slice( prefix.length ) : null ) ); + + const literalValue = literalValues[ 0 ]; + if ( + url.searchParams.getAll( name ).length !== literalValues.length || + literalValues.length !== 1 || + literalValue === null || + literalValue === undefined + ) { + throw new CanonicalUrlError( `Missing, duplicate, or aliased ${ name } parameter.` ); + } + values[ name ] = literalValue; + } + + return /** @type {Record<(typeof CANONICAL_PARAMETERS)[number], string>} */ ( + values + ); +} + +/** + * Strictly form-decode a raw selector value as UTF-8. + * + * @param {string} raw Raw application/x-www-form-urlencoded value. + * @returns {string} Unicode selector. + */ +function decodeCanonicalSelector( raw ) { + const bytes = []; + for ( let offset = 0; offset < raw.length; ++offset ) { + const character = raw.charCodeAt( offset ); + if ( character === 0x2b ) { + bytes.push( 0x20 ); + continue; + } + if ( character === 0x25 ) { + if ( offset + 2 >= raw.length ) { + throw new CanonicalUrlError( 'Selector contains malformed percent encoding.' ); + } + const high = hexValue( raw.charCodeAt( offset + 1 ) ); + const low = hexValue( raw.charCodeAt( offset + 2 ) ); + if ( high < 0 || low < 0 ) { + throw new CanonicalUrlError( 'Selector contains malformed percent encoding.' ); + } + bytes.push( ( high << 4 ) | low ); + offset += 2; + continue; + } + if ( character > 0x7f ) { + throw new CanonicalUrlError( 'Selector is not canonically encoded.' ); + } + bytes.push( character ); + } + + try { + const selector = decodeUtf8( Uint8Array.from( bytes ) ); + assertWellFormedUnicode( selector ); + return selector; + } catch ( error ) { + if ( error instanceof CanonicalUrlError ) { + throw error; + } + throw new CanonicalUrlError( 'Selector is not valid UTF-8.' ); + } +} + +/** + * Serialize one selector value using the browser's canonical form encoding. + * + * @param {string} selector Unicode selector. + * @returns {string} Raw form value. + */ +function encodeFormValue( selector ) { + const params = new URLSearchParams(); + params.set( 'selector', selector ); + return params.toString().slice( 'selector='.length ); +} + +/** + * Reject lone UTF-16 surrogates before browser APIs can replace them. + * + * @param {string} value Unicode scalar string. + */ +function assertWellFormedUnicode( value ) { + if ( typeof value !== 'string' ) { + throw new CanonicalUrlError( 'Selector must be Unicode text.' ); + } + for ( let offset = 0; offset < value.length; ++offset ) { + const unit = value.charCodeAt( offset ); + if ( unit >= 0xd800 && unit <= 0xdbff ) { + const next = value.charCodeAt( offset + 1 ); + if ( ! Number.isInteger( next ) || next < 0xdc00 || next > 0xdfff ) { + throw new CanonicalUrlError( 'Selector contains a lone surrogate.' ); + } + ++offset; + } else if ( unit >= 0xdc00 && unit <= 0xdfff ) { + throw new CanonicalUrlError( 'Selector contains a lone surrogate.' ); + } + } +} + +/** + * Decode one ASCII hexadecimal digit. + * + * @param {number} character Character code. + * @returns {number} Value from 0 to 15, or -1. + */ +function hexValue( character ) { + if ( character >= 0x30 && character <= 0x39 ) { + return character - 0x30; + } + if ( character >= 0x41 && character <= 0x46 ) { + return character - 0x41 + 10; + } + if ( character >= 0x61 && character <= 0x66 ) { + return character - 0x61 + 10; + } + return -1; +} + +/** @param {unknown} value */ +function assertUrl( value ) { + if ( ! ( value instanceof URL ) ) { + throw new TypeError( 'Expected a URL.' ); + } +} diff --git a/tests/canonical-url.mjs b/tests/canonical-url.mjs new file mode 100644 index 0000000..fc382ce --- /dev/null +++ b/tests/canonical-url.mjs @@ -0,0 +1,178 @@ +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 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=&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.' ); From 53a322b22af3c8fa4efabb05c16b862de0fa99d6 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 15 Jul 2026 14:15:58 +0200 Subject: [PATCH 05/16] Migrate old links without guessing --- html-api-debugger/legacy-url.php | 99 +++++++++++++++++++++ tests/legacy-url-regression.php | 147 +++++++++++++++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 html-api-debugger/legacy-url.php create mode 100644 tests/legacy-url-regression.php diff --git a/html-api-debugger/legacy-url.php b/html-api-debugger/legacy-url.php new file mode 100644 index 0000000..ef4bba0 --- /dev/null +++ b/html-api-debugger/legacy-url.php @@ -0,0 +1,99 @@ +|null Canonical transport parameters, or null when no migration applies. + * @throws \InvalidArgumentException When a recognized legacy value is not a valid string. + */ +function get_legacy_redirect_params( $query ): ?array { + if ( ! is_array( $query ) ) { + throw new \InvalidArgumentException( 'Invalid legacy URL.' ); + } + + foreach ( array( 'format', 'html64', 'context64', 'opts' ) as $canonical_only_key ) { + if ( array_key_exists( $canonical_only_key, $query ) ) { + return null; + } + } + + $legacy_keys = array( 'html', 'contextHTML', 'selector', 'html-opts' ); + $has_legacy = false; + foreach ( $legacy_keys as $key ) { + if ( ! array_key_exists( $key, $query ) ) { + continue; + } + $has_legacy = true; + if ( ! is_string( $query[ $key ] ) ) { + throw new \InvalidArgumentException( 'Invalid legacy URL.' ); + } + } + + if ( ! $has_legacy ) { + return null; + } + + $html = array_key_exists( 'html', $query ) + ? \wp_unslash( $query['html'] ) + : ''; + $context_html = array_key_exists( 'contextHTML', $query ) + ? \wp_unslash( $query['contextHTML'] ) + : ''; + $selector = array_key_exists( 'selector', $query ) + ? \wp_unslash( $query['selector'] ) + : ''; + $legacy_opts = array_key_exists( 'html-opts', $query ) + ? \wp_unslash( $query['html-opts'] ) + : ''; + + if ( 1 !== preg_match( '//u', $selector ) ) { + throw new \InvalidArgumentException( 'Invalid legacy URL.' ); + } + + return array( + 'format' => 'v1', + 'html64' => encode_base64url( $html ), + 'context64' => encode_base64url( $context_html ), + 'selector' => $selector, + 'opts' => normalize_legacy_url_options( $legacy_opts ), + ); +} + +/** + * Normalize old option flags to the unique canonical order. + * + * Legacy parsing applied flags from left to right and let the final case for + * each option win. Unknown flags were ignored. + * + * @param string $legacy_opts Legacy html-opts value. + * @return string Canonical C/c, I/i, V/v flags. + */ +function normalize_legacy_url_options( string $legacy_opts ): string { + $states = array( + 'C' => null, + 'I' => null, + 'V' => null, + ); + + for ( $offset = 0; $offset < strlen( $legacy_opts ); ++$offset ) { + $flag = $legacy_opts[ $offset ]; + $upper = strtoupper( $flag ); + if ( array_key_exists( $upper, $states ) ) { + $states[ $upper ] = $flag; + } + } + + return implode( '', array_filter( $states, 'is_string' ) ); +} diff --git a/tests/legacy-url-regression.php b/tests/legacy-url-regression.php new file mode 100644 index 0000000..34485d4 --- /dev/null +++ b/tests/legacy-url-regression.php @@ -0,0 +1,147 @@ + $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"; + } +} + +echo "All legacy URL migration tests passed.\n"; From dc7924a265951b2788f05a985a67cf5de3ab7bbb Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 15 Jul 2026 14:19:41 +0200 Subject: [PATCH 06/16] Feed iframe bytes, not guesses --- html-api-debugger/byte-preview.mjs | 103 ++++++++++++++++++++++ tests/byte-preview.mjs | 132 +++++++++++++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 html-api-debugger/byte-preview.mjs create mode 100644 tests/byte-preview.mjs diff --git a/html-api-debugger/byte-preview.mjs b/html-api-debugger/byte-preview.mjs new file mode 100644 index 0000000..e3564e1 --- /dev/null +++ b/html-api-debugger/byte-preview.mjs @@ -0,0 +1,103 @@ +/** + * 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 ), + }; +} diff --git a/tests/byte-preview.mjs b/tests/byte-preview.mjs new file mode 100644 index 0000000..ff35690 --- /dev/null +++ b/tests/byte-preview.mjs @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict'; + +import { + ByteDocumentPreview, + splitByteSpan, +} from '../html-api-debugger/byte-preview.mjs'; + +class FakeUrlApi { + constructor() { + this.nextId = 1; + /** @type {Map} */ + this.blobs = new Map(); + /** @type {string[]} */ + this.revoked = []; + this.failCreation = false; + } + + /** @param {Blob} blob */ + createObjectURL( blob ) { + if ( this.failCreation ) { + throw new Error( 'URL creation failed' ); + } + const url = `blob:test-${ this.nextId++ }`; + this.blobs.set( url, blob ); + return url; + } + + /** @param {string} url */ + revokeObjectURL( url ) { + this.revoked.push( url ); + } +} + +let assignedSrc = 'about:blank'; +let failAssignment = false; +const iframe = { + get src() { + return assignedSrc; + }, + set src( value ) { + if ( failAssignment ) { + throw new Error( 'navigation failed' ); + } + assignedSrc = value; + }, +}; +const urlApi = new FakeUrlApi(); +const preview = new ByteDocumentPreview( iframe, urlApi ); + +const acceptanceBytes = Uint8Array.from( + Buffer.from( '3c696672616d653e41ff423c2f696672616d653e', 'hex' ), +); +const firstUrl = preview.load( acceptanceBytes ); +const firstBlob = urlApi.blobs.get( firstUrl ); +assert.ok( firstBlob instanceof Blob ); +assert.equal( firstBlob.type, 'text/html;charset=utf-8' ); +assert.deepEqual( new Uint8Array( await firstBlob.arrayBuffer() ), acceptanceBytes ); +assert.equal( iframe.src, firstUrl ); +assert.equal( preview.isCurrent( firstUrl ), true ); +assert.deepEqual( urlApi.revoked, [] ); + +const secondBytes = Uint8Array.of( 0xff, 0xc3, 0xbf ); +const secondUrl = preview.load( secondBytes ); +assert.notEqual( secondUrl, firstUrl ); +assert.equal( preview.isCurrent( firstUrl ), false ); +assert.equal( preview.isCurrent( secondUrl ), true ); +assert.deepEqual( urlApi.revoked, [ firstUrl ] ); +assert.equal( urlApi.revoked.includes( secondUrl ), false ); +assert.deepEqual( + new Uint8Array( await urlApi.blobs.get( secondUrl ).arrayBuffer() ), + secondBytes, +); + +failAssignment = true; +assert.throws( () => preview.load( Uint8Array.of( 1 ) ), /navigation failed/u ); +const orphanUrl = 'blob:test-3'; +assert.equal( preview.isCurrent( secondUrl ), true ); +assert.equal( iframe.src, secondUrl ); +assert.deepEqual( urlApi.revoked, [ firstUrl, orphanUrl ] ); + +urlApi.failCreation = true; +assert.throws( () => preview.load( Uint8Array.of( 2 ) ), /URL creation failed/u ); +assert.equal( preview.isCurrent( secondUrl ), true ); +assert.deepEqual( urlApi.revoked, [ firstUrl, orphanUrl ] ); +urlApi.failCreation = false; +failAssignment = false; + +preview.dispose(); +preview.dispose(); +assert.equal( preview.isCurrent( secondUrl ), false ); +assert.deepEqual( urlApi.revoked, [ firstUrl, orphanUrl, secondUrl ] ); + +assert.throws( + () => preview.load( /** @type {any} */ ( 'not bytes' ) ), + TypeError, +); + +const spanBytes = Uint8Array.of( 0xc3, 0xbf, 0xff, 0x41 ); +assert.deepEqual( splitByteSpan( spanBytes, 2, 1 ), { + before: Uint8Array.of( 0xc3, 0xbf ), + current: Uint8Array.of( 0xff ), + after: Uint8Array.of( 0x41 ), +} ); +assert.deepEqual( splitByteSpan( spanBytes, 0, 0 ), { + before: new Uint8Array(), + current: new Uint8Array(), + after: spanBytes, +} ); +assert.deepEqual( splitByteSpan( spanBytes, spanBytes.length, 0 ), { + before: spanBytes, + current: new Uint8Array(), + after: new Uint8Array(), +} ); + +for ( const [ start, length ] of [ + [ -1, 1 ], + [ 0, -1 ], + [ 4, 1 ], + [ 3, 2 ], +] ) { + assert.throws( () => splitByteSpan( spanBytes, start, length ), RangeError ); +} +for ( const [ start, length ] of [ + [ Number.NaN, 0 ], + [ Number.POSITIVE_INFINITY, 0 ], + [ 0.5, 1 ], + [ 0, 1.5 ], +] ) { + assert.throws( () => splitByteSpan( spanBytes, start, length ), TypeError ); +} + +console.log( 'All byte preview tests passed.' ); From 2d3ebd0fe9231b02db5c0f93f252ea06a90cd301 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 15 Jul 2026 14:25:56 +0200 Subject: [PATCH 07/16] Build redirects without loopholes --- html-api-debugger/legacy-url.php | 109 +++++++++++++++++++++++++++++++ tests/legacy-url-regression.php | 65 ++++++++++++++++++ 2 files changed, 174 insertions(+) diff --git a/html-api-debugger/legacy-url.php b/html-api-debugger/legacy-url.php index ef4bba0..a774411 100644 --- a/html-api-debugger/legacy-url.php +++ b/html-api-debugger/legacy-url.php @@ -97,3 +97,112 @@ function normalize_legacy_url_options( string $legacy_opts ): string { return implode( '', array_filter( $states, 'is_string' ) ); } + +/** + * Build a redirect URL using the browser's canonical form spelling. + * + * @param string $admin_url Admin endpoint URL. + * @param string $page Safe admin page slug. + * @param array $params Canonical transport parameters. + * @return string Canonical redirect URL. + * @throws \InvalidArgumentException When the target is not canonical and safe. + */ +function build_canonical_admin_url( string $admin_url, string $page, array $params ): string { + $expected_keys = array( 'format', 'html64', 'context64', 'selector', 'opts' ); + if ( $expected_keys !== array_keys( $params ) ) { + throw new \InvalidArgumentException( 'Invalid canonical redirect.' ); + } + foreach ( $params as $value ) { + if ( ! is_string( $value ) ) { + throw new \InvalidArgumentException( 'Invalid canonical redirect.' ); + } + } + + if ( + 'v1' !== $params['format'] || + 1 !== preg_match( '/\A[A-Za-z0-9_-]+\z/D', $page ) || + 1 !== preg_match( '//u', $params['selector'] ) || + 1 !== preg_match( '/\A[Cc]?[Ii]?[Vv]?\z/D', $params['opts'] ) + ) { + throw new \InvalidArgumentException( 'Invalid canonical redirect.' ); + } + + try { + decode_base64url( $params['html64'] ); + decode_base64url( $params['context64'] ); + } catch ( \InvalidArgumentException $e ) { + throw new \InvalidArgumentException( 'Invalid canonical redirect.' ); + } + + if ( 1 === preg_match( '/[\x00-\x20\x7F]/', $admin_url ) ) { + throw new \InvalidArgumentException( 'Invalid canonical redirect.' ); + } + $parts = parse_url( $admin_url ); + if ( + false === $parts || + ! isset( $parts['scheme'], $parts['host'] ) || + ! in_array( strtolower( $parts['scheme'] ), array( 'http', 'https' ), true ) || + '' === $parts['host'] || + isset( $parts['fragment'] ) + ) { + throw new \InvalidArgumentException( 'Invalid canonical redirect.' ); + } + + $reserved_names = array_merge( + array( 'page', 'format', 'html64', 'context64', 'selector', 'opts' ), + array( 'html', 'contextHTML', 'html-opts' ) + ); + if ( isset( $parts['query'] ) && '' !== $parts['query'] ) { + foreach ( explode( '&', $parts['query'] ) as $pair ) { + $equals = strpos( $pair, '=' ); + $raw_name = false === $equals ? $pair : substr( $pair, 0, $equals ); + $name = decode_form_query_name( $raw_name ); + if ( in_array( $name, $reserved_names, true ) ) { + throw new \InvalidArgumentException( 'Invalid canonical redirect.' ); + } + } + } + + $query = array( + 'page=' . $page, + 'format=v1', + 'html64=' . $params['html64'], + 'context64=' . $params['context64'], + 'selector=' . str_replace( '%2A', '*', urlencode( $params['selector'] ) ), + 'opts=' . $params['opts'], + ); + + return $admin_url . ( isset( $parts['query'] ) ? '&' : '?' ) . implode( '&', $query ); +} + +/** + * Strictly form-decode a base-query name for collision checks. + * + * @param string $raw_name Raw query name. + * @return string Decoded bytes. + * @throws \InvalidArgumentException When percent encoding is malformed. + */ +function decode_form_query_name( string $raw_name ): string { + $decoded = ''; + $length = strlen( $raw_name ); + for ( $offset = 0; $offset < $length; ++$offset ) { + $character = $raw_name[ $offset ]; + if ( '+' === $character ) { + $decoded .= ' '; + continue; + } + if ( '%' !== $character ) { + $decoded .= $character; + continue; + } + if ( + $offset + 2 >= $length || + ! ctype_xdigit( $raw_name[ $offset + 1 ] . $raw_name[ $offset + 2 ] ) + ) { + throw new \InvalidArgumentException( 'Invalid canonical redirect.' ); + } + $decoded .= chr( hexdec( substr( $raw_name, $offset + 1, 2 ) ) ); + $offset += 2; + } + return $decoded; +} diff --git a/tests/legacy-url-regression.php b/tests/legacy-url-regression.php index 34485d4..d55a866 100644 --- a/tests/legacy-url-regression.php +++ b/tests/legacy-url-regression.php @@ -144,4 +144,69 @@ function html_api_debugger_legacy_migrate( array $query ): ?array { } } +$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"; From 45cd0a3095f947bcf7a18132530963e231246ae3 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 15 Jul 2026 14:29:17 +0200 Subject: [PATCH 08/16] Keep response bytes beside text --- html-api-debugger/response-transport.mjs | 117 +++++++++++++++++++++++ tests/response-transport.mjs | 110 +++++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 html-api-debugger/response-transport.mjs create mode 100644 tests/response-transport.mjs diff --git a/html-api-debugger/response-transport.mjs b/html-api-debugger/response-transport.mjs new file mode 100644 index 0000000..c40270f --- /dev/null +++ b/html-api-debugger/response-transport.mjs @@ -0,0 +1,117 @@ +import { + decodeBase64url, + isByteEnvelope, + projectResponseStrings, +} from './byte-transport.mjs'; + +/** + * Decode a successful byte-enveloped HTML API response. + * + * The raw response is retained by identity. Existing tree consumers receive a + * separate Unicode projection, while block-sized and playback strings remain + * available as exact bytes. + * + * @param {unknown} raw Raw JSON response. + * @returns {{raw: unknown, projected: Record, htmlBytes: Uint8Array, normalizedBytes: Uint8Array|null, errorBytes: Uint8Array|null, playbackBytes: Uint8Array[]}} Decoded response views. + */ +export function decodeHtmlApiResponse( raw ) { + const projected = projectResponseStrings( raw ); + if ( ! isPlainObject( raw ) || ! isPlainObject( projected ) ) { + throw new TypeError( 'Invalid HTML API response.' ); + } + const rawHtml = raw[ 'html' ]; + const rawNormalizedHtml = raw[ 'normalizedHtml' ]; + const rawError = raw[ 'error' ]; + const rawSupports = raw[ 'supports' ]; + const rawResult = raw[ 'result' ]; + const projectedHtml = projected[ 'html' ]; + const projectedNormalizedHtml = projected[ 'normalizedHtml' ]; + const projectedError = projected[ 'error' ]; + const projectedSupports = projected[ 'supports' ]; + const projectedResult = projected[ 'result' ]; + + if ( + ! isByteEnvelope( rawHtml ) || + ! ( rawNormalizedHtml === null || isByteEnvelope( rawNormalizedHtml ) ) || + ! ( rawError === null || isByteEnvelope( rawError ) ) || + ! isPlainObject( rawSupports ) || + ! ( rawResult === null || isPlainObject( rawResult ) ) + ) { + throw new TypeError( 'Invalid HTML API response.' ); + } + + if ( + typeof projectedHtml !== 'string' || + ! ( projectedNormalizedHtml === null || typeof projectedNormalizedHtml === 'string' ) || + ! ( projectedError === null || typeof projectedError === 'string' ) || + ! isPlainObject( projectedSupports ) || + ! ( projectedResult === null || isPlainObject( projectedResult ) ) + ) { + throw new TypeError( 'Invalid projected HTML API response.' ); + } + + /** @type {Uint8Array[]} */ + const playbackBytes = []; + if ( rawResult !== null ) { + const rawPlayback = rawResult[ 'playback' ]; + if ( ! Array.isArray( rawPlayback ) ) { + throw new TypeError( 'Invalid HTML API playback response.' ); + } + for ( const entry of rawPlayback ) { + if ( + ! Array.isArray( entry ) || + entry.length !== 2 || + ! isByteEnvelope( entry[ 0 ] ) + ) { + throw new TypeError( 'Invalid HTML API playback response.' ); + } + playbackBytes.push( decodeBase64url( entry[ 0 ].__bytesBase64url ) ); + } + + if ( + projectedResult === null || + ! Array.isArray( projectedResult[ 'playback' ] ) || + projectedResult[ 'playback' ].length !== playbackBytes.length + ) { + throw new TypeError( 'Invalid projected HTML API playback response.' ); + } + for ( const entry of projectedResult[ 'playback' ] ) { + if ( + ! Array.isArray( entry ) || + entry.length !== 2 || + typeof entry[ 0 ] !== 'string' + ) { + throw new TypeError( 'Invalid projected HTML API playback response.' ); + } + } + } + + return { + raw, + projected, + htmlBytes: decodeBase64url( rawHtml.__bytesBase64url ), + normalizedBytes: + rawNormalizedHtml === null + ? null + : decodeBase64url( rawNormalizedHtml.__bytesBase64url ), + errorBytes: + rawError === null + ? null + : decodeBase64url( rawError.__bytesBase64url ), + playbackBytes, + }; +} + +/** + * Determine whether a value is a JSON object. + * + * @param {unknown} value Value to inspect. + * @returns {value is Record} Whether the value is plain. + */ +function isPlainObject( value ) { + if ( value === null || typeof value !== 'object' || Array.isArray( value ) ) { + return false; + } + const prototype = Object.getPrototypeOf( value ); + return prototype === Object.prototype || prototype === null; +} 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.' ); From 3d26b88129ef7b18e331e9c2ef0a757c2f3b6809 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 15 Jul 2026 14:41:43 +0200 Subject: [PATCH 09/16] Keep runtime bytes in charge --- html-api-debugger/runtime-controller.mjs | 361 +++++++++++++++++++++++ tests/runtime-controller.mjs | 303 +++++++++++++++++++ 2 files changed, 664 insertions(+) create mode 100644 html-api-debugger/runtime-controller.mjs create mode 100644 tests/runtime-controller.mjs diff --git a/html-api-debugger/runtime-controller.mjs b/html-api-debugger/runtime-controller.mjs new file mode 100644 index 0000000..d9c53c5 --- /dev/null +++ b/html-api-debugger/runtime-controller.mjs @@ -0,0 +1,361 @@ +import { + canonicalUrlPath, + parseCanonicalUrl, + serializeCanonicalUrl, +} from './canonical-url.mjs'; +import { splitByteSpan } from './byte-preview.mjs'; +import { + decodeUtf8, + encodeBase64url, + encodeUtf8, + isValidUtf8, + projectUtf8, +} from './byte-transport.mjs'; +import { decodeHtmlApiResponse } from './response-transport.mjs'; + +/** + * Byte-authoritative controller for the debugger runtime. + */ +export class ByteRuntimeController { + /** @type {(body: {html64: string, context64: string, selector: string}) => Promise} */ + #request; + /** @type {(url: URL) => void} */ + #replaceUrl; + /** @type {(message: string) => boolean} */ + #confirmConversion; + /** @type {boolean} */ + #supportsFragments; + /** @type {URL} */ + #url; + /** @type {Uint8Array} */ + #htmlBytes; + /** @type {Uint8Array} */ + #contextBytes; + /** @type {string} */ + #selector; + /** @type {string} */ + #opts; + /** @type {string|null} */ + #urlError; + /** @type {ReturnType|null} */ + #decodedResponse; + /** @type {number} */ + #requestGeneration; + /** @type {boolean} */ + #isProcessing; + + /** + * @param {{url: URL, supports: {create_fragment_advanced: boolean}, request: (body: {html64: string, context64: string, selector: string}) => Promise, replaceUrl: (url: URL) => void, confirmConversion: (message: string) => boolean}} options Injected runtime boundaries. + */ + constructor( options ) { + this.#request = options.request; + this.#replaceUrl = options.replaceUrl; + this.#confirmConversion = options.confirmConversion; + this.#supportsFragments = Boolean( + options.supports.create_fragment_advanced, + ); + this.#url = new URL( options.url.href ); + this.#htmlBytes = new Uint8Array(); + this.#contextBytes = new Uint8Array(); + this.#selector = ''; + this.#opts = ''; + this.#urlError = null; + this.#decodedResponse = null; + this.#requestGeneration = 0; + this.#isProcessing = false; + + try { + const parsed = parseCanonicalUrl( this.#url ); + this.#htmlBytes = parsed.htmlBytes.slice(); + this.#contextBytes = parsed.contextBytes.slice(); + this.#selector = parsed.selector; + this.#opts = parsed.opts; + if ( parsed.needsCanonicalization ) { + this.#rewriteUrl(); + } + } catch ( error ) { + this.#urlError = + error instanceof Error ? error.message : 'Invalid canonical URL.'; + } + } + + get urlError() { + return this.#urlError; + } + + get selector() { + return this.#selector; + } + + get opts() { + return this.#opts; + } + + get htmlBytes() { + return this.#htmlBytes.slice(); + } + + get contextBytes() { + return this.#contextBytes.slice(); + } + + get isProcessing() { + return this.#isProcessing; + } + + get rawResponse() { + return this.#decodedResponse?.raw ?? null; + } + + get projectedResponse() { + return this.#decodedResponse?.projected ?? null; + } + + get normalizedBytes() { + return this.#decodedResponse?.normalizedBytes?.slice() ?? null; + } + + /** Start initial processing. */ + async start() { + return this.process(); + } + + /** + * Process the current exact source snapshot. + * + * Only the newest generation may install a response or surface a failure. + */ + async process() { + if ( this.#urlError !== null ) { + return null; + } + + const generation = ++this.#requestGeneration; + this.#decodedResponse = null; + this.#isProcessing = true; + const body = { + html64: encodeBase64url( this.#htmlBytes ), + context64: encodeBase64url( this.#contextBytes ), + selector: this.#selector, + }; + + try { + const raw = await this.#request( body ); + if ( generation !== this.#requestGeneration ) { + return null; + } + const decoded = decodeHtmlApiResponse( raw ); + if ( generation !== this.#requestGeneration ) { + return null; + } + this.#decodedResponse = decoded; + return decoded; + } catch ( error ) { + if ( generation !== this.#requestGeneration ) { + return null; + } + throw error; + } finally { + if ( generation === this.#requestGeneration ) { + this.#isProcessing = false; + } + } + } + + /** + * Replace source bytes with an intentional Unicode edit and process them. + * + * @param {'html'|'context'} kind Source kind. + * @param {string} text Unicode editor value. + */ + async editSource( kind, text ) { + this.#setSourceBytes( kind, encodeUtf8( text ) ); + this.#rewriteUrl(); + return this.process(); + } + + /** + * Request editable text, explicitly converting malformed bytes when approved. + * + * @param {'html'|'context'} kind Source kind. + * @returns {Promise} Editable text, or null when conversion is cancelled. + */ + async requestTextEditing( kind ) { + const bytes = this.#getSourceBytes( kind ); + if ( isValidUtf8( bytes ) ) { + return decodeUtf8( bytes ); + } + + if ( + ! this.#confirmConversion( + 'Editing this malformed UTF-8 will replace invalid bytes and change the source.', + ) + ) { + return null; + } + + const text = projectUtf8( bytes ); + this.#setSourceBytes( kind, encodeUtf8( text ) ); + this.#rewriteUrl(); + await this.process(); + return text; + } + + /** @param {string} selector */ + async setSelector( selector ) { + const previous = this.#selector; + this.#selector = selector; + try { + this.#rewriteUrl(); + } catch ( error ) { + this.#selector = previous; + throw error; + } + return this.process(); + } + + /** @param {string} opts */ + setOpts( opts ) { + const previous = this.#opts; + this.#opts = opts; + try { + this.#rewriteUrl(); + } catch ( error ) { + this.#opts = previous; + throw error; + } + } + + /** + * Get exact processed bytes for the final or one playback state. + * + * @param {number|null} [playbackIndex=null] Zero-based playback index. + */ + getProcessedBytes( playbackIndex = null ) { + if ( this.#decodedResponse === null ) { + if ( playbackIndex !== null ) { + throw new RangeError( 'Playback is not available.' ); + } + return this.#htmlBytes.slice(); + } + if ( playbackIndex === null ) { + return this.#decodedResponse.htmlBytes.slice(); + } + if ( + ! Number.isInteger( playbackIndex ) || + playbackIndex < 0 || + playbackIndex >= this.#decodedResponse.playbackBytes.length + ) { + throw new RangeError( 'Playback index is out of range.' ); + } + const playbackBytes = this.#decodedResponse.playbackBytes[ playbackIndex ]; + if ( playbackBytes === undefined ) { + throw new RangeError( 'Playback index is out of range.' ); + } + return playbackBytes.slice(); + } + + /** + * Plan exact document navigation and optional native fragment projection. + * + * @param {number|null} [playbackIndex=null] Playback index. + */ + getPreviewPlan( playbackIndex = null ) { + const processed = this.getProcessedBytes( playbackIndex ); + if ( this.#supportsFragments && this.#contextBytes.length > 0 ) { + return { + documentBytes: this.#contextBytes.slice(), + fragment: { + bytes: processed.slice(), + text: projectUtf8( processed ), + lossy: ! isValidUtf8( processed ), + }, + }; + } + return { + documentBytes: processed.slice(), + fragment: null, + }; + } + + /** + * Split the current exact processed bytes at a byte span. + * + * @param {number} start Byte offset. + * @param {number} length Byte length. + * @param {number|null} [playbackIndex=null] Playback index. + */ + splitProcessedSpan( start, length, playbackIndex = null ) { + return splitByteSpan( + this.getProcessedBytes( playbackIndex ), + start, + length, + ); + } + + /** @param {string|null} [resolvedOpts=null] Optional resolved share options. */ + getCanonicalUrl( resolvedOpts = null ) { + return serializeCanonicalUrl( this.#url, { + htmlBytes: this.#htmlBytes, + contextBytes: this.#contextBytes, + selector: this.#selector, + opts: resolvedOpts ?? this.#opts, + } ); + } + + /** + * Build a WordPress Playground link containing the canonical admin path. + * + * @param {URL} playgroundBase Playground base URL. + * @param {string} resolvedOpts Resolved view options. + * @param {string|null} [wpVersion=null] Optional WordPress version. + */ + getPlaygroundUrl( playgroundBase, resolvedOpts, wpVersion = null ) { + const playground = new URL( playgroundBase.href ); + playground.searchParams.set( + 'url', + canonicalUrlPath( this.getCanonicalUrl( resolvedOpts ) ), + ); + if ( wpVersion !== null ) { + playground.searchParams.set( 'wp', wpVersion ); + } + return playground; + } + + /** @param {number} [threshold=8192] Advisory character threshold. */ + isUrlUnusuallyLong( threshold = 8192 ) { + if ( ! Number.isInteger( threshold ) || threshold <= 0 ) { + throw new RangeError( 'URL threshold must be a positive integer.' ); + } + return this.getCanonicalUrl().href.length > threshold; + } + + /** @param {'html'|'context'} kind */ + #getSourceBytes( kind ) { + if ( kind === 'html' ) { + return this.#htmlBytes; + } + if ( kind === 'context' ) { + return this.#contextBytes; + } + throw new TypeError( 'Unknown source kind.' ); + } + + /** @param {'html'|'context'} kind @param {Uint8Array} bytes */ + #setSourceBytes( kind, bytes ) { + if ( kind === 'html' ) { + this.#htmlBytes = bytes.slice(); + return; + } + if ( kind === 'context' ) { + this.#contextBytes = bytes.slice(); + return; + } + throw new TypeError( 'Unknown source kind.' ); + } + + #rewriteUrl() { + this.#url = this.getCanonicalUrl(); + this.#replaceUrl( new URL( this.#url.href ) ); + } +} diff --git a/tests/runtime-controller.mjs b/tests/runtime-controller.mjs new file mode 100644 index 0000000..12cc3e5 --- /dev/null +++ b/tests/runtime-controller.mjs @@ -0,0 +1,303 @@ +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: '' } ); + +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( '' ); +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, 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', +); + +console.log( 'All runtime controller tests passed.' ); From 702aff306702f377f8525bb034f543abe094fd30 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 15 Jul 2026 15:34:06 +0200 Subject: [PATCH 10/16] Make runtime races lose --- html-api-debugger/runtime-wiring.mjs | 424 +++++++++++++++++++++++++++ tests/runtime-wiring.mjs | 399 +++++++++++++++++++++++++ 2 files changed, 823 insertions(+) create mode 100644 html-api-debugger/runtime-wiring.mjs create mode 100644 tests/runtime-wiring.mjs diff --git a/html-api-debugger/runtime-wiring.mjs b/html-api-debugger/runtime-wiring.mjs new file mode 100644 index 0000000..0a1e826 --- /dev/null +++ b/html-api-debugger/runtime-wiring.mjs @@ -0,0 +1,424 @@ +/** Error raised when runtime work is replaced by a newer operation. */ +export class SupersededRuntimeOperationError extends Error { + constructor() { + super( 'Runtime operation was superseded.' ); + this.name = 'SupersededRuntimeOperationError'; + } +} + +/** Error raised when work is requested after a runtime boundary is disposed. */ +export class DisposedRuntimeBoundaryError extends Error { + constructor() { + super( 'Runtime boundary is disposed.' ); + this.name = 'DisposedRuntimeBoundaryError'; + } +} + +/** + * Debounce, cancel, and authenticate byte-safe REST requests. + */ +export class ByteRequestBoundary { + /** @type {string} */ + #endpoint; + /** @type {string} */ + #nonce; + /** @type {typeof fetch} */ + #fetch; + /** @type {typeof AbortController} */ + #AbortController; + /** @type {(callback: () => void, delay: number) => unknown} */ + #setTimer; + /** @type {(timer: unknown) => void} */ + #clearTimer; + /** @type {number} */ + #delay; + /** @type {number} */ + #generation = 0; + /** @type {boolean} */ + #disposed = false; + /** @type {{generation: number, timer: unknown, reject: (reason: unknown) => void}|null} */ + #pending = null; + /** @type {{generation: number, controller: AbortController}|null} */ + #active = null; + + /** + * @param {{endpoint: string, nonce: string, fetch: typeof fetch, AbortController: typeof AbortController, setTimer?: (callback: () => void, delay: number) => unknown, clearTimer?: (timer: unknown) => void, delay?: number}} options Injected request boundaries. + */ + constructor( options ) { + this.#endpoint = options.endpoint; + this.#nonce = options.nonce; + this.#fetch = options.fetch; + this.#AbortController = options.AbortController; + this.#setTimer = options.setTimer ?? setTimeout; + this.#clearTimer = + options.clearTimer ?? + ( ( timer ) => clearTimeout( /** @type {number} */ ( timer ) ) ); + this.#delay = options.delay ?? 150; + + if ( ! Number.isInteger( this.#delay ) || this.#delay < 0 ) { + throw new RangeError( 'Request delay must be a non-negative integer.' ); + } + } + + /** + * Schedule the newest byte-safe request. + * + * @param {{html64: string, context64: string, selector: string}} body JSON-safe request body. + * @returns {Promise} Parsed successful response. + */ + request( body ) { + if ( this.#disposed ) { + return Promise.reject( new DisposedRuntimeBoundaryError() ); + } + + const generation = ++this.#generation; + this.#cancelPending( new SupersededRuntimeOperationError() ); + this.#abortActive( new SupersededRuntimeOperationError() ); + + return new Promise( ( resolve, reject ) => { + const timer = this.#setTimer( () => { + if ( + this.#disposed || + this.#generation !== generation || + this.#pending?.generation !== generation + ) { + reject( new SupersededRuntimeOperationError() ); + return; + } + + this.#pending = null; + void this.#perform( generation, body ).then( resolve, reject ); + }, this.#delay ); + + this.#pending = { generation, timer, reject }; + } ); + } + + /** Permanently cancel and reject all owned work. */ + dispose() { + if ( this.#disposed ) { + return; + } + this.#disposed = true; + ++this.#generation; + const error = new DisposedRuntimeBoundaryError(); + this.#cancelPending( error ); + this.#abortActive( error ); + } + + /** + * @param {number} generation Operation generation. + * @param {{html64: string, context64: string, selector: string}} body Request body. + */ + async #perform( generation, body ) { + if ( this.#disposed || generation !== this.#generation ) { + throw new SupersededRuntimeOperationError(); + } + + const controller = new this.#AbortController(); + this.#active = { generation, controller }; + + try { + const response = await this.#fetch( this.#endpoint, { + method: 'POST', + body: JSON.stringify( body ), + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': this.#nonce, + }, + signal: controller.signal, + } ); + + this.#assertCurrent( generation ); + const nextNonce = response.headers.get( 'X-WP-Nonce' ); + if ( nextNonce !== null ) { + this.#nonce = nextNonce; + } + if ( ! response.ok ) { + throw response; + } + + const result = await response.json(); + this.#assertCurrent( generation ); + return result; + } finally { + if ( + this.#active?.generation === generation && + this.#active.controller === controller + ) { + this.#active = null; + } + } + } + + /** @param {number} generation Operation generation. */ + #assertCurrent( generation ) { + if ( this.#disposed || generation !== this.#generation ) { + throw new SupersededRuntimeOperationError(); + } + } + + /** @param {unknown} reason Rejection reason. */ + #cancelPending( reason ) { + if ( this.#pending === null ) { + return; + } + const pending = this.#pending; + this.#pending = null; + this.#clearTimer( pending.timer ); + pending.reject( reason ); + } + + /** @param {unknown} reason Abort reason. */ + #abortActive( reason ) { + if ( this.#active === null ) { + return; + } + const active = this.#active; + this.#active = null; + active.controller.abort( reason ); + } +} + +/** + * Coordinate exact-byte iframe navigations and fragment initialization. + */ +export class BytePreviewCoordinator { + /** @type {{load: (bytes: Uint8Array) => string, isCurrent: (url: string) => boolean, dispose: () => void}} */ + #preview; + /** @type {HTMLIFrameElement} */ + #iframe; + /** @type {(document: Document) => Element|null} */ + #resolveFragmentTarget; + /** @type {(details: {document: Document, contextElement: Element|null, fragmentLossy: boolean, url: string}) => void} */ + #onCurrentDocument; + /** @type {(details: {document: Document, url: string}) => void} */ + #restoreCurrentDocument; + /** @type {() => void} */ + #disconnectObserver; + /** @type {{addEventListener: (type: string, listener: EventListener) => void, removeEventListener: (type: string, listener: EventListener) => void}} */ + #pagehideTarget; + /** @type {{key: string, url: string}|null} */ + #committed = null; + /** @type {{generation: number, key: string, url: string|null, handler: EventListener, plan: PreviewPlan}|null} */ + #pending = null; + /** @type {string|null} */ + #ownedUrl = null; + /** @type {number} */ + #generation = 0; + /** @type {boolean} */ + #disposed = false; + /** @type {EventListener} */ + #pagehideHandler; + + /** + * @param {{preview: {load: (bytes: Uint8Array) => string, isCurrent: (url: string) => boolean, dispose: () => void}, iframe: HTMLIFrameElement, resolveFragmentTarget: (document: Document) => Element|null, onCurrentDocument: (details: {document: Document, contextElement: Element|null, fragmentLossy: boolean, url: string}) => void, restoreCurrentDocument: (details: {document: Document, url: string}) => void, disconnectObserver: () => void, pagehideTarget: {addEventListener: (type: string, listener: EventListener) => void, removeEventListener: (type: string, listener: EventListener) => void}}} options Injected preview boundaries. + */ + constructor( options ) { + this.#preview = options.preview; + this.#iframe = options.iframe; + this.#resolveFragmentTarget = options.resolveFragmentTarget; + this.#onCurrentDocument = options.onCurrentDocument; + this.#restoreCurrentDocument = options.restoreCurrentDocument; + this.#disconnectObserver = options.disconnectObserver; + this.#pagehideTarget = options.pagehideTarget; + this.#pagehideHandler = () => this.dispose(); + this.#pagehideTarget.addEventListener( + 'pagehide', + this.#pagehideHandler, + ); + } + + /** + * Navigate only when the exact byte plan differs from committed and pending work. + * + * @param {PreviewPlan} plan Exact preview plan. + * @returns {boolean} Whether a navigation was started. + */ + render( plan ) { + if ( this.#disposed ) { + throw new DisposedRuntimeBoundaryError(); + } + + const key = previewPlanKey( plan ); + if ( this.#committed?.key === key || this.#pending?.key === key ) { + return false; + } + + this.#disconnectObserver(); + this.#clearPending(); + + const generation = ++this.#generation; + /** @type {EventListener} */ + const handler = () => this.#handleLoad( generation ); + this.#pending = { + generation, + key, + url: null, + handler, + plan, + }; + this.#iframe.addEventListener( 'load', handler ); + + try { + const url = this.#preview.load( plan.documentBytes ); + if ( this.#pending?.generation !== generation ) { + return true; + } + this.#pending.url = url; + this.#ownedUrl = url; + // The successful load revoked the URL represented by any prior commit. + this.#committed = null; + return true; + } catch ( error ) { + if ( this.#pending?.generation === generation ) { + this.#clearPending(); + this.#restoreCurrent(); + } + throw error; + } + } + + /** Permanently release all listeners and the current object URL. */ + dispose() { + if ( this.#disposed ) { + return; + } + this.#disposed = true; + ++this.#generation; + this.#clearPending(); + this.#committed = null; + this.#disconnectObserver(); + this.#pagehideTarget.removeEventListener( + 'pagehide', + this.#pagehideHandler, + ); + this.#preview.dispose(); + this.#ownedUrl = null; + } + + /** @param {number} generation Pending generation. */ + #handleLoad( generation ) { + const pending = this.#pending; + if ( + this.#disposed || + pending === null || + pending.generation !== generation || + pending.url === null + ) { + return; + } + + let loadedUrl; + try { + loadedUrl = this.#iframe.contentWindow?.location.href; + } catch { + this.#failPending( generation ); + return; + } + + try { + if ( + ! this.#preview.isCurrent( pending.url ) || + loadedUrl !== pending.url + ) { + return; + } + + this.#iframe.removeEventListener( 'load', pending.handler ); + const document = this.#iframe.contentWindow?.document; + if ( document === undefined ) { + throw new Error( 'The preview document is unavailable.' ); + } + + let contextElement = null; + if ( pending.plan.fragment !== null ) { + contextElement = this.#resolveFragmentTarget( document ); + if ( contextElement === null ) { + throw new Error( 'The fragment context is unavailable.' ); + } + contextElement.innerHTML = pending.plan.fragment.text; + } + + this.#onCurrentDocument( { + document, + contextElement, + fragmentLossy: pending.plan.fragment?.lossy ?? false, + url: pending.url, + } ); + + if ( this.#pending?.generation !== generation ) { + return; + } + this.#committed = { key: pending.key, url: pending.url }; + this.#pending = null; + } catch { + this.#failPending( generation ); + } + } + + /** @param {number} generation Failed pending generation. */ + #failPending( generation ) { + if ( this.#pending?.generation !== generation ) { + return; + } + this.#clearPending(); + this.#committed = null; + this.#restoreCurrent(); + } + + #clearPending() { + if ( this.#pending === null ) { + return; + } + this.#iframe.removeEventListener( 'load', this.#pending.handler ); + this.#pending = null; + } + + #restoreCurrent() { + if ( this.#ownedUrl === null || ! this.#preview.isCurrent( this.#ownedUrl ) ) { + return; + } + try { + const document = this.#iframe.contentWindow?.document; + if ( document !== undefined ) { + this.#restoreCurrentDocument( { + document, + url: this.#ownedUrl, + } ); + } + } catch { + // A failed recovery must not escape an event handler or poison retry state. + } + } +} + +/** + * @typedef PreviewPlan + * @property {Uint8Array} documentBytes + * @property {{bytes: Uint8Array, text: string, lossy: boolean}|null} fragment + */ + +/** @param {PreviewPlan} plan Exact preview plan. */ +function previewPlanKey( plan ) { + if ( ! ( plan.documentBytes instanceof Uint8Array ) ) { + throw new TypeError( 'Preview document bytes must be a Uint8Array.' ); + } + let key = `D${ bytesKey( plan.documentBytes ) }`; + if ( plan.fragment === null ) { + return `${ key }N`; + } + if ( ! ( plan.fragment.bytes instanceof Uint8Array ) ) { + throw new TypeError( 'Preview fragment bytes must be a Uint8Array.' ); + } + return `${ key }F${ bytesKey( plan.fragment.bytes ) }`; +} + +/** @param {Uint8Array} bytes Exact bytes. */ +function bytesKey( bytes ) { + let key = `${ bytes.length }:`; + for ( const byte of bytes ) { + key += byte.toString( 16 ).padStart( 2, '0' ); + } + return key; +} diff --git a/tests/runtime-wiring.mjs b/tests/runtime-wiring.mjs new file mode 100644 index 0000000..19ff243 --- /dev/null +++ b/tests/runtime-wiring.mjs @@ -0,0 +1,399 @@ +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, +); + +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.' ); From 28ddc4c87ccd109c511735afc93f98d337125ba1 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 15 Jul 2026 16:20:47 +0200 Subject: [PATCH 11/16] Make bytes the source of truth --- html-api-debugger/byte-preview.mjs | 37 + html-api-debugger/canonical-url.mjs | 10 +- html-api-debugger/html-api-debugger.php | 90 +- html-api-debugger/interactivity.php | 301 +++-- html-api-debugger/main.mjs | 1454 +++++++++++----------- html-api-debugger/readme.txt | 4 +- html-api-debugger/response-transport.mjs | 10 +- html-api-debugger/runtime-controller.mjs | 29 +- html-api-debugger/style.css | 26 + tests/byte-preview.mjs | 83 ++ tests/main-wiring.mjs | 74 ++ tests/plugin-cutover-regression.php | 274 ++++ 12 files changed, 1437 insertions(+), 955 deletions(-) create mode 100644 tests/main-wiring.mjs create mode 100644 tests/plugin-cutover-regression.php diff --git a/html-api-debugger/byte-preview.mjs b/html-api-debugger/byte-preview.mjs index e3564e1..983cec0 100644 --- a/html-api-debugger/byte-preview.mjs +++ b/html-api-debugger/byte-preview.mjs @@ -101,3 +101,40 @@ export function splitByteSpan( bytes, start, length ) { 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.' ); + } + + if ( document.body?.hasChildNodes() || document.head?.hasChildNodes() ) { + const walker = document.createTreeWalker( document, 1 ); + /** @type {Element|null} */ + let lastElement = null; + while ( walker.nextNode() ) { + lastElement = /** @type {Element} */ ( walker.currentNode ); + } + if ( lastElement !== null ) { + return lastElement; + } + } + + if ( / 'POST', - 'callback' => function ( WP_REST_Request $request ) { - // phpcs:ignore Universal.Operators.DisallowShortTernary.Found - $html = $request->get_json_params()['html'] ?: ''; - $options = array( - // phpcs:ignore Universal.Operators.DisallowShortTernary.Found - 'context_html' => $request->get_json_params()['contextHTML'] ?: null, - // phpcs:ignore Universal.Operators.DisallowShortTernary.Found - 'selector' => $request->get_json_params()['selector'] ?: null, - ); - return prepare_html_result_object( $html, $options ); - }, - 'permission_callback' => function () { - return current_user_can( 'edit_posts' ); - }, - ) - ); } ); + add_action( 'admin_init', __NAMESPACE__ . '\\maybe_redirect_legacy_url', 0 ); + wp_register_script_module( '@html-api-debugger/replace-invisible-chars', plugins_url( 'replace-invisible-chars.mjs', __FILE__ ), @@ -126,27 +107,8 @@ function () { SLUG, function () { require_once __DIR__ . '/interactivity.php'; - - $options = array( - 'context_html' => null, - 'selector' => null, - ); - - $html = ''; - // phpcs:disable WordPress.Security.NonceVerification.Recommended - if ( isset( $_GET['html'] ) && \is_string( $_GET['html'] ) ) { - $html = stripslashes( $_GET['html'] ); - } - if ( isset( $_GET['contextHTML'] ) && \is_string( $_GET['contextHTML'] ) ) { - $options['context_html'] = stripslashes( $_GET['contextHTML'] ); - } - if ( isset( $_GET['selector'] ) && \is_string( $_GET['selector'] ) ) { - $options['selector'] = stripslashes( $_GET['selector'] ); - } - // phpcs:enable WordPress.Security.NonceVerification.Recommended - // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - echo namespace\Interactivity\generate_page( $html, $options ); + echo namespace\Interactivity\generate_page(); }, include __DIR__ . '/icon.php' ); @@ -154,6 +116,46 @@ function () { ); } +/** + * Redirect legacy string URLs before the application shell is rendered. + */ +function maybe_redirect_legacy_url(): void { + // phpcs:disable WordPress.Security.NonceVerification.Recommended + if ( + ! isset( $_GET['page'] ) || + ! is_string( $_GET['page'] ) || + SLUG !== wp_unslash( $_GET['page'] ) + ) { + return; + } + + try { + $params = get_legacy_redirect_params( $_GET ); + if ( null === $params ) { + return; + } + $target = build_canonical_admin_url( admin_url( 'admin.php' ), SLUG, $params ); + } catch ( \InvalidArgumentException $e ) { + wp_die( + 'Invalid legacy HTML API Debugger URL.', + 'Invalid HTML API Debugger URL', + array( 'response' => 400 ) + ); + return; + } + // phpcs:enable WordPress.Security.NonceVerification.Recommended + + if ( ! wp_safe_redirect( $target, 302, 'HTML API Debugger' ) ) { + wp_die( + 'Could not redirect the legacy HTML API Debugger URL.', + 'HTML API Debugger redirect failed', + array( 'response' => 500 ) + ); + return; + } + exit; +} + /** * Prepare a result object. * diff --git a/html-api-debugger/interactivity.php b/html-api-debugger/interactivity.php index 59cc8be..4dbf2b7 100644 --- a/html-api-debugger/interactivity.php +++ b/html-api-debugger/interactivity.php @@ -2,21 +2,19 @@ namespace HTML_API_Debugger\Interactivity; /** - * Generate the WP Admin page HTML. + * Generate an input-independent WP Admin application shell. * - * @param string $html The input html. - * @param array $options The input html. - * @return The page HTML as rendered by the Interactivity API. This is intended to be printed directly to the page with no additional escaping. + * @return string Page HTML processed by the Interactivity API. */ -function generate_page( string $html, array $options ): string { - // phpcs:enable WordPress.Security.NonceVerification.Recommended - $htmlapi_response = \HTML_API_Debugger\prepare_html_result_object( $html, $options ); +function generate_page(): string { + $supports = \HTML_API_Debugger\HTML_API_Integration\get_supports(); wp_interactivity_config( \HTML_API_Debugger\SLUG, array( - 'restEndpoint' => rest_url( 'html-api-debugger/v1/htmlapi' ), + 'restEndpoint' => rest_url( 'html-api-debugger/v2/htmlapi' ), 'nonce' => wp_create_nonce( 'wp_rest' ), + 'supports' => $supports, ) ); wp_interactivity_state( @@ -28,68 +26,135 @@ function generate_page( string $html, array $options ): string { 'doctypeName' => '', 'doctypeSystemId' => '', 'doctypePublicId' => '', + 'contextNode' => '', ), 'hasMutatedDom' => false, - 'html' => $html, - 'htmlapiResponse' => $htmlapi_response, - + 'htmlapiResponse' => array( + 'supports' => $supports, + 'html' => '', + 'error' => null, + 'result' => null, + 'normalizedHtml' => null, + ), + 'htmlView' => 'text', + 'contextView' => 'text', + 'processedView' => 'text', + 'normalizedView' => 'text', + 'htmlText' => '', + 'contextText' => '', + 'htmlByteRows' => '', + 'contextByteRows' => '', + 'processedText' => '', + 'processedByteRows' => '', + 'normalizedText' => '', + 'normalizedByteRows' => '', + 'htmlTextHidden' => false, + 'htmlBytesHidden' => true, + 'contextTextHidden' => false, + 'contextBytesHidden' => true, + 'processedTextHidden' => false, + 'processedBytesHidden' => true, + 'normalizedTextHidden' => false, + 'normalizedBytesHidden' => true, + 'htmlConversionHidden' => true, + 'contextConversionHidden' => true, + 'normalizedUnavailable' => true, + 'urlError' => null, + 'transportError' => null, + 'previewError' => null, + 'urlUnusuallyLong' => false, + 'fragmentProjectionLossy' => false, + 'contextUnsupported' => ! $supports['create_fragment_advanced'], + 'processing' => false, 'showClosers' => false, 'showInvisible' => false, 'showVirtual' => false, - 'contextHTML' => $options['context_html'] ?? '', - 'selector' => $options['selector'] ?? '', - + 'selector' => '', + 'selectorErrorMessage' => null, 'hoverInfo' => 'breadcrumbs', 'hoverBreadcrumbs' => true, 'hoverInsertion' => false, + 'htmlApiDocumentTitle' => null, + 'htmlApiDoctypeName' => null, + 'htmlApiDoctypePublicId' => null, + 'htmlApiDoctypeSystemId' => null, + 'treeWarnings' => array(), + 'playbackLength' => 0, + 'formattedRawResponse' => '', 'checkingForPRPlaygroundLink' => false, - - 'htmlApiDocumentTitle' => $htmlapi_response['result']['documentTitle'] ?? null, - 'htmlApiDoctypeName' => $htmlapi_response['result']['doctypeName'] ?? null, - 'htmlApiDoctypePublicId' => $htmlapi_response['result']['doctypePublicId'] ?? null, - 'htmlApiDoctypeSytemId' => $htmlapi_response['result']['doctypeSystemId'] ?? null, - 'treeWarnings' => $htmlapi_response['result']['warnings'] ?? array(), - 'normalizedHtml' => $htmlapi_response['normalizedHtml'] ?? '', - - 'playbackLength' => isset( $htmlapi_response['result']['playback'] ) - ? \count( $htmlapi_response['result']['playback'] ) - : 0, + 'previewCoreLink' => null, + 'previewGutenbergLink' => null, ) ); + ob_start(); ?>
-
+

+

+

+

This canonical URL is unusually long. Its bytes remain intact.

+

The fragment preview uses a lossy Unicode projection. Exact source and result bytes are unchanged.

+ +
- - +

Fragment context

+
+ + + +
- -
-
-

Input HTML

- -
+

This WordPress version cannot parse fragments. Context bytes remain in the URL and byte inspector, while processing and preview use document mode.

+
+ +
+
+

+			

Malformed UTF-8 is read-only. Editing converts invalid bytes to U+FFFD and changes the source.

+ +
+ + +
+
+

Input HTML

+
+ + +
+
+
+ +
+
+

+			

Malformed UTF-8 is read-only. Editing converts invalid bytes to U+FFFD and changes the source.

+ +
+
+

Rendered output

-
+ +
HTML API Normalized HTML -

+		
+ + +
+

+		

 	
+
Document info -
Rendering mode: 
@@ -124,119 +195,79 @@ class="context-html"
+

Interpreted by HTML API

- +
-
+

-				
-
    -
    +

      Interpreted from DOM

      - +
        -
        -
        - - - -
        - -
        -
        -
        - -
        +
        + + + + + + +
        - +

        -

        Processed HTML

        -
        -

        - -
        + +

        - - - - - - - - - + + + + +

        -
        -
        - debug response -
        
        -			
        -
        +
        Exact REST response envelopes
        '; +const PLAYGROUND_BASE = new URL( + 'https://playground.wordpress.net/?plugin=html-api-debugger', +); + const RENDERED_IFRAME = /** @type {HTMLIFrameElement} */ ( document.getElementById( 'rendered_iframe' ) ); -/** @type {Element|null} */ -let CONTEXT_ELEMENT = null; - -const cfg = I.getConfig( NS ); -let { nonce } = cfg; - -/** @type {AbortController|null} */ -let inFlightRequestAbortController = null; +const cfg = /** @type {{restEndpoint: string, nonce: string, supports: {create_fragment_advanced: boolean, selectors: boolean}}} */ ( + I.getConfig( NS ) +); -/** @type {AbortController|null} */ -let debounceInputAbortController = null; +const requestBoundary = new ByteRequestBoundary( { + endpoint: cfg.restEndpoint, + nonce: cfg.nonce, + fetch: window.fetch.bind( window ), + AbortController, +} ); -/** @type {MutationObserver|null} */ -let mutationObserver = null; +const controller = new ByteRuntimeController( { + url: new URL( document.location.href ), + supports: cfg.supports, + request: ( body ) => requestBoundary.request( body ), + replaceUrl: ( url ) => history.replaceState( null, '', url ), + confirmConversion: ( message ) => window.confirm( message ), +} ); -/** - * @typedef Link - * @property {string} href - * @property {string} text - * - * - * @typedef DOM - * @property {string|undefined} renderingMode - * @property {string|undefined} documentTitle - * @property {string|undefined} doctypeName - * @property {string|undefined} doctypeSystemId - * @property {string|undefined} doctypePublicId - * @property {string|undefined} contextNode - * - * - * @typedef HTMLAPISpan - * @property {number} start - * @property {number} length - * - * - * @typedef Supports - * @property {boolean} create_fragment_advanced - * @property {boolean} selectors - * - * - * @typedef HtmlApiResponse - * @property {any} error - * @property {Supports} supports - * @property {{tree: any, compatMode: string, documentTitle: string|null, doctypeName: string|null, doctypePublicId: string|null, doctypeSystemId: string|null, playback: ReadonlyArray<[string,any]>, warnings: ReadonlyArray }|null} result - * @property {string|null} normalizedHtml - * @property {string} html - * - * - * @typedef Options - * @property {boolean} showClosers - * @property {boolean} showInvisible - * @property {boolean} showVirtual - * @property {'breadcrumbs'|'insertionMode'} hoverInfo - * - * - * @typedef {'showClosers'|'showInvisible'|'showVirtual'} BooleanConfigurationOption - * - * - * @typedef State - * @property {ReadonlyArray} treeWarnings - * @property {string} selector - * @property {string|null} selectorErrorMessage - * @property {boolean} showClosers - * @property {boolean} showInvisible - * @property {boolean} showVirtual - * @property {'breadcrumbs'|'insertionMode'} hoverInfo - * @property {any|undefined} playbackTree - * @property {string|undefined} playbackHTML - * @property {number|null} playbackPoint - * @property {number|null} playbackLength - * @property {string|null} htmlApiDocumentTitle - * @property {string|null} htmlApiDoctypeName - * @property {string|null} htmlApiDoctypePublicId - * @property {string|null} htmlApiDoctypeSystemId - * @property {string|null} normalizedHtml - * @property {string} formattedHtmlapiResponse - * @property {HtmlApiResponse} htmlapiResponse - * @property {URL} playgroundLink - * @property {string} html - * @property {string} contextHTML - * @property {string|null} contextHTMLForUse - * @property {number|null} previewCorePrNumber - * @property {number|null} previewGutenbergPrNumber - * @property {Link|null} previewCoreLink - * @property {Link|null} previewGutenbergLink - * @property {boolean} checkingForPRPlaygroundLink - * @property {boolean} hoverBreadcrumbs - * @property {boolean} hoverInsertion - * @property {DOM} DOM - * @property {boolean} hasMutatedDom - * @property {HTMLAPISpan|false} span - * @property {string} htmlForDisplay - * @property {Options} options - * - * - * @typedef Store - * @property {()=>void} callAPI - * @property {()=>void} clearSpan - * @property {(e: InputEvent)=>void} handleContextHtmlInput - * @property {()=>void} handleCopyClick - * @property {()=>void} handleCopyPrClick - * @property {()=>void} handleCopyPrInput - * @property {()=>void} handleDefaultBodyContextClick - * @property {()=>void} handleInput - * @property {()=>void} handleShowClosersClick - * @property {()=>void} handleShowInvisibleClick - * @property {()=>void} handleShowVirtualClick - * @property {()=>void} onRenderedIframeLoad - * @property {()=>void} redrawDOMTreeFromIframe - * @property {()=>void} render - * @property {()=>void} watch - * @property {()=>void} watchURL - * @property {State} state - */ - -const createStore = /** @type {typeof I.store} */ ( I.store ); - -const HTML_OPTIONS_PARAM = 'html-opts'; +/** @typedef {'showClosers'|'showInvisible'|'showVirtual'} BooleanConfigurationOption */ +/** @typedef {'html'|'context'} SourceKind */ const BOOLEAN_CONFIGURATION_OPTIONS = /** @type {const} */ ( [ [ 'C', 'c', 'showClosers' ], [ 'I', 'i', 'showInvisible' ], [ 'V', 'v', 'showVirtual' ], ] ); - const booleanConfigurationOverrides = getInitialBooleanConfigurationOverrides(); -/** @type {Store} */ +/** @type {Element|null} */ +let CONTEXT_ELEMENT = null; +/** @type {MutationObserver|null} */ +let mutationObserver = null; +/** @type {InstanceType} */ +let previewCoordinator; + +const initialHtmlValid = isValidUtf8( controller.htmlBytes ); +const initialContextValid = isValidUtf8( controller.contextBytes ); +const storedHoverInfo = localStorage.getItem( `${ NS }-hoverInfo` ); + +const createStore = /** @type {any} */ ( I.store ); const store = createStore( NS, { - // @ts-expect-error Server provided state is not included here. state: { - showClosers: getInitialBooleanConfigurationValue( 'showClosers' ), - showInvisible: getInitialBooleanConfigurationValue( 'showInvisible' ), - showVirtual: getInitialBooleanConfigurationValue( 'showVirtual' ), - + revision: 0, + urlError: controller.urlError, + transportError: null, + previewError: null, + urlUnusuallyLong: false, + fragmentProjectionLossy: false, + contextUnsupported: ! cfg.supports.create_fragment_advanced, + processing: false, + htmlView: initialHtmlValid ? 'text' : 'bytes', + contextView: initialContextValid ? 'text' : 'bytes', + processedView: initialHtmlValid ? 'text' : 'bytes', + normalizedView: 'text', playbackPoint: null, previewCorePrNumber: null, previewGutenbergPrNumber: null, + selector: controller.selector, + selectorErrorMessage: null, + showClosers: getInitialBooleanConfigurationValue( 'showClosers' ), + showInvisible: getInitialBooleanConfigurationValue( 'showInvisible' ), + showVirtual: getInitialBooleanConfigurationValue( 'showVirtual' ), + hoverInfo: + storedHoverInfo === 'insertionMode' ? 'insertionMode' : 'breadcrumbs', + hasMutatedDom: false, + DOM: { + renderingMode: '', + documentTitle: '', + doctypeName: '', + doctypeSystemId: '', + doctypePublicId: '', + contextNode: '', + }, + htmlapiResponse: { + supports: cfg.supports, + html: '', + error: null, + result: null, + normalizedHtml: null, + }, + get htmlText() { + void store.state.revision; + return sourceText( 'html' ); + }, + get contextText() { + void store.state.revision; + return sourceText( 'context' ); + }, + get htmlByteRows() { + void store.state.revision; + return byteRowsText( controller.htmlBytes ); + }, + get contextByteRows() { + void store.state.revision; + return byteRowsText( controller.contextBytes ); + }, + get htmlTextHidden() { + return store.state.htmlView !== 'text'; + }, + get htmlBytesHidden() { + return store.state.htmlView !== 'bytes'; + }, + get contextTextHidden() { + return store.state.contextView !== 'text'; + }, + get contextBytesHidden() { + return store.state.contextView !== 'bytes'; + }, + get htmlConversionHidden() { + void store.state.revision; + return isValidUtf8( controller.htmlBytes ); + }, + get contextConversionHidden() { + void store.state.revision; + return isValidUtf8( controller.contextBytes ); + }, + get processedText() { + void store.state.revision; + return displayBytes( currentProcessedBytes() ); + }, + get processedByteRows() { + void store.state.revision; + return byteRowsText( currentProcessedBytes() ); + }, + get processedTextHidden() { + return store.state.processedView !== 'text'; + }, + get processedBytesHidden() { + return store.state.processedView !== 'bytes'; + }, + get normalizedText() { + void store.state.revision; + const bytes = controller.normalizedBytes; + return bytes === null ? '' : displayBytes( bytes ); + }, + get normalizedByteRows() { + void store.state.revision; + const bytes = controller.normalizedBytes; + return bytes === null ? '' : byteRowsText( bytes ); + }, + get normalizedTextHidden() { + return store.state.normalizedView !== 'text'; + }, + get normalizedBytesHidden() { + return store.state.normalizedView !== 'bytes'; + }, + get normalizedUnavailable() { + void store.state.revision; + return controller.normalizedBytes === null; + }, get options() { return { showClosers: store.state.showClosers, showInvisible: store.state.showInvisible, showVirtual: store.state.showVirtual, hoverInfo: store.state.hoverInfo, - selector: store.state.htmlapiResponse.supports.selectors - ? store.state.selector - : '', + selector: cfg.supports.selectors ? store.state.selector : '', }; }, - get treeWarnings() { return store.state.htmlapiResponse.result?.warnings ?? []; }, - get playbackTree() { if ( store.state.playbackPoint === null ) { return undefined; @@ -176,676 +211,602 @@ const store = createStore( NS, { store.state.playbackPoint ]?.[ 1 ]; }, - - get playbackHTML() { - if ( store.state.playbackPoint === null ) { - return undefined; - } - return store.state.htmlapiResponse.result?.playback?.[ - store.state.playbackPoint - ]?.[ 0 ]; - }, - get playbackLength() { - return store.state.htmlapiResponse.result?.playback?.length; + return store.state.htmlapiResponse.result?.playback?.length ?? 0; }, - - get contextHTMLForUse() { - return store.state.htmlapiResponse.supports.create_fragment_advanced - ? store.state.contextHTML.trim() || null - : null; - }, - - /** @type {Link|null} */ - get previewCoreLink() { - if ( ! store.state.previewCorePrNumber ) { - return null; - } - return { - href: `https://github.com/WordPress/wordpress-develop/pull/${ store.state.previewCorePrNumber }`, - text: `wordpress-develop #${ store.state.previewCorePrNumber }`, - }; + get hoverBreadcrumbs() { + return store.state.hoverInfo === 'breadcrumbs'; }, - - /** @type {Link|null} */ - get previewGutenbergLink() { - if ( ! store.state.previewGutenbergPrNumber ) { - return null; - } - return { - href: `https://github.com/WordPress/gutenberg/pull/${ store.state.previewGutenbergPrNumber }`, - text: `Gutenberg #${ store.state.previewGutenbergPrNumber }`, - }; + get hoverInsertion() { + return store.state.hoverInfo === 'insertionMode'; }, - - hoverInfo: /** @type {typeof store.state.hoverInfo} */ ( - localStorage.getItem( `${ NS }-hoverInfo` ) - ), - get htmlApiDocumentTitle() { - return store.state.showInvisible - ? store.state.htmlapiResponse.result?.documentTitle && - replaceInvisible( store.state.htmlapiResponse.result.documentTitle ) - : store.state.htmlapiResponse.result?.documentTitle; + return displayOptionalString( + store.state.htmlapiResponse.result?.documentTitle, + ); }, - get htmlApiDoctypeName() { - return store.state.showInvisible - ? store.state.htmlapiResponse.result?.doctypeName && - replaceInvisible( store.state.htmlapiResponse.result.doctypeName ) - : store.state.htmlapiResponse.result?.doctypeName; + return displayOptionalString( + store.state.htmlapiResponse.result?.doctypeName, + ); }, - get htmlApiDoctypePublicId() { - return store.state.showInvisible - ? store.state.htmlapiResponse.result?.doctypePublicId && - replaceInvisible( - store.state.htmlapiResponse.result.doctypePublicId, - ) - : store.state.htmlapiResponse.result?.doctypePublicId; + return displayOptionalString( + store.state.htmlapiResponse.result?.doctypePublicId, + ); }, get htmlApiDoctypeSystemId() { - return store.state.showInvisible - ? store.state.htmlapiResponse.result?.doctypeSystemId && - replaceInvisible( - store.state.htmlapiResponse.result.doctypeSystemId, - ) - : store.state.htmlapiResponse.result?.doctypeSystemId; + return displayOptionalString( + store.state.htmlapiResponse.result?.doctypeSystemId, + ); + }, + get formattedRawResponse() { + void store.state.revision; + return controller.rawResponse === null + ? '' + : JSON.stringify( controller.rawResponse, undefined, 2 ); }, + get previewCoreLink() { + const number = store.state.previewCorePrNumber; + return number === null + ? null + : { + href: `https://github.com/WordPress/wordpress-develop/pull/${ number }`, + text: `wordpress-develop #${ number }`, + }; + }, + get previewGutenbergLink() { + const number = store.state.previewGutenbergPrNumber; + return number === null + ? null + : { + href: `https://github.com/WordPress/gutenberg/pull/${ number }`, + text: `Gutenberg #${ number }`, + }; + }, + }, - get normalizedHtml() { - if ( ! store.state.htmlapiResponse.normalizedHtml ) { - return ''; + actions: { + showHtmlText() { + if ( isValidUtf8( controller.htmlBytes ) ) { + store.state.htmlView = 'text'; } - return store.state.showInvisible - ? replaceInvisible( store.state.htmlapiResponse.normalizedHtml ) - : store.state.htmlapiResponse.normalizedHtml; }, - - get formattedHtmlapiResponse() { - return JSON.stringify( store.state.htmlapiResponse, undefined, 2 ); + showHtmlBytes() { + store.state.htmlView = 'bytes'; }, - - get hoverBreadcrumbs() { - return store.state.hoverInfo === 'breadcrumbs'; + showContextText() { + if ( isValidUtf8( controller.contextBytes ) ) { + store.state.contextView = 'text'; + } }, - - get hoverInsertion() { - return store.state.hoverInfo === 'insertionMode'; + showContextBytes() { + store.state.contextView = 'bytes'; }, - - get playgroundLink() { - // We'll embed a path in a URL. - const searchParams = new URLSearchParams( { page: NS } ); - if ( store.state.html ) { - searchParams.set( 'html', store.state.html ); + showProcessedText() { + if ( isValidUtf8( currentProcessedBytes() ) ) { + store.state.processedView = 'text'; } - if ( store.state.contextHTMLForUse ) { - searchParams.set( 'contextHTML', store.state.contextHTMLForUse ); + }, + showProcessedBytes() { + store.state.processedView = 'bytes'; + }, + showNormalizedText() { + const bytes = controller.normalizedBytes; + if ( bytes !== null && isValidUtf8( bytes ) ) { + store.state.normalizedView = 'text'; + } + }, + showNormalizedBytes() { + store.state.normalizedView = 'bytes'; + }, + enableHtmlTextEditing: function* () { + yield convertSourceToText( 'html' ); + }, + enableContextTextEditing: function* () { + yield convertSourceToText( 'context' ); + }, + /** @param {InputEvent} event */ + handleInput: function* ( event ) { + const text = /** @type {HTMLTextAreaElement} */ ( event.target ).value; + const operation = controller.editSource( 'html', text ); + store.state.playbackPoint = null; + store.state.processedView = 'text'; + touchState(); + renderPreview(); + yield settleControllerOperation( operation ); + }, + /** @param {InputEvent} event */ + handleContextHtmlInput: function* ( event ) { + const text = /** @type {HTMLTextAreaElement} */ ( event.target ).value; + const operation = controller.editSource( 'context', text ); + store.state.playbackPoint = null; + touchState(); + renderPreview(); + yield settleControllerOperation( operation ); + }, + handleDefaultBodyContextClick: function* () { + const operation = controller.editSource( + 'context', + DEFAULT_HTML5_BODY_CONTEXT, + ); + store.state.contextView = 'text'; + store.state.playbackPoint = null; + touchState(); + renderPreview(); + yield settleControllerOperation( operation ); + }, + /** @param {InputEvent} event */ + handleSelectorChange: function* ( event ) { + const selector = /** @type {HTMLTextAreaElement} */ ( event.target ).value; + if ( selector !== '' ) { + try { + document.createDocumentFragment().querySelector( selector ); + } catch ( error ) { + if ( error instanceof DOMException && error.name === 'SyntaxError' ) { + store.state.selectorErrorMessage = error.message; + return; + } + throw error; + } } - if ( store.state.selector ) { - searchParams.set( 'selector', store.state.selector ); + store.state.selector = selector; + store.state.selectorErrorMessage = null; + touchState(); + yield settleControllerOperation( controller.setSelector( selector ) ); + }, + handleShowInvisibleClick: getToggleHandler( 'showInvisible' ), + handleShowClosersClick: getToggleHandler( 'showClosers' ), + handleShowVirtualClick: getToggleHandler( 'showVirtual' ), + /** @param {Event} event */ + hoverInfoChange( event ) { + const value = /** @type {HTMLSelectElement} */ ( event.target ).value; + store.state.hoverInfo = + value === 'insertionMode' ? 'insertionMode' : 'breadcrumbs'; + localStorage.setItem( `${ NS }-hoverInfo`, store.state.hoverInfo ); + }, + clearSpan, + handleSpanOver, + /** @param {InputEvent} event */ + handlePlaybackChange( event ) { + const value = /** @type {HTMLInputElement} */ ( event.target ) + .valueAsNumber; + store.state.playbackPoint = value - 1; + setResultViewDefault(); + touchState(); + renderHtmlApiOutput(); + renderPreview(); + }, + /** @param {MouseEvent} event */ + handleCopyTreeClick: function* ( event ) { + const useDomTree = + /** @type {HTMLButtonElement} */ ( event.target ).name === 'tree__dom'; + const tree = useDomTree + ? CONTEXT_ELEMENT ?? RENDERED_IFRAME.contentWindow?.document + : store.state.playbackTree ?? + store.state.htmlapiResponse.result?.tree; + try { + yield navigator.clipboard.writeText( + printHtmlApiTreeText( tree, store.state.options ), + ); + } catch { + window.alert( 'Copy failed, make sure the browser window is focused.' ); } - searchParams.set( HTML_OPTIONS_PARAM, getResolvedHtmlOptions() ); - const base = '/wp-admin/admin.php'; - const u = new URL( - 'https://playground.wordpress.net/?plugin=html-api-debugger', + }, + handleCopyClick: function* () { + const select = /** @type {HTMLSelectElement} */ ( + document.getElementById( 'htmlapi-wp-version' ) ); - u.searchParams.set( 'url', `${ base }?${ searchParams.toString() }` ); - return u; + const url = controller.getPlaygroundUrl( + PLAYGROUND_BASE, + getResolvedHtmlOptions(), + select.value, + ); + yield copyUrl( url ); }, - - get htmlForDisplay() { - /** @type {string | undefined} */ - const html = store.state.playbackHTML ?? store.state.htmlapiResponse.html; - if ( ! html ) { - return ''; + /** @param {InputEvent} event */ + handleCopyCorePrInput( event ) { + store.state.previewCorePrNumber = positiveInputNumber( event ); + }, + /** @param {InputEvent} event */ + handleCopyGutenbergPrInput( event ) { + store.state.previewGutenbergPrNumber = positiveInputNumber( event ); + }, + handleCopyPrClick: function* () { + const url = controller.getPlaygroundUrl( + PLAYGROUND_BASE, + getResolvedHtmlOptions(), + ); + if ( store.state.previewCorePrNumber !== null ) { + url.searchParams.set( + 'core-pr', + String( store.state.previewCorePrNumber ), + ); } - return store.state.showInvisible ? replaceInvisible( html ) : html; + if ( store.state.previewGutenbergPrNumber !== null ) { + url.searchParams.set( + 'gutenberg-pr', + String( store.state.previewGutenbergPrNumber ), + ); + } + yield copyUrl( url ); }, }, - clearSpan() { - const el = /** @type {HTMLElement} */ ( - document.getElementById( 'processed-html' ) - ); - el.classList.remove( 'has-highlighted-span' ); - el.textContent = store.state.htmlForDisplay; - }, - - /** @param {MouseEvent} e */ - handleSpanOver( e ) { - const target = /** @type {HTMLElement} */ ( e.target ); - - const html = store.state.playbackHTML ?? store.state.htmlapiResponse.html; - if ( ! html ) { - return; - } - - /** @type {HTMLElement|null} */ - const spanElement = target.dataset[ 'spanStart' ] - ? target - : target.closest( '[data-span-start]' ); - - if ( ! spanElement ) { - return; - } - - const { spanStart: spanStartVal, spanLength: spanLengthVal } = - spanElement.dataset; - if ( ! spanStartVal || ! spanLengthVal ) { - return; - } - const spanStart = Number( spanStartVal ); - const spanLength = Number( spanLengthVal ); - - const buf = new TextEncoder().encode( html ); - const decoder = new TextDecoder(); - - const spanEnd = spanStart + spanLength; - /** @type {readonly [Text,Text,Text]} */ - // @ts-expect-error trust me! - const [ before, current, after ] = /** @type {const} */ ( [ - decoder.decode( buf.slice( 0, spanStart ) ), - decoder.decode( buf.slice( spanStart, spanEnd ) ), - decoder.decode( buf.slice( spanEnd ) ), - ] ).map( ( text ) => { - const t = store.state.showInvisible ? replaceInvisible( text ) : text; - return document.createTextNode( t ); - } ); - - const highlightCurrent = document.createElement( 'span' ); - highlightCurrent.className = 'highlight-span'; - highlightCurrent.appendChild( current ); - - const el = /** @type {HTMLElement} */ ( - document.getElementById( 'processed-html' ) - ); - el.classList.add( 'has-highlighted-span' ); - el.replaceChildren( before, highlightCurrent, after ); + callbacks: { + run: function* () { + mutationObserver = new MutationObserver( () => { + store.state.hasMutatedDom = true; + const document = RENDERED_IFRAME.contentWindow?.document; + if ( document !== undefined ) { + redrawDomTree( document, CONTEXT_ELEMENT ); + } + } ); + touchState(); + renderPreview(); + if ( controller.urlError === null ) { + yield settleControllerOperation( controller.start() ); + } + }, + watch() { + renderHtmlApiOutput(); + redrawCurrentDomTree(); + }, }, +} ); - run() { - RENDERED_IFRAME.addEventListener( 'load', store.onRenderedIframeLoad, { - passive: true, - } ); - - // The HTML parser will replace null bytes from the HTML. - // Force print them if we have null bytes. - if ( store.state.html.includes( '\0' ) ) { - /** @type {HTMLTextAreaElement} */ ( - document.getElementById( 'input-html' ) - ).value = store.state.html; - } - if ( store.state.contextHTML.includes( '\0' ) ) { - /** @type {HTMLTextAreaElement} */ ( - document.getElementById( 'context-html' ) - ).value = store.state.contextHTML; - } - if ( store.state.selector.includes( '\0' ) ) { - /** @type {HTMLTextAreaElement} */ ( - document.getElementById( 'selector-input' ) - ).value = store.state.selector; - } - - store.render(); - - // browsers "eat" some characters from search params… - // newlines seem especially problematic in chrome. - // Let's clean up the URL - store.watchURL(); - - mutationObserver = new MutationObserver( () => { - store.state.hasMutatedDom = true; - store.redrawDOMTreeFromIframe(); - } ); +const documentPreview = new ByteDocumentPreview( RENDERED_IFRAME ); +previewCoordinator = new BytePreviewCoordinator( { + preview: documentPreview, + iframe: RENDERED_IFRAME, + resolveFragmentTarget: ( document ) => + resolveFragmentTarget( + document, + projectUtf8( controller.contextBytes ), + ), + onCurrentDocument( details ) { + CONTEXT_ELEMENT = details.contextElement; + store.state.fragmentProjectionLossy = details.fragmentLossy; + store.state.hasMutatedDom = false; + updateDomInfo( details.document, details.contextElement ); + redrawDomTree( details.document, details.contextElement ); + observeDocument( details.document ); + touchState(); }, - - onRenderedIframeLoad() { - store.redrawDOMTreeFromIframe(); - - // @ts-expect-error It better be defined! - const doc = RENDERED_IFRAME.contentWindow.document; - - mutationObserver?.observe( doc, { - subtree: true, - childList: true, - attributes: true, - characterData: true, - } ); - Array.prototype.forEach.call( - doc.getElementsByTagNameNS( 'http://www.w3.org/1999/xhtml', 'template' ), - /** @param {HTMLTemplateElement} template */ - ( template ) => { - mutationObserver?.observe( template.content, { - subtree: true, - childList: true, - attributes: true, - characterData: true, - } ); - }, - ); + restoreCurrentDocument( details ) { + CONTEXT_ELEMENT = null; + store.state.fragmentProjectionLossy = false; + store.state.hasMutatedDom = false; + updateDomInfo( details.document, null ); + redrawDomTree( details.document, null ); + observeDocument( details.document ); + touchState(); }, - - redrawDOMTreeFromIframe() { - // @ts-expect-error It better be defined! - const doc = RENDERED_IFRAME.contentWindow.document; - - store.state.DOM.documentTitle = doc.title; - store.state.DOM.renderingMode = doc.compatMode; - store.state.DOM.doctypeName = doc.doctype?.name; - store.state.DOM.doctypeSystemId = doc.doctype?.systemId; - store.state.DOM.doctypePublicId = doc.doctype?.publicId; - - /** @type {Element|null} */ - let contextElement = null; - if ( store.state.contextHTMLForUse ) { - // An HTML document will always make HTML > HEAD + BODY. - // But that may not be the intended context. - // Guess the intended context in case the HEAD and BODY elements are empty. - if ( doc.body.hasChildNodes() || doc.head.hasChildNodes() ) { - const walker = doc.createTreeWalker( doc, NodeFilter.SHOW_ELEMENT ); - while ( walker.nextNode() ) { - // @ts-expect-error It's an Element! - contextElement = walker.currentNode; - } - } else { - if ( / { - const t = setTimeout( resolve, DEBOUNCE_TIMEOUT ); - debounceInputAbortController?.signal.addEventListener( 'abort', () => { - clearInterval( t ); - reject( debounceInputAbortController?.signal.reason ); - } ); - } ); - } catch ( e ) { - if ( e === 'debounced' ) { - return; - } - throw e; - } +window.addEventListener( 'pagehide', () => requestBoundary.dispose(), { + once: true, +} ); - yield store.callAPI(); - }, +/** @param {SourceKind} kind */ +function sourceBytes( kind ) { + return kind === 'html' ? controller.htmlBytes : controller.contextBytes; +} - handleCopyClick: function* () { - const url = new URL( store.state.playgroundLink ); +/** @param {SourceKind} kind */ +function sourceText( kind ) { + const bytes = sourceBytes( kind ); + return isValidUtf8( bytes ) ? decodeUtf8( bytes ) : projectUtf8( bytes ); +} - // @ts-expect-error This better exist. - const wpVersion = document.getElementById( 'htmlapi-wp-version' ).value; - url.searchParams.set( 'wp', wpVersion ); +/** @param {Uint8Array} bytes */ +function displayBytes( bytes ) { + const text = projectUtf8( bytes ); + return store.state.showInvisible ? replaceInvisible( text ) : text; +} - try { - yield navigator.clipboard.writeText( url.href ); - } catch { - alert( 'Copy failed, make sure the browser window is focused.' ); - } - }, +/** @param {unknown} value */ +function displayOptionalString( value ) { + if ( typeof value !== 'string' ) { + return value ?? null; + } + return store.state.showInvisible ? replaceInvisible( value ) : value; +} - handleShowInvisibleClick: getToggleHandler( 'showInvisible' ), - handleShowClosersClick: getToggleHandler( 'showClosers' ), - handleShowVirtualClick: getToggleHandler( 'showVirtual' ), +/** @param {Uint8Array} bytes */ +function byteRowsText( bytes ) { + const rows = formatByteRows( bytes ); + if ( rows.length === 0 ) { + return '(empty)'; + } + return rows + .map( + ( row ) => + `${ row.offset.toString( 16 ).toUpperCase().padStart( 8, '0' ) } ${ row.hex.padEnd( 47 ) } |${ row.gutter }|`, + ) + .join( '\n' ); +} - /** @param {Event} e */ - hoverInfoChange: ( e ) => { - // @ts-expect-error - store.state.hoverInfo = e.target.value; - localStorage.setItem( `${ NS }-hoverInfo`, store.state.hoverInfo ); - }, +function currentProcessedBytes() { + return controller.getProcessedBytes( store.state.playbackPoint ); +} - watch() { - store.render(); - }, +function touchState() { + store.state.revision += 1; + store.state.urlUnusuallyLong = + controller.urlError === null && controller.isUrlUnusuallyLong( 8192 ); +} - watchURL() { - const u = new URL( document.location.href ); - let shouldReplace = false; - for ( const [ param, getValue ] of /** @type {const} */ ( [ - [ 'html', () => store.state.html ], - [ 'contextHTML', () => store.state.contextHTMLForUse ], - [ 'selector', () => store.state.selector ], - [ HTML_OPTIONS_PARAM, getExplicitHtmlOptions ], - ] ) ) { - const value = getValue(); - if ( value ) { - if ( u.searchParams.get( param ) !== value ) { - u.searchParams.set( param, value ); - shouldReplace = true; - } - } else if ( u.searchParams.has( param ) ) { - u.searchParams.delete( param ); - shouldReplace = true; - } +/** @param {Promise} operation */ +async function settleControllerOperation( operation ) { + beginPendingResponse(); + try { + const result = await operation; + if ( result !== null ) { + applyControllerResponse(); } - if ( shouldReplace ) { - history.replaceState( null, '', u ); + } catch ( error ) { + if ( + error instanceof SupersededRuntimeOperationError || + error instanceof DisposedRuntimeBoundaryError + ) { + return; } - }, + store.state.transportError = describeError( error ); + } finally { + store.state.processing = controller.isProcessing; + touchState(); + renderHtmlApiOutput(); + renderPreview(); + } +} - callAPI: function* () { - inFlightRequestAbortController?.abort( 'request superseded' ); - inFlightRequestAbortController = new AbortController(); - let data; - try { - /** @type {Response} */ - const response = yield fetch( cfg.restEndpoint, { - method: 'POST', - body: JSON.stringify( { - html: store.state.html, - contextHTML: store.state.contextHTMLForUse, - selector: store.state.selector, - } ), - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': nonce, - }, - signal: inFlightRequestAbortController.signal, - } ); +function applyControllerResponse() { + const projected = controller.projectedResponse; + if ( projected === null ) { + return; + } + store.state.htmlapiResponse = projected; + store.state.playbackPoint = null; + setResultViewDefault(); + const normalized = controller.normalizedBytes; + store.state.normalizedView = + normalized === null || isValidUtf8( normalized ) ? 'text' : 'bytes'; +} - if ( response.headers.has( 'X-WP-Nonce' ) ) { - nonce = response.headers.get( 'X-WP-Nonce' ); - } - if ( ! response.ok ) { - throw response; - } +function beginPendingResponse() { + store.state.playbackPoint = null; + store.state.htmlapiResponse = { + supports: cfg.supports, + html: '', + error: null, + result: null, + normalizedHtml: null, + }; + store.state.transportError = null; + store.state.processing = true; + touchState(); + renderHtmlApiOutput(); +} - // @ts-expect-error It's fine. - data = yield response.json(); - } catch ( /** @type {any} */ err ) { - if ( err === 'request superseded' || err instanceof DOMException ) { - return; - } +function setResultViewDefault() { + store.state.processedView = isValidUtf8( currentProcessedBytes() ) + ? 'text' + : 'bytes'; +} - store.state.htmlapiResponse.result = null; - - if ( err instanceof Response ) { - yield err - .json() - .then( ( j ) => { - let msg = ''; - if ( j?.code ) { - msg = j.code; - } - if ( j?.data?.error ) { - if ( msg ) { - msg += ': '; - } - msg += `${ j.data.error.message } in ${ j.data.error.file }:${ j.data.error.line }`; - } - if ( msg ) { - store.state.htmlapiResponse.error = msg; - } else { - // Fallback to catch - throw 'no msg'; - } - } ) - .catch( () => - err.text().then( ( t ) => { - store.state.htmlapiResponse.error = t; - } ), - ) - .catch( () => { - store.state.htmlapiResponse.error = 'unknown error'; - } ); - return; - } - throw err; +/** @param {SourceKind} kind */ +async function convertSourceToText( kind ) { + store.state.transportError = null; + try { + const operation = controller.requestTextEditing( kind ); + if ( controller.isProcessing ) { + beginPendingResponse(); } - - store.state.htmlapiResponse = data; + const text = await operation; + if ( text === null ) { + return; + } + store.state[ `${ kind }View` ] = 'text'; store.state.playbackPoint = null; - store.clearSpan(); + applyControllerResponse(); + } catch ( error ) { + store.state.transportError = describeError( error ); + } finally { + store.state.processing = controller.isProcessing; + touchState(); + renderHtmlApiOutput(); + renderPreview(); + } +} - if ( data.error ) { - /** @type {HTMLUListElement} */ ( - document.getElementById( 'html_api_result_holder' ) - ).innerHTML = ''; +function renderPreview() { + if ( controller.urlError !== null ) { + return; + } + try { + const started = previewCoordinator.render( + controller.getPreviewPlan( store.state.playbackPoint ), + ); + if ( started ) { + store.state.previewError = null; + store.state.fragmentProjectionLossy = false; } - }, - - render() { - // @ts-expect-error This should not be null. - const iframeDocument = RENDERED_IFRAME.contentWindow.document; - mutationObserver?.disconnect(); - - store.state.hasMutatedDom = false; - - const html = - store.state.contextHTMLForUse ?? - store.state.playbackHTML ?? - store.state.html; - - CONTEXT_ELEMENT = null; - iframeDocument.open(); - iframeDocument.write( html ); - iframeDocument.close(); - - const tree = - store.state.playbackTree ?? store.state.htmlapiResponse.result?.tree; + } catch ( error ) { + store.state.previewError = describeError( error ); + } +} - const processedHtmlEl = /** @type {HTMLElement} */ ( - document.getElementById( 'processed-html' ) - ); - processedHtmlEl.classList.remove( 'has-highlighted-span' ); - processedHtmlEl.textContent = store.state.htmlForDisplay; +function renderHtmlApiOutput() { + const processed = document.getElementById( 'processed-html' ); + if ( processed !== null ) { + processed.classList.remove( 'has-highlighted-span' ); + processed.textContent = store.state.processedText; + } + const tree = + store.state.playbackTree ?? store.state.htmlapiResponse.result?.tree; + const holder = document.getElementById( 'html_api_result_holder' ); + if ( holder !== null ) { if ( tree ) { printHtmlApiTree( tree, - // @ts-expect-error - document.getElementById( 'html_api_result_holder' ), + /** @type {HTMLUListElement} */ ( holder ), store.state.options, ); + } else { + holder.replaceChildren(); } - }, + } +} - /** @param {InputEvent} e */ - handleContextHtmlInput: function* ( e ) { - store.state.contextHTML = /** @type {HTMLTextAreaElement} */ ( - e.target - ).value; - yield store.callAPI(); - }, +function clearSpan() { + const element = document.getElementById( 'processed-html' ); + if ( element === null ) { + return; + } + element.classList.remove( 'has-highlighted-span' ); + element.textContent = store.state.processedText; +} - handleDefaultBodyContextClick: function* () { - const contextHtmlElement = /** @type {HTMLTextAreaElement} */ ( - document.getElementById( 'context-html' ) +/** @param {MouseEvent} event */ +function handleSpanOver( event ) { + const target = /** @type {HTMLElement} */ ( event.target ); + const spanElement = target.hasAttribute( 'data-span-start' ) + ? target + : target.closest( '[data-span-start]' ); + if ( ! ( spanElement instanceof HTMLElement ) ) { + return; + } + const start = Number( spanElement.dataset[ 'spanStart' ] ); + const length = Number( spanElement.dataset[ 'spanLength' ] ); + let split; + try { + split = controller.splitProcessedSpan( + start, + length, + store.state.playbackPoint, ); - contextHtmlElement.value = store.state.contextHTML = - DEFAULT_HTML5_BODY_CONTEXT; - yield store.callAPI(); - }, - - /** @param {InputEvent} e */ - handleCopyCorePrInput( e ) { - const val = /** @type {HTMLInputElement} */ ( e.target ).valueAsNumber; - if ( Number.isFinite( val ) && val > 0 ) { - store.state.previewCorePrNumber = val; - return; - } - store.state.previewCorePrNumber = null; - }, - - /** @param {InputEvent} e */ - handleCopyGutenbergPrInput( e ) { - const val = /** @type {HTMLInputElement} */ ( e.target ).valueAsNumber; - if ( Number.isFinite( val ) && val > 0 ) { - store.state.previewGutenbergPrNumber = val; - return; - } - store.state.previewGutenbergPrNumber = null; - }, - - handleCopyPrClick: function* () { - const corePrNumber = store.state.previewCorePrNumber; - const gbPrNumber = store.state.previewGutenbergPrNumber; + } catch ( error ) { + store.state.previewError = describeError( error ); + return; + } - const playgroundLink = new URL( store.state.playgroundLink ); - if ( corePrNumber ) { - playgroundLink.searchParams.set( 'core-pr', String( corePrNumber ) ); - } - if ( gbPrNumber ) { - playgroundLink.searchParams.set( 'gutenberg-pr', String( gbPrNumber ) ); - } + const nodes = [ split.before, split.current, split.after ].map( ( bytes ) => + document.createTextNode( displayBytes( bytes ) ), + ); + const before = /** @type {Text} */ ( nodes[ 0 ] ); + const current = /** @type {Text} */ ( nodes[ 1 ] ); + const after = /** @type {Text} */ ( nodes[ 2 ] ); + const highlight = document.createElement( 'span' ); + highlight.className = 'highlight-span'; + highlight.append( current ); + const element = document.getElementById( 'processed-html' ); + if ( element !== null ) { + element.classList.add( 'has-highlighted-span' ); + element.replaceChildren( before, highlight, after ); + } +} - try { - yield navigator.clipboard.writeText( playgroundLink.href ); - } catch { - alert( 'Copy failed, make sure the browser window is focused.' ); - } - }, +/** @param {Document} document @param {Element|null} contextElement */ +function updateDomInfo( document, contextElement ) { + store.state.DOM.documentTitle = document.title; + store.state.DOM.renderingMode = document.compatMode; + store.state.DOM.doctypeName = document.doctype?.name ?? ''; + store.state.DOM.doctypeSystemId = document.doctype?.systemId ?? ''; + store.state.DOM.doctypePublicId = document.doctype?.publicId ?? ''; + store.state.DOM.contextNode = contextElement?.nodeName ?? ''; +} - /** - * @param {Event} e - */ - handleCopyTreeClick: function* ( e ) { - const useDomTree = - /** @type {HTMLButtonElement} */ ( e.target ).name === 'tree__dom'; - - let tree; - if ( useDomTree ) { - tree = - CONTEXT_ELEMENT || - // @ts-expect-error It's an Element! - RENDERED_IFRAME.contentWindow.document; - } else { - tree = - store.state.playbackTree ?? store.state.htmlapiResponse.result?.tree; - } +/** @param {Document} document @param {Element|null} contextElement */ +function redrawDomTree( document, contextElement ) { + const holder = globalThis.document.getElementById( 'dom_tree' ); + if ( holder === null ) { + return; + } + printHtmlApiTree( + contextElement ?? document, + /** @type {HTMLUListElement} */ ( holder ), + store.state.options, + ); +} - const textualTree = printHtmlApiTreeText( tree, store.state.options ); +function redrawCurrentDomTree() { + const document = RENDERED_IFRAME.contentWindow?.document; + if ( document !== undefined ) { + redrawDomTree( document, CONTEXT_ELEMENT ); + } +} - try { - yield navigator.clipboard.writeText( textualTree ); - } catch { - alert( 'Copy failed, make sure the browser window is focused.' ); - } - }, +/** @param {Document} document */ +function observeDocument( document ) { + if ( mutationObserver === null ) { + return; + } + mutationObserver.observe( document, { + subtree: true, + childList: true, + attributes: true, + characterData: true, + } ); + for ( const template of document.getElementsByTagNameNS( + 'http://www.w3.org/1999/xhtml', + 'template', + ) ) { + mutationObserver.observe( /** @type {HTMLTemplateElement} */ ( template ).content, { + subtree: true, + childList: true, + attributes: true, + characterData: true, + } ); + } +} - /** @param {InputEvent} e */ - handlePlaybackChange( e ) { - const val = /** @type {HTMLInputElement} */ ( e.target ).valueAsNumber; - store.state.playbackPoint = val - 1; - }, +/** @param {unknown} error */ +function describeError( error ) { + if ( error instanceof Response ) { + return `REST request failed with HTTP ${ error.status }.`; + } + return error instanceof Error ? error.message : String( error ); +} - /** @param {InputEvent} e */ - handleSelectorChange: function* ( e ) { - const val = - /** @type {HTMLInputElement} */ ( e.target ).value.trim() || null; - if ( val ) { - try { - // Test whether the selector is valid before setting it so it isn't applied. - document.createDocumentFragment().querySelector( val ); - store.state.selector = val; - store.state.selectorErrorMessage = null; - yield store.callAPI(); - return; - } catch ( /** @type {unknown} */ e ) { - if ( e instanceof DOMException && e.name === 'SyntaxError' ) { - let msg = e.message; - - /* - * The error message includes methods about our test. - * Chrome: - * > Failed to execute 'querySelector' on 'DocumentFragment': 'foo >' is not a valid selector. - * Firefox: - * > DocumentFragment.querySelector: 'foo >' is not a valid selector - * Safari: - * > 'foo >' is not a valid selector. - * - * Try to strip the irrelevant parts. - */ - let idx = msg.indexOf( val ); - if ( idx > 0 ) { - if ( msg[ idx - 1 ] === '"' || msg[ idx - 1 ] === "'" ) { - idx -= 1; - } - msg = msg.slice( idx ); - } +/** @param {URL} url */ +async function copyUrl( url ) { + try { + await navigator.clipboard.writeText( url.href ); + } catch { + window.alert( 'Copy failed, make sure the browser window is focused.' ); + } +} - store.state.selectorErrorMessage = msg; - } else { - throw e; - } - } - } - store.state.selector = ''; - yield store.callAPI(); - }, -} ); +/** @param {Event} event */ +function positiveInputNumber( event ) { + const value = /** @type {HTMLInputElement} */ ( event.target ).valueAsNumber; + return Number.isFinite( value ) && value > 0 ? value : null; +} -/** - * @param {BooleanConfigurationOption} option - * @returns {boolean} - */ +/** @param {BooleanConfigurationOption} option */ function getInitialBooleanConfigurationValue( option ) { const override = booleanConfigurationOverrides[ option ]; - if ( override !== null ) { - return override; - } - return getStoredBooleanConfigurationValue( option ); + return override ?? Boolean( localStorage.getItem( `${ NS }-${ option }` ) ); } -/** - * @returns {Record} - */ +/** @returns {Record} */ function getInitialBooleanConfigurationOverrides() { - const overrides = - /** @type {Record} */ ( { - showClosers: null, - showInvisible: null, - showVirtual: null, - } ); - const searchParams = new URL( document.location.href ).searchParams; - if ( ! searchParams.has( HTML_OPTIONS_PARAM ) ) { - return overrides; - } - - const htmlOptions = searchParams.get( HTML_OPTIONS_PARAM ) ?? ''; - for ( const value of htmlOptions ) { - for ( const [ - enabledCode, - disabledCode, - option, - ] of BOOLEAN_CONFIGURATION_OPTIONS ) { - if ( value === enabledCode ) { + const overrides = /** @type {Record} */ ( { + showClosers: null, + showInvisible: null, + showVirtual: null, + } ); + for ( const value of controller.opts ) { + for ( const [ enabled, disabled, option ] of BOOLEAN_CONFIGURATION_OPTIONS ) { + if ( value === enabled ) { overrides[ option ] = true; - } else if ( value === disabledCode ) { + } else if ( value === disabled ) { overrides[ option ] = false; } } @@ -853,60 +814,41 @@ function getInitialBooleanConfigurationOverrides() { return overrides; } -/** @param {BooleanConfigurationOption} option */ -function getStoredBooleanConfigurationValue( option ) { - return Boolean( localStorage.getItem( `${ NS }-${ option }` ) ); -} - -/** - * @param {( option: BooleanConfigurationOption ) => boolean|null} getValue - * @returns {string} - */ +/** @param {(option: BooleanConfigurationOption) => boolean|null} getValue */ function buildHtmlOptions( getValue ) { - let htmlOptions = ''; - for ( const [ - enabledCode, - disabledCode, - option, - ] of BOOLEAN_CONFIGURATION_OPTIONS ) { + let options = ''; + for ( const [ enabled, disabled, option ] of BOOLEAN_CONFIGURATION_OPTIONS ) { const value = getValue( option ); if ( value === true ) { - htmlOptions += enabledCode; + options += enabled; } else if ( value === false ) { - htmlOptions += disabledCode; + options += disabled; } } - return htmlOptions; + return options; } -/** @returns {string} */ function getExplicitHtmlOptions() { - return buildHtmlOptions( - ( option ) => booleanConfigurationOverrides[ option ], - ); + return buildHtmlOptions( ( option ) => booleanConfigurationOverrides[ option ] ); } -/** @returns {string} */ function getResolvedHtmlOptions() { return buildHtmlOptions( ( option ) => Boolean( store.state[ option ] ) ); } /** @param {BooleanConfigurationOption} stateKey */ function getToggleHandler( stateKey ) { - /** - * @param {Event} e - * @returns {void} - */ - return ( e ) => { - const isChecked = /** @type {HTMLInputElement} */ ( e.target ).checked; - - store.state[ stateKey ] = isChecked; - booleanConfigurationOverrides[ stateKey ] = isChecked; - if ( isChecked ) { + /** @param {Event} event */ + return ( event ) => { + const checked = /** @type {HTMLInputElement} */ ( event.target ).checked; + store.state[ stateKey ] = checked; + booleanConfigurationOverrides[ stateKey ] = checked; + if ( checked ) { localStorage.setItem( `${ NS }-${ stateKey }`, '1' ); } else { localStorage.removeItem( `${ NS }-${ stateKey }` ); } - store.watchURL(); + controller.setOpts( getExplicitHtmlOptions() ); + touchState(); }; } diff --git a/html-api-debugger/readme.txt b/html-api-debugger/readme.txt index 5f404b2..14879fe 100644 --- a/html-api-debugger/readme.txt +++ b/html-api-debugger/readme.txt @@ -3,7 +3,7 @@ Contributors: jonsurrell, bernhard-reiter Tags: HTML API, development, debug Requires at least: 6.7 Tested up to: 6.8 -Stable tag: 2.9 +Stable tag: 3.0 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -15,6 +15,8 @@ Please file issues and pull requests on the [GitHub repository](https://github.c == Changelog == += 3.0 = +* Preserve arbitrary source, context, result, and playback bytes across URLs, REST processing, inspection, and DOM previews. * Add support for Processing Instruction nodes. * Preserve HTML tree visibility options in shared URLs. * Add "HTML5 Body" context button to allow setting default context. diff --git a/html-api-debugger/response-transport.mjs b/html-api-debugger/response-transport.mjs index c40270f..b605c74 100644 --- a/html-api-debugger/response-transport.mjs +++ b/html-api-debugger/response-transport.mjs @@ -1,8 +1,8 @@ -import { - decodeBase64url, - isByteEnvelope, - projectResponseStrings, -} from './byte-transport.mjs'; +// @ts-expect-error TypeScript does not resolve browser URL query strings. +import * as ByteTransportLive from './byte-transport.mjs?ver=3.0'; + +const { decodeBase64url, isByteEnvelope, projectResponseStrings } = + /** @type {typeof import('./byte-transport.mjs')} */ ( ByteTransportLive ); /** * Decode a successful byte-enveloped HTML API response. diff --git a/html-api-debugger/runtime-controller.mjs b/html-api-debugger/runtime-controller.mjs index d9c53c5..26b78f6 100644 --- a/html-api-debugger/runtime-controller.mjs +++ b/html-api-debugger/runtime-controller.mjs @@ -1,17 +1,28 @@ -import { - canonicalUrlPath, - parseCanonicalUrl, - serializeCanonicalUrl, -} from './canonical-url.mjs'; -import { splitByteSpan } from './byte-preview.mjs'; -import { +// @ts-expect-error TypeScript does not resolve browser URL query strings. +import * as CanonicalUrlLive from './canonical-url.mjs?ver=3.0'; +// @ts-expect-error TypeScript does not resolve browser URL query strings. +import * as BytePreviewLive from './byte-preview.mjs?ver=3.0'; +// @ts-expect-error TypeScript does not resolve browser URL query strings. +import * as ByteTransportLive from './byte-transport.mjs?ver=3.0'; +// @ts-expect-error TypeScript does not resolve browser URL query strings. +import * as ResponseTransportLive from './response-transport.mjs?ver=3.0'; + +const { canonicalUrlPath, parseCanonicalUrl, serializeCanonicalUrl } = + /** @type {typeof import('./canonical-url.mjs')} */ ( CanonicalUrlLive ); +const { splitByteSpan } = /** @type {typeof import('./byte-preview.mjs')} */ ( + BytePreviewLive +); +const { decodeUtf8, encodeBase64url, encodeUtf8, isValidUtf8, projectUtf8, -} from './byte-transport.mjs'; -import { decodeHtmlApiResponse } from './response-transport.mjs'; +} = /** @type {typeof import('./byte-transport.mjs')} */ ( ByteTransportLive ); +const { decodeHtmlApiResponse } = + /** @type {typeof import('./response-transport.mjs')} */ ( + ResponseTransportLive + ); /** * Byte-authoritative controller for the debugger runtime. diff --git a/html-api-debugger/style.css b/html-api-debugger/style.css index 9dd42dc..d1374f0 100644 --- a/html-api-debugger/style.css +++ b/html-api-debugger/style.css @@ -65,6 +65,32 @@ font-family: var(--monospace-font-family); } + .source-panel { + min-width: 0; + } + + .byte-view { + min-height: 4em; + max-height: 24em; + overflow: auto; + white-space: pre; + font-family: var(--monospace-font-family); + } + + .warning-holder { + padding: 0.75em; + border-left: 4px solid #dba617; + background: #fcf0c3; + } + + .view-buttons, + .controls-row { + display: flex; + align-items: center; + gap: 0.5em; + flex-wrap: wrap; + } + .context-html { width: 100%; font-family: var(--monospace-font-family); diff --git a/tests/byte-preview.mjs b/tests/byte-preview.mjs index ff35690..8e6bf19 100644 --- a/tests/byte-preview.mjs +++ b/tests/byte-preview.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { ByteDocumentPreview, + resolveFragmentTarget, splitByteSpan, } from '../html-api-debugger/byte-preview.mjs'; @@ -129,4 +130,86 @@ for ( const [ start, length ] of [ assert.throws( () => splitByteSpan( spanBytes, start, length ), TypeError ); } +function fakeContextDocument( walkedElements = [], bodyHasNodes = false, headHasNodes = false ) { + const body = { name: 'BODY', hasChildNodes: () => bodyHasNodes }; + const head = { name: 'HEAD', hasChildNodes: () => headHasNodes }; + const documentElement = { name: 'HTML' }; + let offset = -1; + const walker = { + currentNode: documentElement, + nextNode() { + offset += 1; + if ( offset >= walkedElements.length ) { + return false; + } + this.currentNode = walkedElements[ offset ]; + return true; + }, + }; + return { + body, + head, + documentElement, + createTreeWalker() { + return walker; + }, + }; +} + +const emptyContextDocument = fakeContextDocument(); +assert.equal( + resolveFragmentTarget( + /** @type {any} */ ( emptyContextDocument ), + '', + ), + emptyContextDocument.head, + 'empty authored HEAD remains the native fragment context', +); +assert.equal( + resolveFragmentTarget( + /** @type {any} */ ( emptyContextDocument ), + '', + ), + emptyContextDocument.body, + 'empty authored BODY remains the native fragment context', +); +assert.equal( + resolveFragmentTarget( + /** @type {any} */ ( emptyContextDocument ), + '', + ), + emptyContextDocument.documentElement, + 'empty authored HTML uses the document element', +); +assert.equal( + resolveFragmentTarget( + /** @type {any} */ ( emptyContextDocument ), + '', + ), + emptyContextDocument.documentElement, + 'doctype-only context falls back to the document element', +); + +const nestedContext = { name: 'SPAN' }; +const populatedContextDocument = fakeContextDocument( + [ { name: 'HTML' }, { name: 'BODY' }, { name: 'MAIN' }, nestedContext ], + true, +); +assert.equal( + resolveFragmentTarget( + /** @type {any} */ ( populatedContextDocument ), + '
        ', + ), + nestedContext, + 'populated context uses the final parsed element', +); +assert.throws( + () => + resolveFragmentTarget( + /** @type {any} */ ( emptyContextDocument ), + /** @type {any} */ ( null ), + ), + TypeError, +); + console.log( 'All byte preview tests passed.' ); diff --git a/tests/main-wiring.mjs b/tests/main-wiring.mjs new file mode 100644 index 0000000..6c5286d --- /dev/null +++ b/tests/main-wiring.mjs @@ -0,0 +1,74 @@ +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' ), + '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.0', + './byte-transport.mjs?ver=3.0', + './runtime-controller.mjs?ver=3.0', + './runtime-wiring.mjs?ver=3.0', +] ); +assert.deepEqual( runtimeRelativeImports( graph[ 'runtime-controller.mjs' ] ), [ + './canonical-url.mjs?ver=3.0', + './byte-preview.mjs?ver=3.0', + './byte-transport.mjs?ver=3.0', + './response-transport.mjs?ver=3.0', +] ); +assert.deepEqual( runtimeRelativeImports( graph[ 'canonical-url.mjs' ] ), [ + './byte-transport.mjs?ver=3.0', +] ); +assert.deepEqual( runtimeRelativeImports( graph[ 'response-transport.mjs' ] ), [ + './byte-transport.mjs?ver=3.0', +] ); +assert.deepEqual( runtimeRelativeImports( graph[ 'runtime-wiring.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\.0$/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, /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*beginPendingResponse\(\);/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..9552f8f --- /dev/null +++ b/tests/plugin-cutover-regression.php @@ -0,0 +1,274 @@ + $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.0', 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.0", '3.0', $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.0', '3.0', $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' ) +); + +$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"; From 22647ebe3f63118e371b62802f5cfa37d37e612b Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 15 Jul 2026 17:37:49 +0200 Subject: [PATCH 12/16] Keep byte edits honest --- html-api-debugger/byte-preview.mjs | 695 ++++++++++++++++++++- html-api-debugger/html-api-integration.php | 4 +- html-api-debugger/main.mjs | 147 +++-- html-api-debugger/runtime-controller.mjs | 33 +- html-api-debugger/ui-transactions.mjs | 42 ++ tests/byte-preview.mjs | 162 ++++- tests/fragment-context-browser.html | 138 ++++ tests/html-api-integration-regression.php | 28 + tests/main-wiring.mjs | 10 +- tests/rest-api-regression.php | 7 +- tests/runtime-controller.mjs | 112 ++++ tests/ui-transactions.mjs | 107 ++++ 12 files changed, 1393 insertions(+), 92 deletions(-) create mode 100644 html-api-debugger/ui-transactions.mjs create mode 100644 tests/fragment-context-browser.html create mode 100644 tests/ui-transactions.mjs diff --git a/html-api-debugger/byte-preview.mjs b/html-api-debugger/byte-preview.mjs index 983cec0..7befde6 100644 --- a/html-api-debugger/byte-preview.mjs +++ b/html-api-debugger/byte-preview.mjs @@ -118,23 +118,692 @@ export function resolveFragmentTarget( document, contextText ) { throw new TypeError( 'Fragment context text must be a string.' ); } - if ( document.body?.hasChildNodes() || document.head?.hasChildNodes() ) { - const walker = document.createTreeWalker( document, 1 ); - /** @type {Element|null} */ - let lastElement = null; - while ( walker.nextNode() ) { - lastElement = /** @type {Element} */ ( walker.currentNode ); + 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', + 'noscript', + '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; } - if ( lastElement !== null ) { - return lastElement; + } + 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 ), + '<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( diff --git a/tests/fragment-context-browser.html b/tests/fragment-context-browser.html new file mode 100644 index 0000000..9c2f977 --- /dev/null +++ b/tests/fragment-context-browser.html @@ -0,0 +1,138 @@ +<!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-scripts allow-same-origin' ); + const loaded = new Promise( ( resolve ) => { + iframe.addEventListener( 'load', resolve, { once: true } ); + } ); + iframe.srcdoc = source; + document.body.append( iframe ); + await loaded; + 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>', 'NOSCRIPT', 'scripting-enabled 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(); + } + + 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..70ae21d 100644 --- a/tests/html-api-integration-regression.php +++ b/tests/html-api-integration-regression.php @@ -155,6 +155,34 @@ 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"; + html_api_debugger_assert_tree( 'full parser preserves document nesting', '<div><p>a</p>b</div>c', diff --git a/tests/main-wiring.mjs b/tests/main-wiring.mjs index 6c5286d..2f2678a 100644 --- a/tests/main-wiring.mjs +++ b/tests/main-wiring.mjs @@ -21,6 +21,7 @@ const graph = { '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' ), }; @@ -30,6 +31,7 @@ assert.deepEqual( runtimeRelativeImports( graph[ 'main.mjs' ] ), [ './byte-transport.mjs?ver=3.0', './runtime-controller.mjs?ver=3.0', './runtime-wiring.mjs?ver=3.0', + './ui-transactions.mjs?ver=3.0', ] ); assert.deepEqual( runtimeRelativeImports( graph[ 'runtime-controller.mjs' ] ), [ './canonical-url.mjs?ver=3.0', @@ -44,6 +46,7 @@ assert.deepEqual( runtimeRelativeImports( graph[ 'response-transport.mjs' ] ), [ './byte-transport.mjs?ver=3.0', ] ); 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' ] ), [] ); @@ -64,7 +67,12 @@ 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*beginPendingResponse\(\);/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 ); diff --git a/tests/rest-api-regression.php b/tests/rest-api-regression.php index c8b6f20..410435d 100644 --- a/tests/rest-api-regression.php +++ b/tests/rest-api-regression.php @@ -153,9 +153,12 @@ function html_api_debugger_rest_assert_enveloped( $value ): void { 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[4][1]['context_html'] ); - html_api_debugger_rest_assert_same( 'empty selector means no selector', null, $test_processing_calls[4][1]['selector'] ); + 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', diff --git a/tests/runtime-controller.mjs b/tests/runtime-controller.mjs index 12cc3e5..0a71d36 100644 --- a/tests/runtime-controller.mjs +++ b/tests/runtime-controller.mjs @@ -300,4 +300,116 @@ assert.equal( '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/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.' ); From 0e69034fded5a5d835b42b9425df47b527b7ca5c Mon Sep 17 00:00:00 2001 From: Jon Surrell <sirreal@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:55:18 +0200 Subject: [PATCH 13/16] Contain hostile previews --- html-api-debugger/byte-preview.mjs | 1 - html-api-debugger/canonical-url.mjs | 2 +- html-api-debugger/html-api-debugger.php | 4 ++-- html-api-debugger/interactivity.php | 2 +- html-api-debugger/main.mjs | 10 +++++----- html-api-debugger/readme.txt | 5 ++++- html-api-debugger/response-transport.mjs | 2 +- html-api-debugger/runtime-controller.mjs | 8 ++++---- tests/byte-preview.mjs | 8 ++++++++ tests/fragment-context-browser.html | 18 +++++++++++++++-- tests/html-api-integration-regression.php | 13 ++++++++++++ tests/main-wiring.mjs | 24 +++++++++++------------ tests/plugin-cutover-regression.php | 15 +++++++++++--- 13 files changed, 79 insertions(+), 33 deletions(-) diff --git a/html-api-debugger/byte-preview.mjs b/html-api-debugger/byte-preview.mjs index 7befde6..4044d63 100644 --- a/html-api-debugger/byte-preview.mjs +++ b/html-api-debugger/byte-preview.mjs @@ -138,7 +138,6 @@ const RAW_TEXT_ELEMENTS = new Set( [ 'iframe', 'noembed', 'noframes', - 'noscript', 'style', 'xmp', ] ); diff --git a/html-api-debugger/canonical-url.mjs b/html-api-debugger/canonical-url.mjs index f6fd87a..8f5645b 100644 --- a/html-api-debugger/canonical-url.mjs +++ b/html-api-debugger/canonical-url.mjs @@ -1,5 +1,5 @@ // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as ByteTransportLive from './byte-transport.mjs?ver=3.0'; +import * as ByteTransportLive from './byte-transport.mjs?ver=3.1'; const { decodeBase64url, decodeUtf8, encodeBase64url } = /** @type {typeof import('./byte-transport.mjs')} */ ( ByteTransportLive ); diff --git a/html-api-debugger/html-api-debugger.php b/html-api-debugger/html-api-debugger.php index c33415c..2e1a69b 100644 --- a/html-api-debugger/html-api-debugger.php +++ b/html-api-debugger/html-api-debugger.php @@ -3,7 +3,7 @@ * Plugin Name: HTML API Debugger * Plugin URI: https://github.com/sirreal/html-api-debugger * Description: Add a page to wp-admin for debugging the HTML API. - * Version: 3.0 + * Version: 3.1 * Requires at least: 6.7 * Tested up to: 6.8 * Author: Jon Surrell @@ -24,7 +24,7 @@ require_once __DIR__ . '/rest-api.php'; const SLUG = 'html-api-debugger'; -const VERSION = '3.0'; +const VERSION = '3.1'; /** Set up the plugin. */ function init() { diff --git a/html-api-debugger/interactivity.php b/html-api-debugger/interactivity.php index 4dbf2b7..b76a209 100644 --- a/html-api-debugger/interactivity.php +++ b/html-api-debugger/interactivity.php @@ -161,7 +161,7 @@ class="context-html" src="about:blank" id="rendered_iframe" referrerpolicy="no-referrer" - sandbox="allow-forms allow-modals allow-popups allow-scripts allow-same-origin"></iframe> + sandbox="allow-same-origin"></iframe> </div> <details class="full-width" data-wp-bind--hidden="state.normalizedUnavailable"> diff --git a/html-api-debugger/main.mjs b/html-api-debugger/main.mjs index 578a533..dce7f28 100644 --- a/html-api-debugger/main.mjs +++ b/html-api-debugger/main.mjs @@ -6,15 +6,15 @@ import { replaceInvisible } from '@html-api-debugger/replace-invisible-chars'; import * as I from '@wordpress/interactivity'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as BytePreviewLive from './byte-preview.mjs?ver=3.0'; +import * as BytePreviewLive from './byte-preview.mjs?ver=3.1'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as ByteTransportLive from './byte-transport.mjs?ver=3.0'; +import * as ByteTransportLive from './byte-transport.mjs?ver=3.1'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as RuntimeControllerLive from './runtime-controller.mjs?ver=3.0'; +import * as RuntimeControllerLive from './runtime-controller.mjs?ver=3.1'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as RuntimeWiringLive from './runtime-wiring.mjs?ver=3.0'; +import * as RuntimeWiringLive from './runtime-wiring.mjs?ver=3.1'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as UiTransactionsLive from './ui-transactions.mjs?ver=3.0'; +import * as UiTransactionsLive from './ui-transactions.mjs?ver=3.1'; const { ByteDocumentPreview, resolveFragmentTarget } = /** @type {typeof import('./byte-preview.mjs')} */ ( BytePreviewLive ); diff --git a/html-api-debugger/readme.txt b/html-api-debugger/readme.txt index 14879fe..5f0eb5a 100644 --- a/html-api-debugger/readme.txt +++ b/html-api-debugger/readme.txt @@ -3,7 +3,7 @@ Contributors: jonsurrell, bernhard-reiter Tags: HTML API, development, debug Requires at least: 6.7 Tested up to: 6.8 -Stable tag: 3.0 +Stable tag: 3.1 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -15,6 +15,9 @@ Please file issues and pull requests on the [GitHub repository](https://github.c == Changelog == += 3.1 = +* Contain rendered input in a scripting-disabled iframe while preserving DOM inspection. + = 3.0 = * Preserve arbitrary source, context, result, and playback bytes across URLs, REST processing, inspection, and DOM previews. * Add support for Processing Instruction nodes. diff --git a/html-api-debugger/response-transport.mjs b/html-api-debugger/response-transport.mjs index b605c74..23edb50 100644 --- a/html-api-debugger/response-transport.mjs +++ b/html-api-debugger/response-transport.mjs @@ -1,5 +1,5 @@ // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as ByteTransportLive from './byte-transport.mjs?ver=3.0'; +import * as ByteTransportLive from './byte-transport.mjs?ver=3.1'; const { decodeBase64url, isByteEnvelope, projectResponseStrings } = /** @type {typeof import('./byte-transport.mjs')} */ ( ByteTransportLive ); diff --git a/html-api-debugger/runtime-controller.mjs b/html-api-debugger/runtime-controller.mjs index 51ff61a..eeb0d34 100644 --- a/html-api-debugger/runtime-controller.mjs +++ b/html-api-debugger/runtime-controller.mjs @@ -1,11 +1,11 @@ // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as CanonicalUrlLive from './canonical-url.mjs?ver=3.0'; +import * as CanonicalUrlLive from './canonical-url.mjs?ver=3.1'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as BytePreviewLive from './byte-preview.mjs?ver=3.0'; +import * as BytePreviewLive from './byte-preview.mjs?ver=3.1'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as ByteTransportLive from './byte-transport.mjs?ver=3.0'; +import * as ByteTransportLive from './byte-transport.mjs?ver=3.1'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as ResponseTransportLive from './response-transport.mjs?ver=3.0'; +import * as ResponseTransportLive from './response-transport.mjs?ver=3.1'; const { canonicalUrlPath, parseCanonicalUrl, serializeCanonicalUrl } = /** @type {typeof import('./canonical-url.mjs')} */ ( CanonicalUrlLive ); diff --git a/tests/byte-preview.mjs b/tests/byte-preview.mjs index 1a03d03..e4f4815 100644 --- a/tests/byte-preview.mjs +++ b/tests/byte-preview.mjs @@ -248,6 +248,14 @@ assert.equal( 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 ), diff --git a/tests/fragment-context-browser.html b/tests/fragment-context-browser.html index 9c2f977..cf5b7d4 100644 --- a/tests/fragment-context-browser.html +++ b/tests/fragment-context-browser.html @@ -21,13 +21,16 @@ async function parseNavigated( source ) { const iframe = document.createElement( 'iframe' ); iframe.hidden = true; - iframe.setAttribute( 'sandbox', 'allow-scripts allow-same-origin' ); + 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; } @@ -74,7 +77,7 @@ realmIframe.remove(); const liveCases = [ - [ '<noscript><body>', 'NOSCRIPT', 'scripting-enabled NOSCRIPT' ], + [ '<noscript><body>', 'BODY', 'scripting-disabled NOSCRIPT' ], [ '<!doctype html><head><script><!--<script><\/script><body><\/script>', 'SCRIPT', @@ -122,6 +125,17 @@ 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( diff --git a/tests/html-api-integration-regression.php b/tests/html-api-integration-regression.php index 70ae21d..0d9abe9 100644 --- a/tests/html-api-integration-regression.php +++ b/tests/html-api-integration-regression.php @@ -183,6 +183,19 @@ function html_api_debugger_assert_tree( string $label, string $html, ?string $co } 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/main-wiring.mjs b/tests/main-wiring.mjs index 2f2678a..ef29e39 100644 --- a/tests/main-wiring.mjs +++ b/tests/main-wiring.mjs @@ -27,23 +27,23 @@ const graph = { }; assert.deepEqual( runtimeRelativeImports( graph[ 'main.mjs' ] ), [ - './byte-preview.mjs?ver=3.0', - './byte-transport.mjs?ver=3.0', - './runtime-controller.mjs?ver=3.0', - './runtime-wiring.mjs?ver=3.0', - './ui-transactions.mjs?ver=3.0', + './byte-preview.mjs?ver=3.1', + './byte-transport.mjs?ver=3.1', + './runtime-controller.mjs?ver=3.1', + './runtime-wiring.mjs?ver=3.1', + './ui-transactions.mjs?ver=3.1', ] ); assert.deepEqual( runtimeRelativeImports( graph[ 'runtime-controller.mjs' ] ), [ - './canonical-url.mjs?ver=3.0', - './byte-preview.mjs?ver=3.0', - './byte-transport.mjs?ver=3.0', - './response-transport.mjs?ver=3.0', + './canonical-url.mjs?ver=3.1', + './byte-preview.mjs?ver=3.1', + './byte-transport.mjs?ver=3.1', + './response-transport.mjs?ver=3.1', ] ); assert.deepEqual( runtimeRelativeImports( graph[ 'canonical-url.mjs' ] ), [ - './byte-transport.mjs?ver=3.0', + './byte-transport.mjs?ver=3.1', ] ); assert.deepEqual( runtimeRelativeImports( graph[ 'response-transport.mjs' ] ), [ - './byte-transport.mjs?ver=3.0', + './byte-transport.mjs?ver=3.1', ] ); assert.deepEqual( runtimeRelativeImports( graph[ 'runtime-wiring.mjs' ] ), [] ); assert.deepEqual( runtimeRelativeImports( graph[ 'ui-transactions.mjs' ] ), [] ); @@ -52,7 +52,7 @@ 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\.0$/u, `${ file } has an unversioned live relative import` ); + assert.match( specifier, /\?ver=3\.1$/u, `${ file } has an unversioned live relative import` ); } } diff --git a/tests/plugin-cutover-regression.php b/tests/plugin-cutover-regression.php index 9552f8f..37b84da 100644 --- a/tests/plugin-cutover-regression.php +++ b/tests/plugin-cutover-regression.php @@ -168,7 +168,7 @@ function html_api_debugger_cutover_assert_same( string $label, $expected, $actua require dirname( __DIR__ ) . '/html-api-debugger/html-api-debugger.php'; -html_api_debugger_cutover_assert_same( 'plugin version constant is cache-busted', '3.0', HTML_API_Debugger\VERSION ); +html_api_debugger_cutover_assert_same( 'plugin version constant is cache-busted', '3.1', HTML_API_Debugger\VERSION ); do_action( 'init' ); @@ -191,7 +191,7 @@ function html_api_debugger_cutover_assert_same( string $label, $expected, $actua ); foreach ( $test_modules as $id => $module ) { - html_api_debugger_cutover_assert_same( "module {$id} uses version 3.0", '3.0', $module['version'] ); + html_api_debugger_cutover_assert_same( "module {$id} uses version 3.1", '3.1', $module['version'] ); } html_api_debugger_cutover_assert_same( 'main module is registered', @@ -200,7 +200,7 @@ function html_api_debugger_cutover_assert_same( string $label, $expected, $actua ); do_action( 'admin_enqueue_scripts', 'toplevel_page_html-api-debugger' ); -html_api_debugger_cutover_assert_same( 'debugger stylesheet uses version 3.0', '3.0', $test_styles['html-api-debugger']['version'] ); +html_api_debugger_cutover_assert_same( 'debugger stylesheet uses version 3.1', '3.1', $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' ); @@ -244,6 +244,15 @@ function html_api_debugger_render_shell( array $query ): array { 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() ); From c4de721ac7301b610f41009c87c47e76a65f3708 Mon Sep 17 00:00:00 2001 From: Jon Surrell <sirreal@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:05:52 +0200 Subject: [PATCH 14/16] Normalize bare URL fields --- html-api-debugger/canonical-url.mjs | 31 +++++++++----- html-api-debugger/html-api-debugger.php | 4 +- html-api-debugger/main.mjs | 10 ++--- html-api-debugger/readme.txt | 5 ++- html-api-debugger/response-transport.mjs | 2 +- html-api-debugger/runtime-controller.mjs | 8 ++-- tests/canonical-url-browser.html | 51 ++++++++++++++++++++++++ tests/canonical-url.mjs | 19 ++++++++- tests/main-wiring.mjs | 24 +++++------ tests/plugin-cutover-regression.php | 6 +-- tests/runtime-controller.mjs | 25 ++++++++++++ 11 files changed, 147 insertions(+), 38 deletions(-) create mode 100644 tests/canonical-url-browser.html diff --git a/html-api-debugger/canonical-url.mjs b/html-api-debugger/canonical-url.mjs index 8f5645b..6b8e597 100644 --- a/html-api-debugger/canonical-url.mjs +++ b/html-api-debugger/canonical-url.mjs @@ -1,5 +1,5 @@ // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as ByteTransportLive from './byte-transport.mjs?ver=3.1'; +import * as ByteTransportLive from './byte-transport.mjs?ver=3.2'; const { decodeBase64url, decodeUtf8, encodeBase64url } = /** @type {typeof import('./byte-transport.mjs')} */ ( ByteTransportLive ); @@ -66,7 +66,7 @@ export function parseCanonicalUrl( url ) { throw new CanonicalUrlError( 'The legacy URL was not migrated.' ); } - const raw = getCanonicalRawValues( url ); + const { values: raw, needsCanonicalization } = getCanonicalRawValues( url ); if ( raw.format !== 'v1' ) { throw new CanonicalUrlError( 'Unknown or missing URL format.' ); } @@ -91,7 +91,7 @@ export function parseCanonicalUrl( url ) { contextBytes: decodeBase64url( raw.context64 ), selector, opts: raw.opts, - needsCanonicalization: false, + needsCanonicalization, }; } catch { throw new CanonicalUrlError( 'Invalid canonical byte field.' ); @@ -146,24 +146,33 @@ export function canonicalUrlPath( url ) { * such as `ht%6Dl64` cannot hide behind URLSearchParams normalization. * * @param {URL} url URL to inspect. - * @returns {Record<(typeof CANONICAL_PARAMETERS)[number], string>} Raw values. + * A single bare literal parameter is the unambiguous empty value, but is + * reported as noncanonical so the caller can rewrite it to `name=`. + * + * @returns {{values: Record<(typeof CANONICAL_PARAMETERS)[number], string>, needsCanonicalization: boolean}} Raw values and whether their spelling needs a rewrite. */ function getCanonicalRawValues( url ) { const pairs = url.search.length === 0 ? [] : url.search.slice( 1 ).split( '&' ); /** @type {Partial<Record<(typeof CANONICAL_PARAMETERS)[number], string>>} */ const values = {}; + let needsCanonicalization = false; for ( const name of CANONICAL_PARAMETERS ) { const prefix = `${ name }=`; const literalValues = pairs .filter( ( pair ) => pair === name || pair.startsWith( prefix ) ) - .map( ( pair ) => ( pair.startsWith( prefix ) ? pair.slice( prefix.length ) : null ) ); + .map( ( pair ) => { + if ( pair === name ) { + needsCanonicalization = true; + return ''; + } + return pair.slice( prefix.length ); + } ); const literalValue = literalValues[ 0 ]; if ( url.searchParams.getAll( name ).length !== literalValues.length || literalValues.length !== 1 || - literalValue === null || literalValue === undefined ) { throw new CanonicalUrlError( `Missing, duplicate, or aliased ${ name } parameter.` ); @@ -171,9 +180,13 @@ function getCanonicalRawValues( url ) { values[ name ] = literalValue; } - return /** @type {Record<(typeof CANONICAL_PARAMETERS)[number], string>} */ ( - values - ); + return { + values: + /** @type {Record<(typeof CANONICAL_PARAMETERS)[number], string>} */ ( + values + ), + needsCanonicalization, + }; } /** diff --git a/html-api-debugger/html-api-debugger.php b/html-api-debugger/html-api-debugger.php index 2e1a69b..1b775c2 100644 --- a/html-api-debugger/html-api-debugger.php +++ b/html-api-debugger/html-api-debugger.php @@ -3,7 +3,7 @@ * Plugin Name: HTML API Debugger * Plugin URI: https://github.com/sirreal/html-api-debugger * Description: Add a page to wp-admin for debugging the HTML API. - * Version: 3.1 + * Version: 3.2 * Requires at least: 6.7 * Tested up to: 6.8 * Author: Jon Surrell @@ -24,7 +24,7 @@ require_once __DIR__ . '/rest-api.php'; const SLUG = 'html-api-debugger'; -const VERSION = '3.1'; +const VERSION = '3.2'; /** Set up the plugin. */ function init() { diff --git a/html-api-debugger/main.mjs b/html-api-debugger/main.mjs index dce7f28..ed033bc 100644 --- a/html-api-debugger/main.mjs +++ b/html-api-debugger/main.mjs @@ -6,15 +6,15 @@ import { replaceInvisible } from '@html-api-debugger/replace-invisible-chars'; import * as I from '@wordpress/interactivity'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as BytePreviewLive from './byte-preview.mjs?ver=3.1'; +import * as BytePreviewLive from './byte-preview.mjs?ver=3.2'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as ByteTransportLive from './byte-transport.mjs?ver=3.1'; +import * as ByteTransportLive from './byte-transport.mjs?ver=3.2'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as RuntimeControllerLive from './runtime-controller.mjs?ver=3.1'; +import * as RuntimeControllerLive from './runtime-controller.mjs?ver=3.2'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as RuntimeWiringLive from './runtime-wiring.mjs?ver=3.1'; +import * as RuntimeWiringLive from './runtime-wiring.mjs?ver=3.2'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as UiTransactionsLive from './ui-transactions.mjs?ver=3.1'; +import * as UiTransactionsLive from './ui-transactions.mjs?ver=3.2'; const { ByteDocumentPreview, resolveFragmentTarget } = /** @type {typeof import('./byte-preview.mjs')} */ ( BytePreviewLive ); diff --git a/html-api-debugger/readme.txt b/html-api-debugger/readme.txt index 5f0eb5a..1f6b528 100644 --- a/html-api-debugger/readme.txt +++ b/html-api-debugger/readme.txt @@ -3,7 +3,7 @@ Contributors: jonsurrell, bernhard-reiter Tags: HTML API, development, debug Requires at least: 6.7 Tested up to: 6.8 -Stable tag: 3.1 +Stable tag: 3.2 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -15,6 +15,9 @@ Please file issues and pull requests on the [GitHub repository](https://github.c == Changelog == += 3.2 = +* Normalize bare empty canonical URL fields instead of leaving a permanent warning. + = 3.1 = * Contain rendered input in a scripting-disabled iframe while preserving DOM inspection. diff --git a/html-api-debugger/response-transport.mjs b/html-api-debugger/response-transport.mjs index 23edb50..86b22d2 100644 --- a/html-api-debugger/response-transport.mjs +++ b/html-api-debugger/response-transport.mjs @@ -1,5 +1,5 @@ // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as ByteTransportLive from './byte-transport.mjs?ver=3.1'; +import * as ByteTransportLive from './byte-transport.mjs?ver=3.2'; const { decodeBase64url, isByteEnvelope, projectResponseStrings } = /** @type {typeof import('./byte-transport.mjs')} */ ( ByteTransportLive ); diff --git a/html-api-debugger/runtime-controller.mjs b/html-api-debugger/runtime-controller.mjs index eeb0d34..2af3aac 100644 --- a/html-api-debugger/runtime-controller.mjs +++ b/html-api-debugger/runtime-controller.mjs @@ -1,11 +1,11 @@ // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as CanonicalUrlLive from './canonical-url.mjs?ver=3.1'; +import * as CanonicalUrlLive from './canonical-url.mjs?ver=3.2'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as BytePreviewLive from './byte-preview.mjs?ver=3.1'; +import * as BytePreviewLive from './byte-preview.mjs?ver=3.2'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as ByteTransportLive from './byte-transport.mjs?ver=3.1'; +import * as ByteTransportLive from './byte-transport.mjs?ver=3.2'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as ResponseTransportLive from './response-transport.mjs?ver=3.1'; +import * as ResponseTransportLive from './response-transport.mjs?ver=3.2'; const { canonicalUrlPath, parseCanonicalUrl, serializeCanonicalUrl } = /** @type {typeof import('./canonical-url.mjs')} */ ( CanonicalUrlLive ); diff --git a/tests/canonical-url-browser.html b/tests/canonical-url-browser.html new file mode 100644 index 0000000..c46d033 --- /dev/null +++ b/tests/canonical-url-browser.html @@ -0,0 +1,51 @@ +<!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'; + + 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 }` ); + } + + 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 index fc382ce..db7237b 100644 --- a/tests/canonical-url.mjs +++ b/tests/canonical-url.mjs @@ -28,6 +28,23 @@ assert.deepEqual( parseCanonicalUrl( canonicalEmpty ), { 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, @@ -83,7 +100,7 @@ for ( const invalidQuery of [ '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=&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', diff --git a/tests/main-wiring.mjs b/tests/main-wiring.mjs index ef29e39..dd4d433 100644 --- a/tests/main-wiring.mjs +++ b/tests/main-wiring.mjs @@ -27,23 +27,23 @@ const graph = { }; assert.deepEqual( runtimeRelativeImports( graph[ 'main.mjs' ] ), [ - './byte-preview.mjs?ver=3.1', - './byte-transport.mjs?ver=3.1', - './runtime-controller.mjs?ver=3.1', - './runtime-wiring.mjs?ver=3.1', - './ui-transactions.mjs?ver=3.1', + './byte-preview.mjs?ver=3.2', + './byte-transport.mjs?ver=3.2', + './runtime-controller.mjs?ver=3.2', + './runtime-wiring.mjs?ver=3.2', + './ui-transactions.mjs?ver=3.2', ] ); assert.deepEqual( runtimeRelativeImports( graph[ 'runtime-controller.mjs' ] ), [ - './canonical-url.mjs?ver=3.1', - './byte-preview.mjs?ver=3.1', - './byte-transport.mjs?ver=3.1', - './response-transport.mjs?ver=3.1', + './canonical-url.mjs?ver=3.2', + './byte-preview.mjs?ver=3.2', + './byte-transport.mjs?ver=3.2', + './response-transport.mjs?ver=3.2', ] ); assert.deepEqual( runtimeRelativeImports( graph[ 'canonical-url.mjs' ] ), [ - './byte-transport.mjs?ver=3.1', + './byte-transport.mjs?ver=3.2', ] ); assert.deepEqual( runtimeRelativeImports( graph[ 'response-transport.mjs' ] ), [ - './byte-transport.mjs?ver=3.1', + './byte-transport.mjs?ver=3.2', ] ); assert.deepEqual( runtimeRelativeImports( graph[ 'runtime-wiring.mjs' ] ), [] ); assert.deepEqual( runtimeRelativeImports( graph[ 'ui-transactions.mjs' ] ), [] ); @@ -52,7 +52,7 @@ 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\.1$/u, `${ file } has an unversioned live relative import` ); + assert.match( specifier, /\?ver=3\.2$/u, `${ file } has an unversioned live relative import` ); } } diff --git a/tests/plugin-cutover-regression.php b/tests/plugin-cutover-regression.php index 37b84da..0f80c41 100644 --- a/tests/plugin-cutover-regression.php +++ b/tests/plugin-cutover-regression.php @@ -168,7 +168,7 @@ function html_api_debugger_cutover_assert_same( string $label, $expected, $actua require dirname( __DIR__ ) . '/html-api-debugger/html-api-debugger.php'; -html_api_debugger_cutover_assert_same( 'plugin version constant is cache-busted', '3.1', HTML_API_Debugger\VERSION ); +html_api_debugger_cutover_assert_same( 'plugin version constant is cache-busted', '3.2', HTML_API_Debugger\VERSION ); do_action( 'init' ); @@ -191,7 +191,7 @@ function html_api_debugger_cutover_assert_same( string $label, $expected, $actua ); foreach ( $test_modules as $id => $module ) { - html_api_debugger_cutover_assert_same( "module {$id} uses version 3.1", '3.1', $module['version'] ); + html_api_debugger_cutover_assert_same( "module {$id} uses version 3.2", '3.2', $module['version'] ); } html_api_debugger_cutover_assert_same( 'main module is registered', @@ -200,7 +200,7 @@ function html_api_debugger_cutover_assert_same( string $label, $expected, $actua ); do_action( 'admin_enqueue_scripts', 'toplevel_page_html-api-debugger' ); -html_api_debugger_cutover_assert_same( 'debugger stylesheet uses version 3.1', '3.1', $test_styles['html-api-debugger']['version'] ); +html_api_debugger_cutover_assert_same( 'debugger stylesheet uses version 3.2', '3.2', $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' ); diff --git a/tests/runtime-controller.mjs b/tests/runtime-controller.mjs index 0a71d36..ec738a2 100644 --- a/tests/runtime-controller.mjs +++ b/tests/runtime-controller.mjs @@ -57,6 +57,31 @@ 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=', From b107b8de358bd9983f035f7b609019319184a085 Mon Sep 17 00:00:00 2001 From: Jon Surrell <sirreal@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:52:52 +0200 Subject: [PATCH 15/16] Bind browser history correctly --- html-api-debugger/canonical-url.mjs | 2 +- html-api-debugger/html-api-debugger.php | 4 ++-- html-api-debugger/main.mjs | 12 +++++----- html-api-debugger/readme.txt | 5 +++- html-api-debugger/response-transport.mjs | 2 +- html-api-debugger/runtime-controller.mjs | 8 +++---- tests/canonical-url-browser.html | 30 ++++++++++++++++++++++++ tests/main-wiring.mjs | 28 ++++++++++++---------- tests/plugin-cutover-regression.php | 6 ++--- 9 files changed, 67 insertions(+), 30 deletions(-) diff --git a/html-api-debugger/canonical-url.mjs b/html-api-debugger/canonical-url.mjs index 6b8e597..25cad58 100644 --- a/html-api-debugger/canonical-url.mjs +++ b/html-api-debugger/canonical-url.mjs @@ -1,5 +1,5 @@ // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as ByteTransportLive from './byte-transport.mjs?ver=3.2'; +import * as ByteTransportLive from './byte-transport.mjs?ver=3.3'; const { decodeBase64url, decodeUtf8, encodeBase64url } = /** @type {typeof import('./byte-transport.mjs')} */ ( ByteTransportLive ); diff --git a/html-api-debugger/html-api-debugger.php b/html-api-debugger/html-api-debugger.php index 1b775c2..4bbc383 100644 --- a/html-api-debugger/html-api-debugger.php +++ b/html-api-debugger/html-api-debugger.php @@ -3,7 +3,7 @@ * Plugin Name: HTML API Debugger * Plugin URI: https://github.com/sirreal/html-api-debugger * Description: Add a page to wp-admin for debugging the HTML API. - * Version: 3.2 + * Version: 3.3 * Requires at least: 6.7 * Tested up to: 6.8 * Author: Jon Surrell @@ -24,7 +24,7 @@ require_once __DIR__ . '/rest-api.php'; const SLUG = 'html-api-debugger'; -const VERSION = '3.2'; +const VERSION = '3.3'; /** Set up the plugin. */ function init() { diff --git a/html-api-debugger/main.mjs b/html-api-debugger/main.mjs index ed033bc..8804d0a 100644 --- a/html-api-debugger/main.mjs +++ b/html-api-debugger/main.mjs @@ -6,15 +6,15 @@ import { replaceInvisible } from '@html-api-debugger/replace-invisible-chars'; import * as I from '@wordpress/interactivity'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as BytePreviewLive from './byte-preview.mjs?ver=3.2'; +import * as BytePreviewLive from './byte-preview.mjs?ver=3.3'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as ByteTransportLive from './byte-transport.mjs?ver=3.2'; +import * as ByteTransportLive from './byte-transport.mjs?ver=3.3'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as RuntimeControllerLive from './runtime-controller.mjs?ver=3.2'; +import * as RuntimeControllerLive from './runtime-controller.mjs?ver=3.3'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as RuntimeWiringLive from './runtime-wiring.mjs?ver=3.2'; +import * as RuntimeWiringLive from './runtime-wiring.mjs?ver=3.3'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as UiTransactionsLive from './ui-transactions.mjs?ver=3.2'; +import * as UiTransactionsLive from './ui-transactions.mjs?ver=3.3'; const { ByteDocumentPreview, resolveFragmentTarget } = /** @type {typeof import('./byte-preview.mjs')} */ ( BytePreviewLive ); @@ -58,7 +58,7 @@ const controller = new ByteRuntimeController( { url: new URL( document.location.href ), supports: cfg.supports, request: ( body ) => requestBoundary.request( body ), - replaceUrl: ( url ) => history.replaceState( null, '', url ), + replaceUrl: ( url ) => window.history.replaceState( null, '', url.href ), confirmConversion: ( message ) => window.confirm( message ), } ); diff --git a/html-api-debugger/readme.txt b/html-api-debugger/readme.txt index 1f6b528..2123063 100644 --- a/html-api-debugger/readme.txt +++ b/html-api-debugger/readme.txt @@ -3,7 +3,7 @@ Contributors: jonsurrell, bernhard-reiter Tags: HTML API, development, debug Requires at least: 6.7 Tested up to: 6.8 -Stable tag: 3.2 +Stable tag: 3.3 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -15,6 +15,9 @@ Please file issues and pull requests on the [GitHub repository](https://github.c == Changelog == += 3.3 = +* Normalize URLs through an explicit browser History receiver. + = 3.2 = * Normalize bare empty canonical URL fields instead of leaving a permanent warning. diff --git a/html-api-debugger/response-transport.mjs b/html-api-debugger/response-transport.mjs index 86b22d2..04ecba3 100644 --- a/html-api-debugger/response-transport.mjs +++ b/html-api-debugger/response-transport.mjs @@ -1,5 +1,5 @@ // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as ByteTransportLive from './byte-transport.mjs?ver=3.2'; +import * as ByteTransportLive from './byte-transport.mjs?ver=3.3'; const { decodeBase64url, isByteEnvelope, projectResponseStrings } = /** @type {typeof import('./byte-transport.mjs')} */ ( ByteTransportLive ); diff --git a/html-api-debugger/runtime-controller.mjs b/html-api-debugger/runtime-controller.mjs index 2af3aac..8b9638f 100644 --- a/html-api-debugger/runtime-controller.mjs +++ b/html-api-debugger/runtime-controller.mjs @@ -1,11 +1,11 @@ // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as CanonicalUrlLive from './canonical-url.mjs?ver=3.2'; +import * as CanonicalUrlLive from './canonical-url.mjs?ver=3.3'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as BytePreviewLive from './byte-preview.mjs?ver=3.2'; +import * as BytePreviewLive from './byte-preview.mjs?ver=3.3'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as ByteTransportLive from './byte-transport.mjs?ver=3.2'; +import * as ByteTransportLive from './byte-transport.mjs?ver=3.3'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as ResponseTransportLive from './response-transport.mjs?ver=3.2'; +import * as ResponseTransportLive from './response-transport.mjs?ver=3.3'; const { canonicalUrlPath, parseCanonicalUrl, serializeCanonicalUrl } = /** @type {typeof import('./canonical-url.mjs')} */ ( CanonicalUrlLive ); diff --git a/tests/canonical-url-browser.html b/tests/canonical-url-browser.html index c46d033..f90908b 100644 --- a/tests/canonical-url-browser.html +++ b/tests/canonical-url-browser.html @@ -7,6 +7,7 @@ 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( @@ -35,6 +36,35 @@ 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( diff --git a/tests/main-wiring.mjs b/tests/main-wiring.mjs index dd4d433..1014ef5 100644 --- a/tests/main-wiring.mjs +++ b/tests/main-wiring.mjs @@ -27,23 +27,23 @@ const graph = { }; assert.deepEqual( runtimeRelativeImports( graph[ 'main.mjs' ] ), [ - './byte-preview.mjs?ver=3.2', - './byte-transport.mjs?ver=3.2', - './runtime-controller.mjs?ver=3.2', - './runtime-wiring.mjs?ver=3.2', - './ui-transactions.mjs?ver=3.2', + './byte-preview.mjs?ver=3.3', + './byte-transport.mjs?ver=3.3', + './runtime-controller.mjs?ver=3.3', + './runtime-wiring.mjs?ver=3.3', + './ui-transactions.mjs?ver=3.3', ] ); assert.deepEqual( runtimeRelativeImports( graph[ 'runtime-controller.mjs' ] ), [ - './canonical-url.mjs?ver=3.2', - './byte-preview.mjs?ver=3.2', - './byte-transport.mjs?ver=3.2', - './response-transport.mjs?ver=3.2', + './canonical-url.mjs?ver=3.3', + './byte-preview.mjs?ver=3.3', + './byte-transport.mjs?ver=3.3', + './response-transport.mjs?ver=3.3', ] ); assert.deepEqual( runtimeRelativeImports( graph[ 'canonical-url.mjs' ] ), [ - './byte-transport.mjs?ver=3.2', + './byte-transport.mjs?ver=3.3', ] ); assert.deepEqual( runtimeRelativeImports( graph[ 'response-transport.mjs' ] ), [ - './byte-transport.mjs?ver=3.2', + './byte-transport.mjs?ver=3.3', ] ); assert.deepEqual( runtimeRelativeImports( graph[ 'runtime-wiring.mjs' ] ), [] ); assert.deepEqual( runtimeRelativeImports( graph[ 'ui-transactions.mjs' ] ), [] ); @@ -52,7 +52,7 @@ 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\.2$/u, `${ file } has an unversioned live relative import` ); + assert.match( specifier, /\?ver=3\.3$/u, `${ file } has an unversioned live relative import` ); } } @@ -60,6 +60,10 @@ 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 ); diff --git a/tests/plugin-cutover-regression.php b/tests/plugin-cutover-regression.php index 0f80c41..c51b733 100644 --- a/tests/plugin-cutover-regression.php +++ b/tests/plugin-cutover-regression.php @@ -168,7 +168,7 @@ function html_api_debugger_cutover_assert_same( string $label, $expected, $actua require dirname( __DIR__ ) . '/html-api-debugger/html-api-debugger.php'; -html_api_debugger_cutover_assert_same( 'plugin version constant is cache-busted', '3.2', HTML_API_Debugger\VERSION ); +html_api_debugger_cutover_assert_same( 'plugin version constant is cache-busted', '3.3', HTML_API_Debugger\VERSION ); do_action( 'init' ); @@ -191,7 +191,7 @@ function html_api_debugger_cutover_assert_same( string $label, $expected, $actua ); foreach ( $test_modules as $id => $module ) { - html_api_debugger_cutover_assert_same( "module {$id} uses version 3.2", '3.2', $module['version'] ); + html_api_debugger_cutover_assert_same( "module {$id} uses version 3.3", '3.3', $module['version'] ); } html_api_debugger_cutover_assert_same( 'main module is registered', @@ -200,7 +200,7 @@ function html_api_debugger_cutover_assert_same( string $label, $expected, $actua ); do_action( 'admin_enqueue_scripts', 'toplevel_page_html-api-debugger' ); -html_api_debugger_cutover_assert_same( 'debugger stylesheet uses version 3.2', '3.2', $test_styles['html-api-debugger']['version'] ); +html_api_debugger_cutover_assert_same( 'debugger stylesheet uses version 3.3', '3.3', $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' ); From d6e5dfa3ce576a467fd23db8e365263d949dad62 Mon Sep 17 00:00:00 2001 From: Jon Surrell <sirreal@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:34:54 +0200 Subject: [PATCH 16/16] Call browser timers correctly --- html-api-debugger/canonical-url.mjs | 2 +- html-api-debugger/html-api-debugger.php | 4 +- html-api-debugger/main.mjs | 10 ++-- html-api-debugger/readme.txt | 5 +- html-api-debugger/response-transport.mjs | 2 +- html-api-debugger/runtime-controller.mjs | 8 +-- html-api-debugger/runtime-wiring.mjs | 7 ++- tests/main-wiring.mjs | 24 ++++---- tests/plugin-cutover-regression.php | 6 +- tests/runtime-wiring-browser.html | 72 ++++++++++++++++++++++++ tests/runtime-wiring.mjs | 37 ++++++++++++ 11 files changed, 146 insertions(+), 31 deletions(-) create mode 100644 tests/runtime-wiring-browser.html diff --git a/html-api-debugger/canonical-url.mjs b/html-api-debugger/canonical-url.mjs index 25cad58..1dda0fa 100644 --- a/html-api-debugger/canonical-url.mjs +++ b/html-api-debugger/canonical-url.mjs @@ -1,5 +1,5 @@ // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as ByteTransportLive from './byte-transport.mjs?ver=3.3'; +import * as ByteTransportLive from './byte-transport.mjs?ver=3.4'; const { decodeBase64url, decodeUtf8, encodeBase64url } = /** @type {typeof import('./byte-transport.mjs')} */ ( ByteTransportLive ); diff --git a/html-api-debugger/html-api-debugger.php b/html-api-debugger/html-api-debugger.php index 4bbc383..ba5508d 100644 --- a/html-api-debugger/html-api-debugger.php +++ b/html-api-debugger/html-api-debugger.php @@ -3,7 +3,7 @@ * Plugin Name: HTML API Debugger * Plugin URI: https://github.com/sirreal/html-api-debugger * Description: Add a page to wp-admin for debugging the HTML API. - * Version: 3.3 + * Version: 3.4 * Requires at least: 6.7 * Tested up to: 6.8 * Author: Jon Surrell @@ -24,7 +24,7 @@ require_once __DIR__ . '/rest-api.php'; const SLUG = 'html-api-debugger'; -const VERSION = '3.3'; +const VERSION = '3.4'; /** Set up the plugin. */ function init() { diff --git a/html-api-debugger/main.mjs b/html-api-debugger/main.mjs index 8804d0a..9fe1e28 100644 --- a/html-api-debugger/main.mjs +++ b/html-api-debugger/main.mjs @@ -6,15 +6,15 @@ import { replaceInvisible } from '@html-api-debugger/replace-invisible-chars'; import * as I from '@wordpress/interactivity'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as BytePreviewLive from './byte-preview.mjs?ver=3.3'; +import * as BytePreviewLive from './byte-preview.mjs?ver=3.4'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as ByteTransportLive from './byte-transport.mjs?ver=3.3'; +import * as ByteTransportLive from './byte-transport.mjs?ver=3.4'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as RuntimeControllerLive from './runtime-controller.mjs?ver=3.3'; +import * as RuntimeControllerLive from './runtime-controller.mjs?ver=3.4'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as RuntimeWiringLive from './runtime-wiring.mjs?ver=3.3'; +import * as RuntimeWiringLive from './runtime-wiring.mjs?ver=3.4'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as UiTransactionsLive from './ui-transactions.mjs?ver=3.3'; +import * as UiTransactionsLive from './ui-transactions.mjs?ver=3.4'; const { ByteDocumentPreview, resolveFragmentTarget } = /** @type {typeof import('./byte-preview.mjs')} */ ( BytePreviewLive ); diff --git a/html-api-debugger/readme.txt b/html-api-debugger/readme.txt index 2123063..417d97b 100644 --- a/html-api-debugger/readme.txt +++ b/html-api-debugger/readme.txt @@ -3,7 +3,7 @@ Contributors: jonsurrell, bernhard-reiter Tags: HTML API, development, debug Requires at least: 6.7 Tested up to: 6.8 -Stable tag: 3.3 +Stable tag: 3.4 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -15,6 +15,9 @@ Please file issues and pull requests on the [GitHub repository](https://github.c == Changelog == += 3.4 = +* Call browser timers with their required global receiver. + = 3.3 = * Normalize URLs through an explicit browser History receiver. diff --git a/html-api-debugger/response-transport.mjs b/html-api-debugger/response-transport.mjs index 04ecba3..e619874 100644 --- a/html-api-debugger/response-transport.mjs +++ b/html-api-debugger/response-transport.mjs @@ -1,5 +1,5 @@ // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as ByteTransportLive from './byte-transport.mjs?ver=3.3'; +import * as ByteTransportLive from './byte-transport.mjs?ver=3.4'; const { decodeBase64url, isByteEnvelope, projectResponseStrings } = /** @type {typeof import('./byte-transport.mjs')} */ ( ByteTransportLive ); diff --git a/html-api-debugger/runtime-controller.mjs b/html-api-debugger/runtime-controller.mjs index 8b9638f..f3e1afb 100644 --- a/html-api-debugger/runtime-controller.mjs +++ b/html-api-debugger/runtime-controller.mjs @@ -1,11 +1,11 @@ // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as CanonicalUrlLive from './canonical-url.mjs?ver=3.3'; +import * as CanonicalUrlLive from './canonical-url.mjs?ver=3.4'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as BytePreviewLive from './byte-preview.mjs?ver=3.3'; +import * as BytePreviewLive from './byte-preview.mjs?ver=3.4'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as ByteTransportLive from './byte-transport.mjs?ver=3.3'; +import * as ByteTransportLive from './byte-transport.mjs?ver=3.4'; // @ts-expect-error TypeScript does not resolve browser URL query strings. -import * as ResponseTransportLive from './response-transport.mjs?ver=3.3'; +import * as ResponseTransportLive from './response-transport.mjs?ver=3.4'; const { canonicalUrlPath, parseCanonicalUrl, serializeCanonicalUrl } = /** @type {typeof import('./canonical-url.mjs')} */ ( CanonicalUrlLive ); diff --git a/html-api-debugger/runtime-wiring.mjs b/html-api-debugger/runtime-wiring.mjs index 0a1e826..c1ab518 100644 --- a/html-api-debugger/runtime-wiring.mjs +++ b/html-api-debugger/runtime-wiring.mjs @@ -49,10 +49,13 @@ export class ByteRequestBoundary { this.#nonce = options.nonce; this.#fetch = options.fetch; this.#AbortController = options.AbortController; - this.#setTimer = options.setTimer ?? setTimeout; + this.#setTimer = + options.setTimer ?? + ( ( callback, delay ) => globalThis.setTimeout( callback, delay ) ); this.#clearTimer = options.clearTimer ?? - ( ( timer ) => clearTimeout( /** @type {number} */ ( timer ) ) ); + ( ( timer ) => + globalThis.clearTimeout( /** @type {number} */ ( timer ) ) ); this.#delay = options.delay ?? 150; if ( ! Number.isInteger( this.#delay ) || this.#delay < 0 ) { diff --git a/tests/main-wiring.mjs b/tests/main-wiring.mjs index 1014ef5..f8be177 100644 --- a/tests/main-wiring.mjs +++ b/tests/main-wiring.mjs @@ -27,23 +27,23 @@ const graph = { }; assert.deepEqual( runtimeRelativeImports( graph[ 'main.mjs' ] ), [ - './byte-preview.mjs?ver=3.3', - './byte-transport.mjs?ver=3.3', - './runtime-controller.mjs?ver=3.3', - './runtime-wiring.mjs?ver=3.3', - './ui-transactions.mjs?ver=3.3', + './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.3', - './byte-preview.mjs?ver=3.3', - './byte-transport.mjs?ver=3.3', - './response-transport.mjs?ver=3.3', + './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.3', + './byte-transport.mjs?ver=3.4', ] ); assert.deepEqual( runtimeRelativeImports( graph[ 'response-transport.mjs' ] ), [ - './byte-transport.mjs?ver=3.3', + './byte-transport.mjs?ver=3.4', ] ); assert.deepEqual( runtimeRelativeImports( graph[ 'runtime-wiring.mjs' ] ), [] ); assert.deepEqual( runtimeRelativeImports( graph[ 'ui-transactions.mjs' ] ), [] ); @@ -52,7 +52,7 @@ 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\.3$/u, `${ file } has an unversioned live relative import` ); + assert.match( specifier, /\?ver=3\.4$/u, `${ file } has an unversioned live relative import` ); } } diff --git a/tests/plugin-cutover-regression.php b/tests/plugin-cutover-regression.php index c51b733..507d82f 100644 --- a/tests/plugin-cutover-regression.php +++ b/tests/plugin-cutover-regression.php @@ -168,7 +168,7 @@ function html_api_debugger_cutover_assert_same( string $label, $expected, $actua require dirname( __DIR__ ) . '/html-api-debugger/html-api-debugger.php'; -html_api_debugger_cutover_assert_same( 'plugin version constant is cache-busted', '3.3', HTML_API_Debugger\VERSION ); +html_api_debugger_cutover_assert_same( 'plugin version constant is cache-busted', '3.4', HTML_API_Debugger\VERSION ); do_action( 'init' ); @@ -191,7 +191,7 @@ function html_api_debugger_cutover_assert_same( string $label, $expected, $actua ); foreach ( $test_modules as $id => $module ) { - html_api_debugger_cutover_assert_same( "module {$id} uses version 3.3", '3.3', $module['version'] ); + 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', @@ -200,7 +200,7 @@ function html_api_debugger_cutover_assert_same( string $label, $expected, $actua ); do_action( 'admin_enqueue_scripts', 'toplevel_page_html-api-debugger' ); -html_api_debugger_cutover_assert_same( 'debugger stylesheet uses version 3.3', '3.3', $test_styles['html-api-debugger']['version'] ); +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' ); 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 index 19ff243..b3409c0 100644 --- a/tests/runtime-wiring.mjs +++ b/tests/runtime-wiring.mjs @@ -163,6 +163,43 @@ await assert.rejects( 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();