`, and verifies the binary's
+reported version. Set `HTML_API_FUZZ_CHROME_INSTALL_ROOT` to use another
+install root. `--chrome-executable` may select another path, but the script
+rejects binaries whose version differs from `VERSION`.
+
+For persistent operation, use newline-delimited JSON over standard input:
+
+```sh
+node tools/html-api-fuzz/oracles/chrome/chrome-tree-oracle.js --serve
+```
+
+Or keep one Chrome process alive across multiple clients and PHP workers with
+a Unix-domain socket:
+
+```sh
+node tools/html-api-fuzz/oracles/chrome/chrome-tree-oracle.js \
+ --serve --socket /tmp/html-api-fuzz-chrome.sock
+```
+
+The accepted commands are `render`, `version`, and `shutdown`. Full documents
+are navigated as `data:text/html;charset=utf-8;base64,...` while author script
+execution is disabled. HTTP(S), FTP, file, and WebSocket loads are blocked.
+Fragments are parsed with `Range.createContextualFragment()` using an HTML,
+SVG, or MathML context element as appropriate. Successful renders return
+`status`, `oracle`, `tree`, `treeBase64`, and `nodeCount`.
+
+Run the end-to-end smoke test after installation:
+
+```sh
+node tools/html-api-fuzz/oracles/chrome/smoke-test.js
+```
+
+It checks canonical formatting, full-document data-URL parsing, author-script
+suppression, network blocking, SVG/table contextual fragments, concurrent
+socket clients, Chrome-process reuse, and graceful shutdown/socket cleanup.
diff --git a/tools/html-api-fuzz/oracles/chrome/VERSION b/tools/html-api-fuzz/oracles/chrome/VERSION
new file mode 100644
index 0000000000000..5383551615946
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/chrome/VERSION
@@ -0,0 +1 @@
+150.0.7871.114
diff --git a/tools/html-api-fuzz/oracles/chrome/chrome-tree-oracle.js b/tools/html-api-fuzz/oracles/chrome/chrome-tree-oracle.js
new file mode 100755
index 0000000000000..21a4b8322fa19
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/chrome/chrome-tree-oracle.js
@@ -0,0 +1,1015 @@
+#!/usr/bin/env node
+'use strict';
+
+const crypto = require( 'node:crypto' );
+const fs = require( 'node:fs' );
+const net = require( 'node:net' );
+const os = require( 'node:os' );
+const path = require( 'node:path' );
+const readline = require( 'node:readline' );
+const { spawn, spawnSync } = require( 'node:child_process' );
+
+const SCRIPT_DIR = __dirname;
+const PINNED_CHROME_VERSION = fs.readFileSync( path.join( SCRIPT_DIR, 'VERSION' ), 'utf8' ).trim();
+const HTML_NS = 'http://www.w3.org/1999/xhtml';
+const SVG_NS = 'http://www.w3.org/2000/svg';
+const MATH_NS = 'http://www.w3.org/1998/Math/MathML';
+
+function parseArgs( argv ) {
+ const options = { engine: 'chrome' };
+ for ( let i = 2; i < argv.length; i++ ) {
+ const arg = argv[ i ];
+ if ( ! arg.startsWith( '--' ) ) {
+ throw new Error( `Unexpected argument: ${ arg }` );
+ }
+ const name = arg.slice( 2 );
+ if ( [ 'help', 'serve', 'version' ].includes( name ) ) {
+ options[ name ] = true;
+ continue;
+ }
+ if ( i + 1 >= argv.length ) {
+ throw new Error( `Missing value for --${ name }.` );
+ }
+ options[ name ] = argv[ ++i ];
+ }
+ if ( 'chrome' !== options.engine ) {
+ throw new Error( `Expected --engine chrome; got ${ options.engine }.` );
+ }
+ if ( options.socket && ! options.serve ) {
+ throw new Error( '--socket requires --serve.' );
+ }
+ return options;
+}
+
+function localChromeExecutable() {
+ let platform;
+ let archiveDirectory;
+ let executableRelative;
+ if ( 'darwin' === process.platform && 'arm64' === process.arch ) {
+ platform = 'mac-arm64';
+ archiveDirectory = 'chrome-mac-arm64';
+ executableRelative = 'Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing';
+ } else if ( 'darwin' === process.platform && 'x64' === process.arch ) {
+ platform = 'mac-x64';
+ archiveDirectory = 'chrome-mac-x64';
+ executableRelative = 'Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing';
+ } else if ( 'linux' === process.platform && 'x64' === process.arch ) {
+ platform = 'linux64';
+ archiveDirectory = 'chrome-linux64';
+ executableRelative = 'chrome';
+ } else {
+ return null;
+ }
+ const installRoot = process.env.HTML_API_FUZZ_CHROME_INSTALL_ROOT || path.join( SCRIPT_DIR, '.chrome-for-testing' );
+ return path.join( installRoot, PINNED_CHROME_VERSION, platform, archiveDirectory, executableRelative );
+}
+
+function chromeExecutable( options ) {
+ const configured = options[ 'chrome-executable' ] || process.env.HTML_API_FUZZ_CHROME_EXECUTABLE;
+ return configured ? path.resolve( configured ) : localChromeExecutable();
+}
+
+function readInstalledVersion( executable ) {
+ if ( ! executable || ! fs.existsSync( executable ) ) {
+ return null;
+ }
+ const checked = spawnSync( executable, [ '--version' ], { encoding: 'utf8', timeout: 5000 } );
+ if ( checked.error || 0 !== checked.status ) {
+ return null;
+ }
+ const match = `${ checked.stdout }${ checked.stderr }`.match( /([0-9]+(?:\.[0-9]+){3})/ );
+ return match ? match[ 1 ] : null;
+}
+
+function configuredMetadata( options ) {
+ const executable = chromeExecutable( options );
+ const installedVersion = readInstalledVersion( executable );
+ const versionMatchesPin = installedVersion === PINNED_CHROME_VERSION;
+ const metadata = {
+ kind: 'chrome-cdp',
+ engine: 'chrome',
+ available: Boolean( executable && versionMatchesPin ),
+ pinnedChromeVersion: PINNED_CHROME_VERSION,
+ chromeExecutable: executable,
+ nodeVersion: process.version,
+ script: __filename,
+ cdpTransport: 'remote-debugging-websocket',
+ };
+ if ( installedVersion ) {
+ metadata.chromeVersion = installedVersion;
+ metadata.browserVersion = installedVersion;
+ }
+ if ( installedVersion && ! versionMatchesPin ) {
+ metadata.error = `Chrome version ${ installedVersion } does not match the required pin ${ PINNED_CHROME_VERSION } at ${ executable }.`;
+ } else if ( ! executable ) {
+ metadata.error = `Chrome for Testing is not available for ${ process.platform }/${ process.arch }.`;
+ } else if ( ! fs.existsSync( executable ) ) {
+ metadata.error = `Pinned Chrome for Testing is not installed at ${ executable }. Run ${ path.join( SCRIPT_DIR, 'install.sh' ) }.`;
+ } else if ( ! installedVersion ) {
+ metadata.error = `Could not execute Chrome for Testing at ${ executable }.`;
+ }
+ return metadata;
+}
+
+class CdpWebSocket {
+ constructor( socket, initialData = Buffer.alloc( 0 ) ) {
+ this.socket = socket;
+ this.buffer = Buffer.alloc( 0 );
+ this.fragmentOpcode = null;
+ this.fragments = [];
+ this.messageHandler = () => {};
+ this.closeHandler = () => {};
+ this.closed = false;
+ socket.on( 'data', ( data ) => this.consume( data ) );
+ socket.on( 'close', () => this.handleClose( new Error( 'CDP WebSocket closed.' ) ) );
+ socket.on( 'error', ( error ) => this.handleClose( error ) );
+ if ( initialData.length ) {
+ this.consume( initialData );
+ }
+ }
+
+ onMessage( handler ) {
+ this.messageHandler = handler;
+ }
+
+ onClose( handler ) {
+ this.closeHandler = handler;
+ }
+
+ handleClose( error ) {
+ if ( this.closed ) {
+ return;
+ }
+ this.closed = true;
+ this.closeHandler( error );
+ }
+
+ consume( data ) {
+ this.buffer = Buffer.concat( [ this.buffer, data ] );
+ while ( this.buffer.length >= 2 ) {
+ const first = this.buffer[ 0 ];
+ const second = this.buffer[ 1 ];
+ const final = Boolean( first & 0x80 );
+ const opcode = first & 0x0f;
+ const masked = Boolean( second & 0x80 );
+ let length = second & 0x7f;
+ let offset = 2;
+ if ( 126 === length ) {
+ if ( this.buffer.length < 4 ) {
+ return;
+ }
+ length = this.buffer.readUInt16BE( 2 );
+ offset = 4;
+ } else if ( 127 === length ) {
+ if ( this.buffer.length < 10 ) {
+ return;
+ }
+ const longLength = this.buffer.readBigUInt64BE( 2 );
+ if ( longLength > BigInt( Number.MAX_SAFE_INTEGER ) ) {
+ this.handleClose( new Error( 'CDP WebSocket frame is too large.' ) );
+ this.socket.destroy();
+ return;
+ }
+ length = Number( longLength );
+ offset = 10;
+ }
+ const maskBytes = masked ? 4 : 0;
+ if ( this.buffer.length < offset + maskBytes + length ) {
+ return;
+ }
+ let payload = Buffer.from( this.buffer.subarray( offset + maskBytes, offset + maskBytes + length ) );
+ if ( masked ) {
+ const mask = this.buffer.subarray( offset, offset + 4 );
+ for ( let i = 0; i < payload.length; i++ ) {
+ payload[ i ] ^= mask[ i % 4 ];
+ }
+ }
+ this.buffer = this.buffer.subarray( offset + maskBytes + length );
+ if ( 0x8 === opcode ) {
+ this.sendFrame( 0x8, payload );
+ this.socket.end();
+ return;
+ }
+ if ( 0x9 === opcode ) {
+ this.sendFrame( 0xA, payload );
+ continue;
+ }
+ if ( 0xA === opcode ) {
+ continue;
+ }
+ if ( 0x1 === opcode || 0x2 === opcode ) {
+ this.fragmentOpcode = opcode;
+ this.fragments = [ payload ];
+ } else if ( 0x0 === opcode && null !== this.fragmentOpcode ) {
+ this.fragments.push( payload );
+ } else {
+ continue;
+ }
+ if ( final ) {
+ const message = Buffer.concat( this.fragments );
+ const messageOpcode = this.fragmentOpcode;
+ this.fragmentOpcode = null;
+ this.fragments = [];
+ if ( 0x1 === messageOpcode ) {
+ this.messageHandler( message.toString( 'utf8' ) );
+ }
+ }
+ }
+ }
+
+ sendFrame( opcode, payload ) {
+ if ( this.closed ) {
+ throw new Error( 'Cannot write to a closed CDP WebSocket.' );
+ }
+ payload = Buffer.isBuffer( payload ) ? payload : Buffer.from( payload );
+ let header;
+ if ( payload.length < 126 ) {
+ header = Buffer.alloc( 2 );
+ header[ 1 ] = 0x80 | payload.length;
+ } else if ( payload.length <= 0xffff ) {
+ header = Buffer.alloc( 4 );
+ header[ 1 ] = 0x80 | 126;
+ header.writeUInt16BE( payload.length, 2 );
+ } else {
+ header = Buffer.alloc( 10 );
+ header[ 1 ] = 0x80 | 127;
+ header.writeBigUInt64BE( BigInt( payload.length ), 2 );
+ }
+ header[ 0 ] = 0x80 | opcode;
+ const mask = crypto.randomBytes( 4 );
+ const masked = Buffer.allocUnsafe( payload.length );
+ for ( let i = 0; i < payload.length; i++ ) {
+ masked[ i ] = payload[ i ] ^ mask[ i % 4 ];
+ }
+ this.socket.write( Buffer.concat( [ header, mask, masked ] ) );
+ }
+
+ sendJson( value ) {
+ this.sendFrame( 0x1, Buffer.from( JSON.stringify( value ), 'utf8' ) );
+ }
+
+ close() {
+ if ( ! this.closed ) {
+ this.sendFrame( 0x8, Buffer.alloc( 0 ) );
+ this.socket.end();
+ }
+ }
+}
+
+function connectWebSocket( endpoint ) {
+ return new Promise( ( resolve, reject ) => {
+ const url = new URL( endpoint );
+ if ( 'ws:' !== url.protocol ) {
+ reject( new Error( `Unsupported CDP WebSocket URL: ${ endpoint }` ) );
+ return;
+ }
+ const key = crypto.randomBytes( 16 ).toString( 'base64' );
+ const expectedAccept = crypto.createHash( 'sha1' )
+ .update( `${ key }258EAFA5-E914-47DA-95CA-C5AB0DC85B11` )
+ .digest( 'base64' );
+ const socket = net.createConnection( { host: url.hostname, port: Number( url.port ) } );
+ let headers = Buffer.alloc( 0 );
+ let settled = false;
+ const fail = ( error ) => {
+ if ( ! settled ) {
+ settled = true;
+ socket.destroy();
+ reject( error );
+ }
+ };
+ socket.once( 'error', fail );
+ socket.once( 'connect', () => {
+ const target = `${ url.pathname }${ url.search }`;
+ socket.write(
+ `GET ${ target } HTTP/1.1\r\n` +
+ `Host: ${ url.host }\r\n` +
+ 'Upgrade: websocket\r\n' +
+ 'Connection: Upgrade\r\n' +
+ `Sec-WebSocket-Key: ${ key }\r\n` +
+ 'Sec-WebSocket-Version: 13\r\n\r\n'
+ );
+ } );
+ const onData = ( data ) => {
+ headers = Buffer.concat( [ headers, data ] );
+ const boundary = headers.indexOf( '\r\n\r\n' );
+ if ( -1 === boundary ) {
+ return;
+ }
+ const headerText = headers.subarray( 0, boundary ).toString( 'latin1' );
+ const remainder = headers.subarray( boundary + 4 );
+ if ( ! /^HTTP\/1\.1 101\b/.test( headerText ) ) {
+ fail( new Error( `CDP WebSocket handshake failed: ${ headerText.split( '\r\n' )[ 0 ] }` ) );
+ return;
+ }
+ const acceptMatch = headerText.match( /^Sec-WebSocket-Accept:\s*(.+)$/im );
+ if ( ! acceptMatch || acceptMatch[ 1 ].trim() !== expectedAccept ) {
+ fail( new Error( 'CDP WebSocket handshake returned an invalid accept key.' ) );
+ return;
+ }
+ settled = true;
+ socket.removeListener( 'data', onData );
+ socket.removeListener( 'error', fail );
+ resolve( new CdpWebSocket( socket, remainder ) );
+ };
+ socket.on( 'data', onData );
+ } );
+}
+
+class CdpClient {
+ constructor( websocket ) {
+ this.websocket = websocket;
+ this.nextId = 0;
+ this.pending = new Map();
+ this.waiters = [];
+ websocket.onMessage( ( message ) => this.receive( message ) );
+ websocket.onClose( ( error ) => this.failAll( error ) );
+ }
+
+ receive( message ) {
+ let decoded;
+ try {
+ decoded = JSON.parse( message );
+ } catch ( error ) {
+ this.failAll( new Error( `Chrome returned invalid CDP JSON: ${ error.message }` ) );
+ return;
+ }
+ if ( decoded.id && this.pending.has( decoded.id ) ) {
+ const pending = this.pending.get( decoded.id );
+ this.pending.delete( decoded.id );
+ clearTimeout( pending.timer );
+ if ( decoded.error ) {
+ const error = new Error( `CDP ${ pending.method } failed: ${ decoded.error.message }` );
+ error.code = decoded.error.code;
+ pending.reject( error );
+ } else {
+ pending.resolve( decoded.result || {} );
+ }
+ return;
+ }
+ if ( decoded.method ) {
+ for ( const waiter of [ ...this.waiters ] ) {
+ if ( waiter.method === decoded.method && ( ! waiter.sessionId || waiter.sessionId === decoded.sessionId ) ) {
+ this.waiters.splice( this.waiters.indexOf( waiter ), 1 );
+ clearTimeout( waiter.timer );
+ waiter.resolve( decoded.params || {} );
+ }
+ }
+ }
+ }
+
+ send( method, params = {}, sessionId = undefined, timeoutMs = 15000 ) {
+ const id = ++this.nextId;
+ return new Promise( ( resolve, reject ) => {
+ const timer = setTimeout( () => {
+ this.pending.delete( id );
+ reject( new Error( `CDP ${ method } timed out.` ) );
+ }, timeoutMs );
+ this.pending.set( id, { method, resolve, reject, timer } );
+ const message = { id, method, params };
+ if ( sessionId ) {
+ message.sessionId = sessionId;
+ }
+ try {
+ this.websocket.sendJson( message );
+ } catch ( error ) {
+ clearTimeout( timer );
+ this.pending.delete( id );
+ reject( error );
+ }
+ } );
+ }
+
+ waitFor( method, sessionId, timeoutMs = 15000 ) {
+ return new Promise( ( resolve, reject ) => {
+ const waiter = { method, sessionId, resolve, reject, timer: null };
+ waiter.timer = setTimeout( () => {
+ this.waiters.splice( this.waiters.indexOf( waiter ), 1 );
+ reject( new Error( `CDP event ${ method } timed out.` ) );
+ }, timeoutMs );
+ this.waiters.push( waiter );
+ } );
+ }
+
+ failAll( error ) {
+ for ( const pending of this.pending.values() ) {
+ clearTimeout( pending.timer );
+ pending.reject( error );
+ }
+ this.pending.clear();
+ for ( const waiter of this.waiters ) {
+ clearTimeout( waiter.timer );
+ waiter.reject( error );
+ }
+ this.waiters = [];
+ }
+
+ close() {
+ this.websocket.close();
+ }
+}
+
+function browserRender( args ) {
+ const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';
+ const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
+ const MATH_NAMESPACE = 'http://www.w3.org/1998/Math/MathML';
+ const XLINK_NAMESPACE = 'http://www.w3.org/1999/xlink';
+ const XML_NAMESPACE = 'http://www.w3.org/XML/1998/namespace';
+ const XMLNS_NAMESPACE = 'http://www.w3.org/2000/xmlns/';
+ let nodeCount = 0;
+ const encoder = new TextEncoder();
+
+ function escapeScalar( value ) {
+ let output = '';
+ for ( const character of String( value ) ) {
+ switch ( character ) {
+ case '\n': output += '\\n'; break;
+ case '\r': output += '\\r'; break;
+ case '\t': output += '\\t'; break;
+ case '\0': output += '\\0'; break;
+ case '\\': output += '\\\\'; break;
+ case '"': output += '\\"'; break;
+ default: {
+ const code = character.codePointAt( 0 );
+ output += code < 0x20 || 0x7f === code
+ ? `\\x${ code.toString( 16 ).toUpperCase().padStart( 2, '0' ) }`
+ : character;
+ }
+ }
+ }
+ return output;
+ }
+
+ function compareUtf8( left, right ) {
+ const a = encoder.encode( left );
+ const b = encoder.encode( right );
+ const length = Math.min( a.length, b.length );
+ for ( let i = 0; i < length; i++ ) {
+ if ( a[ i ] !== b[ i ] ) {
+ return a[ i ] - b[ i ];
+ }
+ }
+ return a.length - b.length;
+ }
+
+ function compareDisplayNames( left, right ) {
+ const leftHasColon = left.includes( ':' );
+ const rightHasColon = right.includes( ':' );
+ if ( leftHasColon !== rightHasColon ) {
+ return leftHasColon ? 1 : -1;
+ }
+ const leftHasSpace = left.includes( ' ' );
+ const rightHasSpace = right.includes( ' ' );
+ if ( leftHasSpace !== rightHasSpace ) {
+ return leftHasSpace ? 1 : -1;
+ }
+ return compareUtf8( left, right );
+ }
+
+ function elementName( element ) {
+ if ( HTML_NAMESPACE === element.namespaceURI ) {
+ return element.localName.toLowerCase();
+ }
+ if ( SVG_NAMESPACE === element.namespaceURI ) {
+ return `svg ${ element.localName }`;
+ }
+ if ( MATH_NAMESPACE === element.namespaceURI ) {
+ return `math ${ element.localName }`;
+ }
+ return element.nodeName;
+ }
+
+ function attributeName( attribute ) {
+ if ( XLINK_NAMESPACE === attribute.namespaceURI ) {
+ return `xlink ${ attribute.localName }`;
+ }
+ if ( XML_NAMESPACE === attribute.namespaceURI ) {
+ return `xml ${ attribute.localName }`;
+ }
+ if ( XMLNS_NAMESPACE === attribute.namespaceURI ) {
+ return `xmlns ${ attribute.localName }`;
+ }
+ return attribute.name;
+ }
+
+ function renderAttributes( element, indent ) {
+ const records = Array.from( element.attributes, ( attribute ) => {
+ const displayName = attributeName( attribute );
+ return {
+ sortName: escapeScalar( displayName ),
+ renderName: escapeScalar( displayName ),
+ value: escapeScalar( attribute.value ),
+ };
+ } );
+ records.sort( ( left, right ) => {
+ const sorted = compareDisplayNames( left.sortName, right.sortName );
+ return 0 !== sorted ? sorted : compareDisplayNames( left.renderName, right.renderName );
+ } );
+ return records.map(
+ ( record ) => `${ ' '.repeat( indent ) }${ record.renderName }="${ record.value }"\n`
+ ).join( '' );
+ }
+
+ function renderNode( node, indent ) {
+ nodeCount++;
+ if ( nodeCount > args.maxNodes ) {
+ const error = new Error( 'DOM node limit exceeded.' );
+ error.failureClass = 'node-limit-exceeded';
+ throw error;
+ }
+ switch ( node.nodeType ) {
+ case Node.DOCUMENT_TYPE_NODE: {
+ let output = `\n`;
+ }
+ case Node.ELEMENT_NODE: {
+ let output = `${ ' '.repeat( indent ) }<${ escapeScalar( elementName( node ) ) }>\n`;
+ output += renderAttributes( node, indent + 1 );
+ if ( HTML_NAMESPACE === node.namespaceURI && 'template' === node.localName ) {
+ output += `${ ' '.repeat( indent + 1 ) }content\n`;
+ for ( const child of node.content.childNodes ) {
+ output += renderNode( child, indent + 2 );
+ }
+ return output;
+ }
+ for ( const child of node.childNodes ) {
+ output += renderNode( child, indent + 1 );
+ }
+ return output;
+ }
+ case Node.TEXT_NODE:
+ case Node.CDATA_SECTION_NODE:
+ return '' === node.nodeValue
+ ? ''
+ : `${ ' '.repeat( indent ) }"${ escapeScalar( node.nodeValue ) }"\n`;
+ case Node.COMMENT_NODE:
+ return `${ ' '.repeat( indent ) }\n`;
+ default:
+ return '';
+ }
+ }
+
+ let roots;
+ if ( 'full-document' === args.mode ) {
+ roots = document.childNodes;
+ } else {
+ const owner = document.implementation.createHTMLDocument( '' );
+ const lower = args.context.toLowerCase();
+ let contextElement;
+ if ( 'svg' === lower ) {
+ contextElement = owner.createElementNS( SVG_NAMESPACE, 'svg' );
+ } else if ( 'math' === lower ) {
+ contextElement = owner.createElementNS( MATH_NAMESPACE, 'math' );
+ } else {
+ contextElement = owner.createElementNS( HTML_NAMESPACE, lower );
+ }
+ const range = owner.createRange();
+ range.selectNodeContents( contextElement );
+ const fragment = range.createContextualFragment( args.html );
+ roots = fragment.childNodes;
+ }
+
+ let tree = '';
+ for ( const child of roots ) {
+ tree += renderNode( child, 0 );
+ }
+ return { tree: `${ tree }\n`, nodeCount };
+}
+
+class ChromeOracle {
+ constructor( options ) {
+ this.options = options;
+ this.metadata = configuredMetadata( options );
+ this.chrome = null;
+ this.userDataDirectory = null;
+ this.websocket = null;
+ this.cdp = null;
+ this.targetId = null;
+ this.sessionId = null;
+ this.startPromise = null;
+ this.renderQueue = Promise.resolve();
+ this.closing = false;
+ }
+
+ configuredMetadata() {
+ return { ...this.metadata };
+ }
+
+ async start() {
+ if ( this.cdp && this.sessionId ) {
+ return;
+ }
+ if ( this.startPromise ) {
+ return this.startPromise;
+ }
+ this.startPromise = this.startChrome();
+ try {
+ await this.startPromise;
+ } finally {
+ this.startPromise = null;
+ }
+ }
+
+ async startChrome() {
+ if ( false === this.metadata.available ) {
+ const error = new Error( this.metadata.error || 'Pinned Chrome for Testing is unavailable.' );
+ error.failureClass = 'oracle-unavailable';
+ throw error;
+ }
+ this.userDataDirectory = fs.mkdtempSync( path.join( os.tmpdir(), 'html-api-fuzz-chrome-' ) );
+ const args = [
+ '--headless=new',
+ '--remote-debugging-port=0',
+ '--remote-allow-origins=*',
+ `--user-data-dir=${ this.userDataDirectory }`,
+ '--no-first-run',
+ '--no-default-browser-check',
+ '--disable-background-networking',
+ '--disable-component-update',
+ '--disable-domain-reliability',
+ '--disable-features=OptimizationHints,MediaRouter,Translate',
+ '--disable-sync',
+ '--metrics-recording-only',
+ '--mute-audio',
+ '--no-pings',
+ '--password-store=basic',
+ '--use-mock-keychain',
+ 'about:blank',
+ ];
+ if ( 'linux' === process.platform && 0 === process.getuid?.() ) {
+ args.unshift( '--no-sandbox' );
+ }
+ this.chrome = spawn( this.metadata.chromeExecutable, args, { stdio: [ 'ignore', 'ignore', 'pipe' ] } );
+ this.metadata.browserPid = this.chrome.pid;
+ this.metadata.browserInstanceId = crypto.randomUUID();
+ const endpoint = await new Promise( ( resolve, reject ) => {
+ let stderr = '';
+ let settled = false;
+ const timer = setTimeout( () => finish( new Error( `Chrome did not expose a CDP endpoint. ${ stderr.trim() }` ) ), 15000 );
+ const finish = ( error, value ) => {
+ if ( settled ) {
+ return;
+ }
+ settled = true;
+ clearTimeout( timer );
+ if ( error ) {
+ reject( error );
+ } else {
+ resolve( value );
+ }
+ };
+ this.chrome.once( 'error', finish );
+ this.chrome.once( 'exit', ( code, signal ) => finish( new Error( `Chrome exited before CDP startup (code ${ code }, signal ${ signal }). ${ stderr.trim() }` ) ) );
+ this.chrome.stderr.on( 'data', ( chunk ) => {
+ stderr = `${ stderr }${ chunk.toString( 'utf8' ) }`.slice( -16384 );
+ const match = stderr.match( /DevTools listening on (ws:\/\/[^\s]+)/ );
+ if ( match ) {
+ finish( null, match[ 1 ] );
+ }
+ } );
+ } );
+
+ this.websocket = await connectWebSocket( endpoint );
+ this.cdp = new CdpClient( this.websocket );
+ const browserVersion = await this.cdp.send( 'Browser.getVersion' );
+ this.metadata.available = true;
+ this.metadata.browserVersion = ( browserVersion.product || '' ).replace( /^[^/]+\//, '' ) || this.metadata.chromeVersion;
+ this.metadata.chromeVersion = this.metadata.browserVersion;
+ this.metadata.cdpProtocolVersion = browserVersion.protocolVersion;
+ const target = await this.cdp.send( 'Target.createTarget', { url: 'about:blank', background: true } );
+ this.targetId = target.targetId;
+ const attached = await this.cdp.send( 'Target.attachToTarget', { targetId: this.targetId, flatten: true } );
+ this.sessionId = attached.sessionId;
+ await this.cdp.send( 'Page.enable', {}, this.sessionId );
+ await this.cdp.send( 'Runtime.enable', {}, this.sessionId );
+ await this.cdp.send( 'Network.enable', {}, this.sessionId );
+ await this.cdp.send( 'Network.setCacheDisabled', { cacheDisabled: true }, this.sessionId );
+ await this.cdp.send( 'Network.setBypassServiceWorker', { bypass: true }, this.sessionId );
+ await this.cdp.send( 'Network.setBlockedURLs', {
+ urls: [ 'http://*', 'https://*', 'ftp://*', 'file://*', 'ws://*', 'wss://*' ],
+ }, this.sessionId );
+ await this.cdp.send( 'Browser.setDownloadBehavior', { behavior: 'deny' } );
+ }
+
+ async navigateWithScriptsDisabled( dataUrl ) {
+ await this.cdp.send( 'Emulation.setScriptExecutionDisabled', { value: true }, this.sessionId );
+ const loaded = this.cdp.waitFor( 'Page.loadEventFired', this.sessionId );
+ const navigation = await this.cdp.send( 'Page.navigate', { url: dataUrl }, this.sessionId );
+ if ( navigation.errorText ) {
+ throw new Error( `Chrome navigation failed: ${ navigation.errorText }` );
+ }
+ await loaded;
+ await this.cdp.send( 'Page.stopLoading', {}, this.sessionId );
+ }
+
+ render( request ) {
+ const work = this.renderQueue.then( () => this.renderNow( request ) );
+ this.renderQueue = work.catch( () => {} );
+ return work;
+ }
+
+ async renderNow( request ) {
+ await this.start();
+ const mode = request.mode || 'fragment-body';
+ if ( ! [ 'full-document', 'fragment-body' ].includes( mode ) ) {
+ throw new Error( `Unsupported parse mode: ${ mode }` );
+ }
+ const context = request.context || 'body';
+ const maxNodes = Number.parseInt( request.maxNodes || 3000, 10 );
+ if ( ! Number.isSafeInteger( maxNodes ) || maxNodes < 1 ) {
+ throw new Error( 'maxNodes must be a positive integer.' );
+ }
+ const htmlBuffer = Buffer.from( request.htmlBase64 || '', 'base64' );
+ const html = htmlBuffer.toString( 'utf8' );
+ if ( 'full-document' === mode ) {
+ await this.navigateWithScriptsDisabled( `data:text/html;charset=utf-8;base64,${ htmlBuffer.toString( 'base64' ) }` );
+ } else {
+ await this.navigateWithScriptsDisabled( 'data:text/html;charset=utf-8;base64,' );
+ }
+
+ let evaluated;
+ try {
+ await this.cdp.send( 'Emulation.setScriptExecutionDisabled', { value: false }, this.sessionId );
+ const expression = `(${ browserRender.toString() })(${ JSON.stringify( { html, mode, context, maxNodes } ) })`;
+ evaluated = await this.cdp.send( 'Runtime.evaluate', {
+ expression,
+ returnByValue: true,
+ awaitPromise: false,
+ userGesture: false,
+ }, this.sessionId );
+ } finally {
+ await this.cdp.send( 'Emulation.setScriptExecutionDisabled', { value: true }, this.sessionId ).catch( () => {} );
+ }
+ if ( evaluated.exceptionDetails ) {
+ const description = evaluated.exceptionDetails.exception?.description || evaluated.exceptionDetails.text || 'Chrome evaluation failed.';
+ const error = new Error( description );
+ if ( description.includes( 'DOM node limit exceeded.' ) ) {
+ error.failureClass = 'node-limit-exceeded';
+ }
+ throw error;
+ }
+ const rendered = evaluated.result?.value;
+ if ( ! rendered || 'string' !== typeof rendered.tree ) {
+ throw new Error( 'Chrome did not return a canonical tree.' );
+ }
+ return {
+ status: 'ok',
+ oracle: this.configuredMetadata(),
+ tree: rendered.tree,
+ treeBase64: Buffer.from( rendered.tree, 'utf8' ).toString( 'base64' ),
+ nodeCount: rendered.nodeCount,
+ };
+ }
+
+ async close() {
+ if ( this.closing ) {
+ return;
+ }
+ this.closing = true;
+ await this.renderQueue.catch( () => {} );
+ if ( this.cdp ) {
+ if ( this.targetId ) {
+ await this.cdp.send( 'Target.closeTarget', { targetId: this.targetId }, undefined, 500 ).catch( () => {} );
+ }
+ await this.cdp.send( 'Browser.close', {}, undefined, 1000 ).catch( () => {} );
+ }
+ if ( this.chrome ) {
+ const chrome = this.chrome;
+ await new Promise( ( resolve ) => {
+ if ( null !== chrome.exitCode || null !== chrome.signalCode ) {
+ resolve();
+ return;
+ }
+ const timers = [];
+ const finish = () => {
+ for ( const timer of timers ) {
+ clearTimeout( timer );
+ }
+ resolve();
+ };
+ chrome.once( 'exit', finish );
+ timers.push( setTimeout( () => chrome.kill( 'SIGTERM' ), 500 ) );
+ timers.push( setTimeout( () => chrome.kill( 'SIGKILL' ), 1000 ) );
+ timers.push( setTimeout( finish, 1250 ) );
+ } );
+ }
+ this.websocket?.close();
+ if ( this.userDataDirectory ) {
+ fs.rmSync( this.userDataDirectory, { recursive: true, force: true } );
+ }
+ this.chrome = null;
+ this.cdp = null;
+ this.websocket = null;
+ this.targetId = null;
+ this.sessionId = null;
+ this.userDataDirectory = null;
+ }
+}
+
+function errorResult( error, oracle, id = undefined ) {
+ const message = error.message || String( error );
+ const result = {
+ status: 'error',
+ failureClass: error.failureClass || ( message.includes( 'DOM node limit exceeded.' ) ? 'node-limit-exceeded' : 'oracle-renderer-error' ),
+ error: message,
+ oracle,
+ };
+ if ( undefined !== id ) {
+ result.id = id;
+ }
+ return result;
+}
+
+function writeResult( stream, result ) {
+ if ( false === stream.writable || stream.destroyed ) {
+ return;
+ }
+ try {
+ stream.write( `${ JSON.stringify( result ) }\n` );
+ } catch ( error ) {
+ if ( 'ERR_STREAM_DESTROYED' !== error.code && 'ERR_STREAM_WRITE_AFTER_END' !== error.code ) {
+ throw error;
+ }
+ }
+}
+
+async function dispatchRequest( oracle, request ) {
+ if ( 'version' === request.command ) {
+ await oracle.start();
+ return { status: 'ok', oracle: oracle.configuredMetadata() };
+ }
+ if ( 'shutdown' === request.command ) {
+ return { status: 'ok', oracle: oracle.configuredMetadata(), shutdown: true };
+ }
+ if ( request.command && 'render' !== request.command ) {
+ throw new Error( `Unknown command: ${ request.command }` );
+ }
+ return oracle.render( request );
+}
+
+function serveLines( input, output, oracle, shutdown ) {
+ const lines = readline.createInterface( { input, crlfDelay: Infinity } );
+ let queue = Promise.resolve();
+ lines.on( 'line', ( line ) => {
+ queue = queue.then( async () => {
+ let request;
+ try {
+ request = JSON.parse( line );
+ const result = await dispatchRequest( oracle, request );
+ if ( undefined !== request.id ) {
+ result.id = request.id;
+ }
+ writeResult( output, result );
+ if ( result.shutdown ) {
+ setImmediate( shutdown );
+ }
+ } catch ( error ) {
+ writeResult( output, errorResult( error, oracle.configuredMetadata(), request?.id ) );
+ }
+ } );
+ } );
+ return { lines, drained: () => queue };
+}
+
+async function runStdioServer( oracle ) {
+ let shuttingDown = false;
+ let served;
+ const shutdown = async () => {
+ if ( shuttingDown ) {
+ return;
+ }
+ shuttingDown = true;
+ served?.lines.close();
+ process.stdin.pause();
+ await oracle.close();
+ };
+ served = serveLines( process.stdin, process.stdout, oracle, shutdown );
+ await new Promise( ( resolve ) => served.lines.once( 'close', resolve ) );
+ await served.drained();
+ await shutdown();
+}
+
+async function runSocketServer( oracle, socketPath ) {
+ let shutdownPromise = null;
+ const connections = new Set();
+ const server = net.createServer( ( socket ) => {
+ connections.add( socket );
+ socket.on( 'error', () => {} );
+ socket.once( 'close', () => connections.delete( socket ) );
+ serveLines( socket, socket, oracle, shutdown );
+ } );
+ const shutdown = () => {
+ if ( shutdownPromise ) {
+ return shutdownPromise;
+ }
+ shutdownPromise = ( async () => {
+ const serverClosed = new Promise( ( resolve ) => server.once( 'close', resolve ) );
+ server.close();
+ for ( const socket of connections ) {
+ socket.end();
+ }
+ await serverClosed;
+ await oracle.close();
+ try {
+ fs.unlinkSync( socketPath );
+ } catch ( error ) {
+ if ( 'ENOENT' !== error.code ) {
+ throw error;
+ }
+ }
+ } )();
+ return shutdownPromise;
+ };
+ await new Promise( ( resolve, reject ) => {
+ server.once( 'error', reject );
+ server.listen( socketPath, resolve );
+ } );
+ process.stdout.write( `${ JSON.stringify( { status: 'ready', socket: socketPath, oracle: oracle.configuredMetadata() } ) }\n` );
+ await new Promise( ( resolve ) => server.once( 'close', resolve ) );
+ await shutdown();
+}
+
+function printUsage() {
+ process.stdout.write(
+ 'Usage: chrome-tree-oracle.js --engine chrome --mode full-document|fragment-body --input PATH [--context TAG] [--max-nodes N] [--chrome-executable PATH]\n' +
+ ' chrome-tree-oracle.js --serve [--socket PATH] [--engine chrome] [--chrome-executable PATH]\n' +
+ ' chrome-tree-oracle.js --version [--engine chrome] [--chrome-executable PATH]\n'
+ );
+}
+
+async function main() {
+ let options;
+ try {
+ options = parseArgs( process.argv );
+ } catch ( error ) {
+ writeResult( process.stdout, errorResult( error, { kind: 'chrome-cdp', engine: 'chrome', available: false } ) );
+ process.exitCode = 1;
+ return;
+ }
+ if ( options.help ) {
+ printUsage();
+ return;
+ }
+ const oracle = new ChromeOracle( options );
+ let stopping = false;
+ const stopForSignal = async () => {
+ if ( stopping ) {
+ return;
+ }
+ stopping = true;
+ await oracle.close().catch( () => {} );
+ if ( options.socket ) {
+ try {
+ fs.unlinkSync( path.resolve( options.socket ) );
+ } catch ( error ) {
+ if ( 'ENOENT' !== error.code ) {
+ process.stderr.write( `${ error.message }\n` );
+ }
+ }
+ }
+ process.exit( 0 );
+ };
+ process.once( 'SIGINT', stopForSignal );
+ process.once( 'SIGTERM', stopForSignal );
+
+ if ( options.serve ) {
+ if ( options.socket ) {
+ await runSocketServer( oracle, path.resolve( options.socket ) );
+ } else {
+ await runStdioServer( oracle );
+ }
+ return;
+ }
+ if ( options.version ) {
+ writeResult( process.stdout, { status: 'ok', oracle: oracle.configuredMetadata() } );
+ return;
+ }
+ if ( ! options.input || ! options.mode ) {
+ printUsage();
+ process.exitCode = 1;
+ return;
+ }
+ try {
+ const html = fs.readFileSync( options.input );
+ const result = await oracle.render( {
+ command: 'render',
+ htmlBase64: html.toString( 'base64' ),
+ mode: options.mode,
+ context: options.context || 'body',
+ maxNodes: Number.parseInt( options[ 'max-nodes' ] || 3000, 10 ),
+ } );
+ writeResult( process.stdout, result );
+ } catch ( error ) {
+ writeResult( process.stdout, errorResult( error, oracle.configuredMetadata() ) );
+ process.exitCode = 2;
+ } finally {
+ await oracle.close();
+ }
+}
+
+main().catch( ( error ) => {
+ writeResult( process.stdout, errorResult( error, { kind: 'chrome-cdp', engine: 'chrome', available: false } ) );
+ process.exitCode = 1;
+} );
diff --git a/tools/html-api-fuzz/oracles/chrome/install.sh b/tools/html-api-fuzz/oracles/chrome/install.sh
new file mode 100755
index 0000000000000..41f0575914c69
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/chrome/install.sh
@@ -0,0 +1,80 @@
+#!/bin/sh
+set -eu
+
+SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+VERSION=$(tr -d '[:space:]' < "$SCRIPT_DIR/VERSION")
+INSTALL_ROOT=${HTML_API_FUZZ_CHROME_INSTALL_ROOT:-"$SCRIPT_DIR/.chrome-for-testing"}
+
+case "$(uname -s):$(uname -m)" in
+ Darwin:arm64)
+ PLATFORM=mac-arm64
+ ARCHIVE_DIR=chrome-mac-arm64
+ EXECUTABLE_RELATIVE='Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'
+ ;;
+ Darwin:x86_64)
+ PLATFORM=mac-x64
+ ARCHIVE_DIR=chrome-mac-x64
+ EXECUTABLE_RELATIVE='Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'
+ ;;
+ Linux:x86_64|Linux:amd64)
+ PLATFORM=linux64
+ ARCHIVE_DIR=chrome-linux64
+ EXECUTABLE_RELATIVE=chrome
+ ;;
+ *)
+ echo "Unsupported Chrome for Testing platform: $(uname -s) $(uname -m)" >&2
+ exit 1
+ ;;
+esac
+
+DESTINATION="$INSTALL_ROOT/$VERSION/$PLATFORM"
+EXECUTABLE="$DESTINATION/$ARCHIVE_DIR/$EXECUTABLE_RELATIVE"
+
+if [ "${1:-}" = '--print-path' ]; then
+ printf '%s\n' "$EXECUTABLE"
+ exit 0
+fi
+
+if [ -x "$EXECUTABLE" ]; then
+ INSTALLED_VERSION=$("$EXECUTABLE" --version | sed -n 's/.* \([0-9][0-9.]*\)[[:space:]]*$/\1/p')
+ if [ "$INSTALLED_VERSION" = "$VERSION" ]; then
+ printf '%s\n' "$EXECUTABLE"
+ exit 0
+ fi
+ echo "Chrome at $EXECUTABLE is version $INSTALLED_VERSION; expected $VERSION." >&2
+ exit 1
+fi
+
+mkdir -p "$INSTALL_ROOT/.downloads" "$DESTINATION"
+ARCHIVE="$INSTALL_ROOT/.downloads/chrome-$VERSION-$PLATFORM.zip"
+URL="https://storage.googleapis.com/chrome-for-testing-public/$VERSION/$PLATFORM/chrome-$PLATFORM.zip"
+
+if [ ! -f "$ARCHIVE" ]; then
+ PARTIAL="$ARCHIVE.partial.$$"
+ trap 'rm -f "$PARTIAL"' EXIT HUP INT TERM
+ curl --fail --location --proto '=https' --tlsv1.2 --retry 3 --output "$PARTIAL" "$URL"
+ mv "$PARTIAL" "$ARCHIVE"
+ trap - EXIT HUP INT TERM
+fi
+
+TMP_DESTINATION="$DESTINATION.installing.$$"
+rm -rf "$TMP_DESTINATION"
+mkdir -p "$TMP_DESTINATION"
+trap 'rm -rf "$TMP_DESTINATION"' EXIT HUP INT TERM
+unzip -q "$ARCHIVE" -d "$TMP_DESTINATION"
+rm -rf "$DESTINATION"
+mv "$TMP_DESTINATION" "$DESTINATION"
+trap - EXIT HUP INT TERM
+
+if [ ! -x "$EXECUTABLE" ]; then
+ echo "Chrome archive did not contain the expected executable: $EXECUTABLE" >&2
+ exit 1
+fi
+
+INSTALLED_VERSION=$("$EXECUTABLE" --version | sed -n 's/.* \([0-9][0-9.]*\)[[:space:]]*$/\1/p')
+if [ "$INSTALLED_VERSION" != "$VERSION" ]; then
+ echo "Installed Chrome version $INSTALLED_VERSION does not match pin $VERSION." >&2
+ exit 1
+fi
+
+printf '%s\n' "$EXECUTABLE"
diff --git a/tools/html-api-fuzz/oracles/chrome/smoke-test.js b/tools/html-api-fuzz/oracles/chrome/smoke-test.js
new file mode 100755
index 0000000000000..40cc94f669430
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/chrome/smoke-test.js
@@ -0,0 +1,187 @@
+#!/usr/bin/env node
+'use strict';
+
+const fs = require( 'node:fs' );
+const http = require( 'node:http' );
+const net = require( 'node:net' );
+const os = require( 'node:os' );
+const path = require( 'node:path' );
+const { spawn, spawnSync } = require( 'node:child_process' );
+
+const SCRIPT = path.join( __dirname, 'chrome-tree-oracle.js' );
+
+function assert( condition, message ) {
+ if ( ! condition ) {
+ throw new Error( message );
+ }
+}
+
+function request( socketPath, payload ) {
+ return new Promise( ( resolve, reject ) => {
+ const socket = net.createConnection( socketPath );
+ let response = '';
+ const timer = setTimeout( () => {
+ socket.destroy();
+ reject( new Error( 'Timed out waiting for the Chrome oracle.' ) );
+ }, 20000 );
+ socket.once( 'error', ( error ) => {
+ clearTimeout( timer );
+ reject( error );
+ } );
+ socket.on( 'data', ( data ) => {
+ response += data.toString( 'utf8' );
+ const newline = response.indexOf( '\n' );
+ if ( -1 === newline ) {
+ return;
+ }
+ clearTimeout( timer );
+ socket.end();
+ try {
+ resolve( JSON.parse( response.slice( 0, newline ) ) );
+ } catch ( error ) {
+ reject( error );
+ }
+ } );
+ socket.once( 'connect', () => socket.write( `${ JSON.stringify( payload ) }\n` ) );
+ } );
+}
+
+function waitForReady( child ) {
+ return new Promise( ( resolve, reject ) => {
+ let stdout = '';
+ let stderr = '';
+ const timer = setTimeout( () => reject( new Error( `Oracle daemon did not become ready. ${ stderr }` ) ), 10000 );
+ child.stdout.on( 'data', ( data ) => {
+ stdout += data.toString( 'utf8' );
+ const newline = stdout.indexOf( '\n' );
+ if ( -1 !== newline ) {
+ clearTimeout( timer );
+ resolve( JSON.parse( stdout.slice( 0, newline ) ) );
+ }
+ } );
+ child.stderr.on( 'data', ( data ) => {
+ stderr += data.toString( 'utf8' );
+ } );
+ child.once( 'exit', ( code, signal ) => {
+ clearTimeout( timer );
+ reject( new Error( `Oracle daemon exited before ready (code ${ code }, signal ${ signal }). ${ stderr }` ) );
+ } );
+ } );
+}
+
+function waitForExit( child ) {
+ return new Promise( ( resolve, reject ) => {
+ const timer = setTimeout( () => {
+ child.kill( 'SIGTERM' );
+ reject( new Error( 'Oracle daemon did not shut down.' ) );
+ }, 10000 );
+ child.once( 'exit', ( code, signal ) => {
+ clearTimeout( timer );
+ if ( 0 === code || 'SIGTERM' === signal ) {
+ resolve();
+ } else {
+ reject( new Error( `Oracle daemon exited with code ${ code } and signal ${ signal }.` ) );
+ }
+ } );
+ } );
+}
+
+async function main() {
+ const version = spawnSync( process.execPath, [ SCRIPT, '--version' ], { encoding: 'utf8' } );
+ const versionResult = JSON.parse( version.stdout.trim() );
+ if ( false === versionResult.oracle?.available ) {
+ process.stdout.write( `SKIP chrome-cdp-smoke: ${ versionResult.oracle.error }\n` );
+ return;
+ }
+
+ let networkRequests = 0;
+ const probeServer = http.createServer( ( _request, response ) => {
+ networkRequests++;
+ response.writeHead( 204 );
+ response.end();
+ } );
+ await new Promise( ( resolve ) => probeServer.listen( 0, '127.0.0.1', resolve ) );
+ const probePort = probeServer.address().port;
+ const temporaryDirectory = fs.mkdtempSync( path.join( os.tmpdir(), 'html-api-fuzz-chrome-smoke-' ) );
+ const socketPath = path.join( temporaryDirectory, 'oracle.sock' );
+ const child = spawn( process.execPath, [ SCRIPT, '--serve', '--socket', socketPath ], {
+ stdio: [ 'ignore', 'pipe', 'pipe' ],
+ } );
+
+ try {
+ const ready = await waitForReady( child );
+ assert( 'ready' === ready.status, 'Expected the socket daemon readiness record.' );
+ const fragment = await request( socketPath, {
+ id: 1,
+ command: 'render',
+ htmlBase64: Buffer.from( 'x' ).toString( 'base64' ),
+ mode: 'fragment-body',
+ context: 'body',
+ maxNodes: 100,
+ } );
+ assert( 'ok' === fragment.status, fragment.error || 'Body fragment render failed.' );
+ assert( '
\n a="1"\n "x"\n \n\n' === fragment.tree, 'Unexpected canonical body fragment tree.' );
+ assert( Buffer.from( fragment.treeBase64, 'base64' ).toString( 'utf8' ) === fragment.tree, 'treeBase64 does not encode tree.' );
+ assert( 3 === fragment.nodeCount, 'Unexpected body fragment node count.' );
+
+ const unsafeDocument = '
x ' +
+ `safe`;
+ const fullDocument = await request( socketPath, {
+ id: 2,
+ command: 'render',
+ htmlBase64: Buffer.from( unsafeDocument ).toString( 'base64' ),
+ mode: 'full-document',
+ context: 'body',
+ maxNodes: 100,
+ } );
+ assert( 'ok' === fullDocument.status, fullDocument.error || 'Full-document render failed.' );
+ assert( fullDocument.tree.startsWith( '\n\n' ), 'Full-document navigation did not expose document.childNodes.' );
+ assert( fullDocument.tree.includes( '"safe"' ), 'Author script mutated the parsed full document.' );
+ assert( ! fullDocument.tree.includes( '
\n "EXECUTED"' ), 'Author script executed during full-document parsing.' );
+
+ const [ svg, table ] = await Promise.all( [
+ request( socketPath, {
+ id: 3,
+ command: 'render',
+ htmlBase64: Buffer.from( ' ' ).toString( 'base64' ),
+ mode: 'fragment-body',
+ context: 'svg',
+ maxNodes: 100,
+ } ),
+ request( socketPath, {
+ id: 4,
+ command: 'render',
+ htmlBase64: Buffer.from( '
x y' ).toString( 'base64' ),
+ mode: 'fragment-body',
+ context: 'tr',
+ maxNodes: 100,
+ } ),
+ ] );
+ assert( '\n viewBox="0 0 1 1"\n\n' === svg.tree, 'SVG contextual fragment namespace or attribute adjustment is wrong.' );
+ assert( '\n "x"\n \n "y"\n\n' === table.tree, 'Table contextual fragment parsing is wrong.' );
+ assert( fragment.oracle.browserPid === fullDocument.oracle.browserPid, 'Chrome was not reused between sequential clients.' );
+ assert( fragment.oracle.browserPid === svg.oracle.browserPid, 'Chrome was not reused between concurrent clients.' );
+ assert( fragment.oracle.browserInstanceId === table.oracle.browserInstanceId, 'Browser instance changed during socket serving.' );
+ await new Promise( ( resolve ) => setTimeout( resolve, 100 ) );
+ assert( 0 === networkRequests, 'Full-document parsing made an external network request.' );
+
+ const exiting = waitForExit( child );
+ const shutdown = await request( socketPath, { id: 5, command: 'shutdown' } );
+ assert( 'ok' === shutdown.status && true === shutdown.shutdown, 'Shutdown request failed.' );
+ await exiting;
+ assert( ! fs.existsSync( socketPath ), 'Oracle socket was not removed during shutdown.' );
+ } finally {
+ if ( null === child.exitCode && ! child.killed ) {
+ child.kill( 'SIGTERM' );
+ }
+ await new Promise( ( resolve ) => probeServer.close( resolve ) );
+ fs.rmSync( temporaryDirectory, { recursive: true, force: true } );
+ }
+
+ process.stdout.write( 'OK chrome-cdp-smoke\n' );
+}
+
+main().catch( ( error ) => {
+ process.stderr.write( `FAIL chrome-cdp-smoke: ${ error.stack || error.message }\n` );
+ process.exitCode = 1;
+} );
diff --git a/tools/html-api-fuzz/oracles/html5ever/Cargo.lock b/tools/html-api-fuzz/oracles/html5ever/Cargo.lock
new file mode 100644
index 0000000000000..ec920f6504aab
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/html5ever/Cargo.lock
@@ -0,0 +1,310 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "bitflags"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
+[[package]]
+name = "html5ever"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
+dependencies = [
+ "log",
+ "markup5ever",
+]
+
+[[package]]
+name = "html5ever-tree-oracle"
+version = "0.1.0"
+dependencies = [
+ "html5ever",
+ "markup5ever_rcdom",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "markup5ever"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
+dependencies = [
+ "log",
+ "tendril",
+ "web_atoms",
+]
+
+[[package]]
+name = "markup5ever_rcdom"
+version = "0.39.0+unofficial"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ac010f19d6c4af81eeb4018a39d7a115de9d285af45c126a4ac02e6fc5716b7"
+dependencies = [
+ "html5ever",
+ "markup5ever",
+ "tendril",
+ "xml5ever",
+]
+
+[[package]]
+name = "new_debug_unreachable"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "phf"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
+dependencies = [
+ "phf_shared",
+ "serde",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
+dependencies = [
+ "fastrand",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
+dependencies = [
+ "siphasher",
+]
+
+[[package]]
+name = "precomputed-hash"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "siphasher"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "string_cache"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
+dependencies = [
+ "new_debug_unreachable",
+ "parking_lot",
+ "phf_shared",
+ "precomputed-hash",
+ "serde",
+]
+
+[[package]]
+name = "string_cache_codegen"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "tendril"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
+dependencies = [
+ "new_debug_unreachable",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "web_atoms"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297"
+dependencies = [
+ "phf",
+ "phf_codegen",
+ "string_cache",
+ "string_cache_codegen",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "xml5ever"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ab627f34ff61b80d756180d556f9c68801d836d271b3b8c094504ceca69d221"
+dependencies = [
+ "log",
+ "markup5ever",
+]
diff --git a/tools/html-api-fuzz/oracles/html5ever/Cargo.toml b/tools/html-api-fuzz/oracles/html5ever/Cargo.toml
new file mode 100644
index 0000000000000..cefaa8b1bf496
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/html5ever/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "html5ever-tree-oracle"
+version = "0.1.0"
+edition = "2021"
+publish = false
+
+[dependencies]
+html5ever = "=0.39.0"
+# Cargo ignores SemVer build metadata in version requirements. The lockfile
+# resolves this exact requirement to 0.39.0+unofficial and pins its checksum.
+markup5ever_rcdom = "=0.39.0"
+
+[profile.release]
+codegen-units = 1
+lto = "thin"
+strip = "symbols"
diff --git a/tools/html-api-fuzz/oracles/html5ever/README.md b/tools/html-api-fuzz/oracles/html5ever/README.md
new file mode 100644
index 0000000000000..b5773ecd675c4
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/html5ever/README.md
@@ -0,0 +1,33 @@
+# Source-built html5ever tree oracle
+
+This standalone Rust binary parses HTML with pinned `html5ever` 0.39.0 and
+`markup5ever_rcdom` 0.39.0+unofficial, then renders the fuzzer's canonical
+html5lib-style tree. `Cargo.lock` pins every transitive crate and its registry
+checksum. The CLI metadata exposes the two direct crate checksums, and
+`rust-toolchain.toml` pins Rust 1.88.0.
+
+Install the pinned local Rust toolchain and build:
+
+```sh
+tools/html-api-fuzz/oracles/html5ever/install-rust.sh
+tools/html-api-fuzz/oracles/html5ever/build.sh
+tools/html-api-fuzz/oracles/html5ever/smoke.sh
+```
+
+The installer supports arm64/x86-64 macOS and glibc Linux, installs below
+`.cache/html5ever/rust`, and does not edit shell startup files. A preinstalled
+`cargo` also works. The build script automatically finds the local install and
+verifies that the checked-in Rust 1.88.0 toolchain is active.
+
+CLI:
+
+```text
+html5ever-tree-oracle --mode full-document|fragment-* --input PATH [--context TAG] [--max-nodes N]
+html5ever-tree-oracle --version
+```
+
+Fragment modes use html5ever's context-aware fragment parser. Supported
+contexts match the fuzzer: `body`, `div`, `p`, `td`, `tr`, `table`, `caption`,
+`colgroup`, `select`, `option`, `template`, `title`, `textarea`, `script`,
+`style`, `svg`, and `math`. The result is one JSON object containing `status`,
+`oracle`, `tree`, `treeBase64`, and `nodeCount` on success.
diff --git a/tools/html-api-fuzz/oracles/html5ever/build.sh b/tools/html-api-fuzz/oracles/html5ever/build.sh
new file mode 100755
index 0000000000000..8a0606d9bfc73
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/html5ever/build.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env sh
+set -eu
+
+script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
+repo_root="$(CDPATH= cd -- "$script_dir/../../../.." && pwd)"
+target_dir="${HTML5EVER_TARGET_DIR:-$repo_root/.cache/html5ever/target}"
+oracle_build_dir="$script_dir/build"
+oracle_bin="$oracle_build_dir/html5ever-tree-oracle"
+rust_root="${HTML5EVER_RUST_ROOT:-$repo_root/.cache/html5ever/rust}"
+
+if [ -z "${CARGO_HOME:-}" ] && [ -z "${RUSTUP_HOME:-}" ] && [ -x "$rust_root/cargo/bin/cargo" ]; then
+ CARGO_HOME="$rust_root/cargo"
+ RUSTUP_HOME="$rust_root/rustup"
+ export CARGO_HOME RUSTUP_HOME
+ PATH="$CARGO_HOME/bin:$PATH"
+ export PATH
+fi
+
+cd "$script_dir"
+
+if ! command -v "${CARGO:-cargo}" >/dev/null 2>&1; then
+ printf '%s\n' 'cargo is required. Run ./install-rust.sh, then add the printed bin directory to PATH.' >&2
+ exit 1
+fi
+
+rustc_version="$("${RUSTC:-rustc}" --version)"
+case "$rustc_version" in
+ 'rustc 1.88.0 '*) ;;
+ *)
+ printf '%s\n' "Expected rustc 1.88.0; got: $rustc_version" >&2
+ exit 1
+ ;;
+esac
+
+mkdir -p "$oracle_build_dir"
+
+CARGO_TARGET_DIR="$target_dir" "${CARGO:-cargo}" build \
+ --manifest-path "$script_dir/Cargo.toml" \
+ --release \
+ --locked
+
+cp "$target_dir/release/html5ever-tree-oracle" "$oracle_bin"
+chmod 0755 "$oracle_bin"
+
+printf '%s\n' "$oracle_bin"
diff --git a/tools/html-api-fuzz/oracles/html5ever/install-rust.sh b/tools/html-api-fuzz/oracles/html5ever/install-rust.sh
new file mode 100755
index 0000000000000..dff5a80880519
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/html5ever/install-rust.sh
@@ -0,0 +1,62 @@
+#!/usr/bin/env sh
+set -eu
+
+# rustup and rustc are both pinned. The downloaded rustup-init is checked
+# against the SHA-256 file published alongside the same immutable archive.
+rustup_version='1.28.2'
+toolchain='1.88.0'
+script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
+repo_root="$(CDPATH= cd -- "$script_dir/../../../.." && pwd)"
+install_root="${HTML5EVER_RUST_ROOT:-$repo_root/.cache/html5ever/rust}"
+cargo_home="${CARGO_HOME:-$install_root/cargo}"
+rustup_home="${RUSTUP_HOME:-$install_root/rustup}"
+
+case "$(uname -s)-$(uname -m)" in
+ Darwin-arm64) target='aarch64-apple-darwin' ;;
+ Darwin-x86_64) target='x86_64-apple-darwin' ;;
+ Linux-aarch64) target='aarch64-unknown-linux-gnu' ;;
+ Linux-x86_64) target='x86_64-unknown-linux-gnu' ;;
+ *)
+ printf 'Unsupported rustup host: %s-%s\n' "$(uname -s)" "$(uname -m)" >&2
+ exit 1
+ ;;
+esac
+
+archive_url="https://static.rust-lang.org/rustup/archive/$rustup_version/$target/rustup-init"
+download_dir="$install_root/downloads"
+rustup_init="$download_dir/rustup-init-$rustup_version-$target"
+checksum_file="$rustup_init.sha256"
+
+mkdir -p "$download_dir" "$cargo_home" "$rustup_home"
+
+if [ ! -f "$rustup_init" ]; then
+ curl --proto '=https' --tlsv1.2 --fail --location --silent --show-error \
+ "$archive_url" --output "$rustup_init"
+fi
+curl --proto '=https' --tlsv1.2 --fail --location --silent --show-error \
+ "$archive_url.sha256" --output "$checksum_file"
+
+expected="$(awk '{ print $1; exit }' "$checksum_file")"
+if command -v sha256sum >/dev/null 2>&1; then
+ actual="$(sha256sum "$rustup_init" | awk '{ print $1 }')"
+elif command -v shasum >/dev/null 2>&1; then
+ actual="$(shasum -a 256 "$rustup_init" | awk '{ print $1 }')"
+else
+ printf '%s\n' 'sha256sum or shasum is required.' >&2
+ exit 1
+fi
+
+if [ "$actual" != "$expected" ]; then
+ printf '%s\n' "rustup-init SHA-256 mismatch: expected $expected, got $actual" >&2
+ exit 1
+fi
+
+chmod 0755 "$rustup_init"
+CARGO_HOME="$cargo_home" RUSTUP_HOME="$rustup_home" RUSTUP_VERSION="$rustup_version" \
+ "$rustup_init" -y --no-modify-path --profile minimal --default-toolchain none
+CARGO_HOME="$cargo_home" RUSTUP_HOME="$rustup_home" \
+ "$cargo_home/bin/rustup" set auto-self-update disable
+CARGO_HOME="$cargo_home" RUSTUP_HOME="$rustup_home" \
+ "$cargo_home/bin/rustup" toolchain install "$toolchain" --profile minimal --no-self-update
+
+printf '%s\n' "$cargo_home/bin"
diff --git a/tools/html-api-fuzz/oracles/html5ever/rust-toolchain.toml b/tools/html-api-fuzz/oracles/html5ever/rust-toolchain.toml
new file mode 100644
index 0000000000000..55b200d5b2903
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/html5ever/rust-toolchain.toml
@@ -0,0 +1,3 @@
+[toolchain]
+channel = "1.88.0"
+profile = "minimal"
diff --git a/tools/html-api-fuzz/oracles/html5ever/smoke.sh b/tools/html-api-fuzz/oracles/html5ever/smoke.sh
new file mode 100755
index 0000000000000..18568988f1024
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/html5ever/smoke.sh
@@ -0,0 +1,135 @@
+#!/usr/bin/env sh
+set -eu
+
+script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
+binary="${HTML5EVER_ORACLE_BIN:-$script_dir/build/html5ever-tree-oracle}"
+
+if [ ! -x "$binary" ]; then
+ printf '%s\n' "Missing oracle binary: $binary. Run ./build.sh first." >&2
+ exit 1
+fi
+
+tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/html5ever-oracle-smoke.XXXXXX")"
+trap 'rm -rf "$tmp_dir"' EXIT HUP INT TERM
+
+printf '%s' 'x hi&
' >"$tmp_dir/document.html"
+"$binary" --mode full-document --max-nodes 100 --input "$tmp_dir/document.html" >"$tmp_dir/document.json"
+
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+if ( "ok" !== ( $result["status"] ?? null ) ) { throw new Exception( "full document status" ); }
+if ( "html5ever-source" !== ( $result["oracle"]["kind"] ?? null ) ) { throw new Exception( "oracle kind" ); }
+if ( "0.39.0" !== ( $result["oracle"]["html5everVersion"] ?? null ) ) { throw new Exception( "html5ever pin" ); }
+if ( "1.88.0" !== ( $result["oracle"]["rustToolchain"] ?? null ) ) { throw new Exception( "Rust pin" ); }
+if ( base64_decode( $result["treeBase64"], true ) !== $result["tree"] ) { throw new Exception( "tree base64" ); }
+if ( ! str_starts_with( $result["tree"], "\n\n" ) ) { throw new Exception( "doctype/document tree" ); }
+if ( false === strpos( $result["tree"], " a=\"1\"\n b=\"2\"\n \"hi&\"" ) ) { throw new Exception( "canonical attributes/text" ); }
+' "$tmp_dir/document.json"
+
+: >"$tmp_dir/empty.html"
+"$binary" --mode fragment-body --context body --max-nodes 100 --input "$tmp_dir/empty.html" >"$tmp_dir/empty.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+if ( "\n" !== ( $result["tree"] ?? null ) || 0 !== ( $result["nodeCount"] ?? null ) ) { throw new Exception( "empty fragment contract" ); }
+' "$tmp_dir/empty.json"
+
+printf '%s' ' x y' >"$tmp_dir/fragment.html"
+"$binary" --mode fragment-body --context table --max-nodes 100 --input "$tmp_dir/fragment.html" >"$tmp_dir/fragment.json"
+
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+if ( "ok" !== ( $result["status"] ?? null ) ) { throw new Exception( "fragment status" ); }
+$expected = " \n \n \n \"x\"\n \n \"y\"\n\n";
+if ( $expected !== $result["tree"] ) { throw new Exception( "contextual table fragment: " . $result["tree"] ); }
+' "$tmp_dir/fragment.json"
+
+printf '%s' 'a b' >"$tmp_dir/select.html"
+"$binary" --mode fragment-select --context select --max-nodes 100 --input "$tmp_dir/select.html" >"$tmp_dir/select.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+$expected = " \n \"a\"\n \n \"b\"\n\n";
+if ( $expected !== ( $result["tree"] ?? null ) ) { throw new Exception( "contextual select fragment" ); }
+' "$tmp_dir/select.json"
+
+printf '%s' '&' >"$tmp_dir/title.html"
+"$binary" --mode fragment-title --context title --max-nodes 100 --input "$tmp_dir/title.html" >"$tmp_dir/title.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+if ( "\"&\"\n\n" !== ( $result["tree"] ?? null ) ) { throw new Exception( "RCDATA title context" ); }
+' "$tmp_dir/title.json"
+
+printf '%s' '&' >"$tmp_dir/script.html"
+"$binary" --mode fragment-script --context script --max-nodes 100 --input "$tmp_dir/script.html" >"$tmp_dir/script.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+if ( "\"&\"\n\n" !== ( $result["tree"] ?? null ) ) { throw new Exception( "raw-text script context" ); }
+' "$tmp_dir/script.json"
+
+printf '%s' 'x ' >"$tmp_dir/template-fragment.html"
+"$binary" --mode fragment-template --context template --max-nodes 100 --input "$tmp_dir/template-fragment.html" >"$tmp_dir/template-fragment.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+if ( "\n \"x\"\n\n" !== ( $result["tree"] ?? null ) ) { throw new Exception( "template fragment context" ); }
+' "$tmp_dir/template-fragment.json"
+
+printf '%s' 'x
' >"$tmp_dir/template-document.html"
+"$binary" --mode full-document --max-nodes 100 --input "$tmp_dir/template-document.html" >"$tmp_dir/template-document.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+if ( false === strpos( $result["tree"] ?? "", " \n content\n \n \"x\"\n" ) ) { throw new Exception( "template content marker/tree" ); }
+if ( ! str_ends_with( $result["tree"] ?? "", "\n\n" ) ) { throw new Exception( "canonical trailing newline" ); }
+' "$tmp_dir/template-document.json"
+
+printf '%s' ' ' >"$tmp_dir/svg.html"
+"$binary" --mode fragment-svg --context svg --max-nodes 100 --input "$tmp_dir/svg.html" >"$tmp_dir/svg.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+$expected = "\n xlink href=\"q\"\n\n";
+if ( $expected !== ( $result["tree"] ?? null ) ) { throw new Exception( "SVG fragment namespace" ); }
+' "$tmp_dir/svg.json"
+
+printf '%s' 'x ' >"$tmp_dir/math.html"
+"$binary" --mode fragment-math --context math --max-nodes 100 --input "$tmp_dir/math.html" >"$tmp_dir/math.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+$expected = "\n \"x\"\n\n";
+if ( $expected !== ( $result["tree"] ?? null ) ) { throw new Exception( "MathML fragment namespace" ); }
+' "$tmp_dir/math.json"
+
+printf '%s' ' ' >"$tmp_dir/namespaces.html"
+"$binary" --mode fragment-body --context body --max-nodes 100 --input "$tmp_dir/namespaces.html" >"$tmp_dir/namespaces.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+$expected = "\n a=\"0\"\n xlink href=\"y\"\n xml lang=\"x\"\n xmlns xlink=\"z\"\n\n";
+if ( $expected !== ( $result["tree"] ?? null ) ) { throw new Exception( "attribute namespace/order contract" ); }
+' "$tmp_dir/namespaces.json"
+
+printf '%s' '
' >"$tmp_dir/adjusted-svg.html"
+"$binary" --mode fragment-body --context body --max-nodes 100 --input "$tmp_dir/adjusted-svg.html" >"$tmp_dir/adjusted-svg.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+$tree = $result["tree"] ?? "";
+foreach ( array( "\n", "\n", " attributeName=\"x\"\n", "\n", " gradientUnits=\"userSpaceOnUse\"\n" ) as $expected ) {
+ if ( false === strpos( $tree, $expected ) ) { throw new Exception( "adjusted SVG name: " . $expected ); }
+}
+' "$tmp_dir/adjusted-svg.json"
+
+printf '%s' 'x ' >"$tmp_dir/limit.html"
+if "$binary" --mode fragment-body --context body --max-nodes 1 --input "$tmp_dir/limit.html" >"$tmp_dir/limit.json"; then
+ printf '%s\n' 'Expected the node-limited parse to exit nonzero.' >&2
+ exit 1
+fi
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+if ( "node-limit-exceeded" !== ( $result["failureClass"] ?? null ) ) { throw new Exception( "node limit failure class" ); }
+' "$tmp_dir/limit.json"
+
+"$binary" --version >"$tmp_dir/version.json"
+php -r '
+$result = json_decode( file_get_contents( $argv[1] ), true, 512, JSON_THROW_ON_ERROR );
+if ( "0.39.0+unofficial" !== ( $result["oracle"]["markup5everRcdomVersion"] ?? null ) ) { throw new Exception( "rcdom pin" ); }
+if ( "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" !== ( $result["oracle"]["html5everChecksum"] ?? null ) ) { throw new Exception( "html5ever checksum" ); }
+if ( "3ac010f19d6c4af81eeb4018a39d7a115de9d285af45c126a4ac02e6fc5716b7" !== ( $result["oracle"]["markup5everRcdomChecksum"] ?? null ) ) { throw new Exception( "rcdom checksum" ); }
+' "$tmp_dir/version.json"
+
+printf '%s\n' 'OK html5ever-oracle-smoke'
diff --git a/tools/html-api-fuzz/oracles/html5ever/src/main.rs b/tools/html-api-fuzz/oracles/html5ever/src/main.rs
new file mode 100644
index 0000000000000..c7fdd69099bdc
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/html5ever/src/main.rs
@@ -0,0 +1,502 @@
+use html5ever::tendril::TendrilSink;
+use html5ever::{parse_document, parse_fragment, QualName};
+use markup5ever_rcdom::{Handle, NodeData, RcDom};
+use std::env;
+use std::fs;
+use std::io::Cursor;
+use std::process::ExitCode;
+
+const HTML5EVER_VERSION: &str = "0.39.0";
+const HTML5EVER_CHECKSUM: &str =
+ "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8";
+const RCDOM_VERSION: &str = "0.39.0+unofficial";
+const RCDOM_CHECKSUM: &str =
+ "3ac010f19d6c4af81eeb4018a39d7a115de9d285af45c126a4ac02e6fc5716b7";
+const RUST_TOOLCHAIN: &str = "1.88.0";
+const HTML_NS: &str = "http://www.w3.org/1999/xhtml";
+const SVG_NS: &str = "http://www.w3.org/2000/svg";
+const MATH_NS: &str = "http://www.w3.org/1998/Math/MathML";
+const XLINK_NS: &str = "http://www.w3.org/1999/xlink";
+const XML_NS: &str = "http://www.w3.org/XML/1998/namespace";
+const XMLNS_NS: &str = "http://www.w3.org/2000/xmlns/";
+
+#[derive(Default)]
+struct Options {
+ mode: Option,
+ context: Option,
+ input: Option,
+ max_nodes: usize,
+ help: bool,
+ version: bool,
+}
+
+struct RenderState {
+ tree: String,
+ node_count: usize,
+ max_nodes: usize,
+}
+
+struct OracleError {
+ failure_class: &'static str,
+ message: String,
+ node_count: usize,
+}
+
+fn main() -> ExitCode {
+ let options = match parse_args(env::args().skip(1)) {
+ Ok(options) => options,
+ Err(message) => {
+ print_error("oracle-cli-error", &message, 0);
+ return ExitCode::FAILURE;
+ }
+ };
+
+ if options.help {
+ print_usage();
+ return ExitCode::SUCCESS;
+ }
+ if options.version {
+ println!(
+ "{{\"status\":\"ok\",\"oracle\":{}}}",
+ oracle_json()
+ );
+ return ExitCode::SUCCESS;
+ }
+
+ let input_path = options.input.as_deref().expect("validated input path");
+ let input = match fs::read(input_path) {
+ Ok(input) => input,
+ Err(error) => {
+ print_error(
+ "oracle-cli-error",
+ &format!("Could not read input file: {error}"),
+ 0,
+ );
+ return ExitCode::FAILURE;
+ }
+ };
+
+ match render(&options, &input) {
+ Ok((tree, node_count)) => {
+ print_ok(&tree, node_count);
+ ExitCode::SUCCESS
+ }
+ Err(error) => {
+ print_error(error.failure_class, &error.message, error.node_count);
+ ExitCode::from(2)
+ }
+ }
+}
+
+fn parse_args(args: impl Iterator- ) -> Result
{
+ let mut options = Options {
+ max_nodes: 3000,
+ ..Options::default()
+ };
+ let mut args = args.peekable();
+
+ while let Some(arg) = args.next() {
+ match arg.as_str() {
+ "--help" | "-h" => {
+ options.help = true;
+ return Ok(options);
+ }
+ "--version" => {
+ options.version = true;
+ return Ok(options);
+ }
+ "--mode" => options.mode = Some(next_value(&mut args, "--mode")?),
+ "--context" => options.context = Some(next_value(&mut args, "--context")?),
+ "--input" => options.input = Some(next_value(&mut args, "--input")?),
+ "--max-nodes" => {
+ let value = next_value(&mut args, "--max-nodes")?;
+ options.max_nodes = value.parse::().map_err(|_| {
+ "Expected --max-nodes to be a positive integer.".to_string()
+ })?;
+ if options.max_nodes == 0 {
+ return Err("Expected --max-nodes to be a positive integer.".to_string());
+ }
+ }
+ _ => return Err(format!("Unknown option: {arg}")),
+ }
+ }
+
+ let mode = options
+ .mode
+ .as_deref()
+ .ok_or_else(|| "Missing --mode.".to_string())?;
+ if mode != "full-document" && !mode.starts_with("fragment-") {
+ return Err("Expected --mode full-document or fragment-*.".to_string());
+ }
+ if options.input.is_none() {
+ return Err("Missing --input.".to_string());
+ }
+
+ Ok(options)
+}
+
+fn next_value(
+ args: &mut std::iter::Peekable>,
+ option: &str,
+) -> Result {
+ args.next()
+ .ok_or_else(|| format!("Missing value for {option}."))
+}
+
+fn print_usage() {
+ println!(
+ "Usage: html5ever-tree-oracle --mode full-document|fragment-* --input PATH [--context TAG] [--max-nodes N]"
+ );
+}
+
+fn render(options: &Options, input: &[u8]) -> Result<(String, usize), OracleError> {
+ let mode = options.mode.as_deref().expect("validated mode");
+ let (dom, is_fragment) = if mode == "full-document" {
+ (parse_document_bytes(input)?, false)
+ } else {
+ let context = options
+ .context
+ .as_deref()
+ .or_else(|| mode.strip_prefix("fragment-"))
+ .unwrap_or("body");
+ (parse_fragment_bytes(input, context)?, true)
+ };
+
+ let mut state = RenderState {
+ tree: String::new(),
+ node_count: 0,
+ max_nodes: options.max_nodes,
+ };
+ if is_fragment {
+ render_fragment_children(&dom, &mut state)?;
+ } else {
+ render_children(&dom.document, 0, &mut state)?;
+ }
+ state.tree.push('\n');
+ Ok((state.tree, state.node_count))
+}
+
+fn render_fragment_children(dom: &RcDom, state: &mut RenderState) -> Result<(), OracleError> {
+ // RcDom attaches fragment output below a synthetic HTML document element.
+ // That implementation detail is not part of the fragment result and must
+ // not affect either the canonical tree or its node count.
+ let container = dom
+ .document
+ .children
+ .borrow()
+ .iter()
+ .find(|node| {
+ matches!(
+ &node.data,
+ NodeData::Element { name, .. }
+ if name.ns.as_ref() == HTML_NS && name.local.as_ref() == "html"
+ )
+ })
+ .cloned();
+
+ match container {
+ Some(container) => render_children(&container, 0, state),
+ None => render_children(&dom.document, 0, state),
+ }
+}
+
+fn parse_document_bytes(input: &[u8]) -> Result {
+ parse_document(RcDom::default(), Default::default())
+ .from_utf8()
+ .read_from(&mut Cursor::new(input))
+ .map_err(|error| OracleError {
+ failure_class: "oracle-parse-error",
+ message: format!("html5ever could not read the input: {error}"),
+ node_count: 0,
+ })
+}
+
+fn parse_fragment_bytes(input: &[u8], context: &str) -> Result {
+ let context_name = context_qual_name(context).ok_or_else(|| OracleError {
+ failure_class: "oracle-unsupported",
+ message: format!("Unsupported fragment context: {context}"),
+ node_count: 0,
+ })?;
+
+ parse_fragment(
+ RcDom::default(),
+ Default::default(),
+ context_name,
+ Vec::new(),
+ false,
+ )
+ .from_utf8()
+ .read_from(&mut Cursor::new(input))
+ .map_err(|error| OracleError {
+ failure_class: "oracle-parse-error",
+ message: format!("html5ever could not read the fragment: {error}"),
+ node_count: 0,
+ })
+}
+
+fn context_qual_name(context: &str) -> Option {
+ let lower = context.to_ascii_lowercase();
+ let namespace = match lower.as_str() {
+ "body" | "div" | "p" | "td" | "tr" | "table" | "caption" | "colgroup"
+ | "select" | "option" | "template" | "title" | "textarea" | "script"
+ | "style" => HTML_NS,
+ "svg" => SVG_NS,
+ "math" => MATH_NS,
+ _ => return None,
+ };
+
+ Some(QualName::new(
+ None,
+ namespace.into(),
+ lower.as_str().into(),
+ ))
+}
+
+fn render_children(
+ parent: &Handle,
+ indent: usize,
+ state: &mut RenderState,
+) -> Result<(), OracleError> {
+ for child in parent.children.borrow().iter() {
+ render_node(child, indent, state)?;
+ }
+ Ok(())
+}
+
+fn render_node(node: &Handle, indent: usize, state: &mut RenderState) -> Result<(), OracleError> {
+ state.node_count += 1;
+ if state.node_count > state.max_nodes {
+ return Err(OracleError {
+ failure_class: "node-limit-exceeded",
+ message: "DOM node limit exceeded.".to_string(),
+ node_count: state.node_count,
+ });
+ }
+
+ match &node.data {
+ NodeData::Document => render_children(node, indent, state),
+ NodeData::Doctype {
+ name,
+ public_id,
+ system_id,
+ } => {
+ state.tree.push_str("\n");
+ Ok(())
+ }
+ NodeData::Text { contents } => {
+ let contents = contents.borrow();
+ if !contents.is_empty() {
+ push_indent(indent, &mut state.tree);
+ state.tree.push('"');
+ escape_scalar_into(contents.as_ref(), &mut state.tree);
+ state.tree.push_str("\"\n");
+ }
+ Ok(())
+ }
+ NodeData::Comment { contents } => {
+ push_indent(indent, &mut state.tree);
+ state.tree.push_str("\n");
+ Ok(())
+ }
+ NodeData::Element {
+ name,
+ attrs,
+ template_contents,
+ ..
+ } => {
+ push_indent(indent, &mut state.tree);
+ state.tree.push('<');
+ escape_scalar_into(&element_display_name(name), &mut state.tree);
+ state.tree.push_str(">\n");
+
+ let mut attributes: Vec<(String, String)> = attrs
+ .borrow()
+ .iter()
+ .map(|attribute| {
+ (
+ escaped_scalar(&attribute_display_name(&attribute.name)),
+ attribute.value.to_string(),
+ )
+ })
+ .collect();
+ attributes.sort_by(|left, right| compare_display_names(&left.0, &right.0));
+ for (name, value) in attributes {
+ push_indent(indent + 1, &mut state.tree);
+ state.tree.push_str(&name);
+ state.tree.push_str("=\"");
+ escape_scalar_into(&value, &mut state.tree);
+ state.tree.push_str("\"\n");
+ }
+
+ if name.ns.as_ref() == HTML_NS && name.local.as_ref() == "template" {
+ push_indent(indent + 1, &mut state.tree);
+ state.tree.push_str("content\n");
+ if let Some(contents) = template_contents.borrow().as_ref() {
+ render_children(contents, indent + 2, state)?;
+ }
+ Ok(())
+ } else {
+ render_children(node, indent + 1, state)
+ }
+ }
+ _ => Ok(()),
+ }
+}
+
+fn element_display_name(name: &QualName) -> String {
+ match name.ns.as_ref() {
+ HTML_NS => name.local.as_ref().to_ascii_lowercase(),
+ SVG_NS => format!("svg {}", name.local),
+ MATH_NS => format!("math {}", name.local),
+ _ => qualified_name(name),
+ }
+}
+
+fn attribute_display_name(name: &QualName) -> String {
+ match name.ns.as_ref() {
+ XLINK_NS => format!("xlink {}", name.local),
+ XML_NS => format!("xml {}", name.local),
+ XMLNS_NS => format!("xmlns {}", name.local),
+ _ => qualified_name(name),
+ }
+}
+
+fn qualified_name(name: &QualName) -> String {
+ match name.prefix.as_ref() {
+ Some(prefix) => format!("{prefix}:{}", name.local),
+ None => name.local.to_string(),
+ }
+}
+
+fn compare_display_names(left: &str, right: &str) -> std::cmp::Ordering {
+ left.contains(':')
+ .cmp(&right.contains(':'))
+ .then_with(|| left.contains(' ').cmp(&right.contains(' ')))
+ .then_with(|| left.as_bytes().cmp(right.as_bytes()))
+}
+
+fn push_indent(indent: usize, output: &mut String) {
+ for _ in 0..indent {
+ output.push_str(" ");
+ }
+}
+
+fn escape_scalar_into(value: &str, output: &mut String) {
+ for character in value.chars() {
+ match character {
+ '\n' => output.push_str("\\n"),
+ '\r' => output.push_str("\\r"),
+ '\t' => output.push_str("\\t"),
+ '\0' => output.push_str("\\0"),
+ '\\' => output.push_str("\\\\"),
+ '"' => output.push_str("\\\""),
+ character if (character as u32) < 0x20 || character == '\u{7f}' => {
+ output.push_str(&format!("\\x{:02X}", character as u32));
+ }
+ character => output.push(character),
+ }
+ }
+}
+
+fn escaped_scalar(value: &str) -> String {
+ let mut output = String::with_capacity(value.len());
+ escape_scalar_into(value, &mut output);
+ output
+}
+
+fn oracle_json() -> String {
+ format!(
+ "{{\"kind\":\"html5ever-source\",\"html5everVersion\":\"{HTML5EVER_VERSION}\",\"html5everChecksum\":\"{HTML5EVER_CHECKSUM}\",\"markup5everRcdomVersion\":\"{RCDOM_VERSION}\",\"markup5everRcdomChecksum\":\"{RCDOM_CHECKSUM}\",\"rustToolchain\":\"{RUST_TOOLCHAIN}\"}}"
+ )
+}
+
+fn print_ok(tree: &str, node_count: usize) {
+ println!(
+ "{{\"status\":\"ok\",\"oracle\":{},\"tree\":{},\"treeBase64\":\"{}\",\"nodeCount\":{}}}",
+ oracle_json(),
+ json_string(tree),
+ base64(tree.as_bytes()),
+ node_count
+ );
+}
+
+fn print_error(failure_class: &str, message: &str, node_count: usize) {
+ let status = if failure_class == "oracle-unsupported" {
+ "unsupported"
+ } else {
+ "error"
+ };
+ if status == "unsupported" {
+ println!(
+ "{{\"status\":\"unsupported\",\"oracle\":{},\"nodeCount\":{},\"failureClass\":\"oracle-unsupported\",\"unsupported\":{{\"message\":{}}}}}",
+ oracle_json(),
+ node_count,
+ json_string(message)
+ );
+ } else {
+ println!(
+ "{{\"status\":\"error\",\"oracle\":{},\"nodeCount\":{},\"failureClass\":{},\"error\":{}}}",
+ oracle_json(),
+ node_count,
+ json_string(failure_class),
+ json_string(message)
+ );
+ }
+}
+
+fn json_string(value: &str) -> String {
+ let mut output = String::with_capacity(value.len() + 2);
+ output.push('"');
+ for character in value.chars() {
+ match character {
+ '"' => output.push_str("\\\""),
+ '\\' => output.push_str("\\\\"),
+ '\u{08}' => output.push_str("\\b"),
+ '\u{0c}' => output.push_str("\\f"),
+ '\n' => output.push_str("\\n"),
+ '\r' => output.push_str("\\r"),
+ '\t' => output.push_str("\\t"),
+ character if (character as u32) < 0x20 => {
+ output.push_str(&format!("\\u{:04X}", character as u32));
+ }
+ character => output.push(character),
+ }
+ }
+ output.push('"');
+ output
+}
+
+fn base64(input: &[u8]) -> String {
+ const ALPHABET: &[u8; 64] =
+ b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+ let mut output = String::with_capacity(input.len().div_ceil(3) * 4);
+ for chunk in input.chunks(3) {
+ let a = chunk[0];
+ let b = *chunk.get(1).unwrap_or(&0);
+ let c = *chunk.get(2).unwrap_or(&0);
+ output.push(ALPHABET[(a >> 2) as usize] as char);
+ output.push(ALPHABET[(((a & 0x03) << 4) | (b >> 4)) as usize] as char);
+ output.push(if chunk.len() > 1 {
+ ALPHABET[(((b & 0x0f) << 2) | (c >> 6)) as usize] as char
+ } else {
+ '='
+ });
+ output.push(if chunk.len() > 2 {
+ ALPHABET[(c & 0x3f) as usize] as char
+ } else {
+ '='
+ });
+ }
+ output
+}
diff --git a/tools/html-api-fuzz/oracles/lexbor/COMMIT b/tools/html-api-fuzz/oracles/lexbor/COMMIT
new file mode 100644
index 0000000000000..d7369d5f9ecca
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/lexbor/COMMIT
@@ -0,0 +1 @@
+de1d07a7765aad37090cc36f7fac3bb59e21467d
diff --git a/tools/html-api-fuzz/oracles/lexbor/README.md b/tools/html-api-fuzz/oracles/lexbor/README.md
new file mode 100644
index 0000000000000..90472ead4a359
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/lexbor/README.md
@@ -0,0 +1,66 @@
+# Lexbor Source Oracle
+
+This directory contains a standalone oracle binary for comparing the WordPress
+HTML API against a source-built Lexbor checkout instead of PHP's bundled
+`Dom\HTMLDocument` runtime.
+
+Build the exact commit recorded in `COMMIT`:
+
+```sh
+tools/html-api-fuzz/oracles/lexbor/build.sh
+```
+
+The script clones Lexbor under `.cache/lexbor//source`, builds and
+installs a static Lexbor library under the same cache entry, then writes:
+
+```text
+tools/html-api-fuzz/oracles/lexbor/build/lexbor-tree-oracle
+```
+
+Lexbor is the default oracle; it can also be selected explicitly:
+
+```sh
+php tools/html-api-fuzz/worker.php \
+ --seed 1 \
+ --dom-oracle lexbor-source \
+ --output-dir artifacts/html-api-fuzz/seed-1-lexbor
+
+php tools/html-api-fuzz/runner.php \
+ --max-seeds 100 \
+ --dom-oracle lexbor-source
+```
+
+Pass `--lexbor-oracle-bin PATH` or set `HTML_API_FUZZ_LEXBOR_ORACLE` when the
+binary is not at the default build path above. Replays preserve the selected
+oracle and binary path.
+
+The binary records the resolved Lexbor commit in its JSON metadata. The build
+fails if the resolved commit differs from the requested pin.
+
+Use a different checkout or commit when bisecting upstream behavior:
+
+```sh
+LEXBOR_SOURCE_DIR=/path/to/lexbor \
+LEXBOR_COMMIT=481c444261a132190a3fb746d6d2f60824af3717 \
+tools/html-api-fuzz/oracles/lexbor/build.sh
+```
+
+Direct CLI examples:
+
+```sh
+tools/html-api-fuzz/oracles/lexbor/build/lexbor-tree-oracle \
+ --mode full-document \
+ --max-nodes 3000 \
+ --input /path/to/input.bin
+
+tools/html-api-fuzz/oracles/lexbor/build/lexbor-tree-oracle \
+ --mode fragment-body \
+ --context body \
+ --max-nodes 3000 \
+ --input /path/to/input.bin
+```
+
+The oracle returns JSON with `status`, `oracle` metadata, `tree`,
+`treeBase64`, and `nodeCount`. The `treeBase64` field is the exact
+html5lib-style tree bytes consumed by the PHP adapter; `tree` is the same tree
+as a JSON-safe display string. Neither field is serialized HTML.
diff --git a/tools/html-api-fuzz/oracles/lexbor/build.sh b/tools/html-api-fuzz/oracles/lexbor/build.sh
new file mode 100755
index 0000000000000..b826566582643
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/lexbor/build.sh
@@ -0,0 +1,66 @@
+#!/usr/bin/env sh
+set -eu
+
+script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
+ref="${LEXBOR_COMMIT:-$(tr -d '[:space:]' < "$script_dir/COMMIT")}"
+repo_root="$(CDPATH= cd -- "$script_dir/../../../.." && pwd)"
+cache_dir="${LEXBOR_CACHE_DIR:-$repo_root/.cache/lexbor/$ref}"
+source_dir="${LEXBOR_SOURCE_DIR:-$cache_dir/source}"
+build_dir="${LEXBOR_BUILD_DIR:-$cache_dir/build}"
+install_dir="${LEXBOR_INSTALL_DIR:-$cache_dir/install}"
+oracle_build_dir="$script_dir/build"
+oracle_bin="$oracle_build_dir/lexbor-tree-oracle"
+
+case "$ref" in
+ *[!0-9a-f]*|'')
+ printf '%s\n' "LEXBOR_COMMIT must be a full hexadecimal commit hash: $ref" >&2
+ exit 1
+ ;;
+esac
+if [ "${#ref}" -ne 40 ]; then
+ printf '%s\n' "LEXBOR_COMMIT must contain exactly 40 hexadecimal characters: $ref" >&2
+ exit 1
+fi
+
+if [ ! -d "$source_dir/.git" ]; then
+ mkdir -p "$(dirname "$source_dir")"
+ git clone --no-checkout https://github.com/lexbor/lexbor.git "$source_dir"
+fi
+
+if ! git -C "$source_dir" cat-file -e "$ref^{commit}" 2>/dev/null; then
+ git -C "$source_dir" fetch --no-tags origin "$ref"
+fi
+git -C "$source_dir" checkout --detach "$ref"
+commit="$(git -C "$source_dir" rev-parse HEAD)"
+if [ "$commit" != "$ref" ]; then
+ printf '%s\n' "Resolved Lexbor commit $commit does not match requested pin $ref." >&2
+ exit 1
+fi
+
+cmake -S "$source_dir" -B "$build_dir" \
+ -DLEXBOR_BUILD_SHARED=OFF \
+ -DLEXBOR_BUILD_STATIC=ON \
+ -DLEXBOR_BUILD_SEPARATELY=OFF \
+ -DLEXBOR_BUILD_EXAMPLES=OFF \
+ -DLEXBOR_BUILD_TESTS=OFF \
+ -DLEXBOR_BUILD_UTILS=OFF \
+ -DCMAKE_INSTALL_PREFIX="$install_dir"
+
+cmake --build "$build_dir" --target lexbor_static
+cmake --install "$build_dir" --prefix "$install_dir"
+
+mkdir -p "$oracle_build_dir"
+
+cc ${CFLAGS:-} \
+ -std=c99 \
+ -Wall \
+ -Wextra \
+ -Werror \
+ -I"$install_dir/include" \
+ -DHTML_API_FUZZ_LEXBOR_COMMIT="\"$commit\"" \
+ "$script_dir/lexbor-tree-oracle.c" \
+ "$install_dir/lib/liblexbor_static.a" \
+ -o "$oracle_bin" \
+ ${LDFLAGS:-}
+
+printf '%s\n' "$oracle_bin"
diff --git a/tools/html-api-fuzz/oracles/lexbor/lexbor-tree-oracle.c b/tools/html-api-fuzz/oracles/lexbor/lexbor-tree-oracle.c
new file mode 100644
index 0000000000000..701e25fc791b6
--- /dev/null
+++ b/tools/html-api-fuzz/oracles/lexbor/lexbor-tree-oracle.c
@@ -0,0 +1,1243 @@
+/*
+ * Source-built Lexbor tree oracle for the HTML API fuzzer.
+ *
+ * Parses one input with Lexbor and emits a JSON result whose "tree" field uses
+ * the same html5lib-style text format as HtmlApiFuzz\TreeRenderer.
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+#ifndef HTML_API_FUZZ_LEXBOR_COMMIT
+#define HTML_API_FUZZ_LEXBOR_COMMIT "unknown"
+#endif
+
+typedef struct {
+ char *data;
+ size_t length;
+ size_t capacity;
+ bool failed;
+} buffer_t;
+
+typedef struct {
+ char *sort_name;
+ char *render_name;
+ char *value;
+} attr_record_t;
+
+typedef enum {
+ ORACLE_OK,
+ ORACLE_UNSUPPORTED,
+ ORACLE_ERROR,
+} oracle_status_t;
+
+typedef struct {
+ oracle_status_t status;
+ const char *failure_class;
+ const char *message;
+ buffer_t tree;
+ size_t node_count;
+ size_t max_nodes;
+} render_ctx_t;
+
+typedef struct {
+ const char *mode;
+ const char *context;
+ const char *input_path;
+ size_t max_nodes;
+ bool show_help;
+ bool show_version;
+} cli_options_t;
+
+static void buffer_init(buffer_t *buf);
+static void buffer_destroy(buffer_t *buf);
+static bool buffer_reserve(buffer_t *buf, size_t extra);
+static bool buffer_append_mem(buffer_t *buf, const char *data, size_t len);
+static bool buffer_append_cstr(buffer_t *buf, const char *data);
+static bool buffer_append_char(buffer_t *buf, char ch);
+static bool buffer_append_repeat(buffer_t *buf, const char *data, size_t len, size_t count);
+static char *buffer_take_cstr(buffer_t *buf);
+static bool append_escaped_scalar(buffer_t *buf, const lxb_char_t *data, size_t len, bool scrub);
+static bool append_json_string(buffer_t *buf, const char *data, size_t len);
+static bool append_json_base64(buffer_t *buf, const char *data, size_t len);
+static bool append_tree_line_indent(buffer_t *buf, int indent_level);
+static bool append_display_element_name(buffer_t *buf, lxb_dom_element_t *element);
+static bool append_escaped_display_element_name(buffer_t *buf, lxb_dom_element_t *element);
+static bool append_display_attribute_name(buffer_t *buf, lxb_dom_attr_t *attr);
+static int compare_attr_records(const void *a_ptr, const void *b_ptr);
+static bool render_attributes(render_ctx_t *ctx, lxb_dom_element_t *element, int indent_level);
+static void destroy_attr_records(attr_record_t *records, size_t count);
+static void render_node(render_ctx_t *ctx, lxb_dom_node_t *node, int indent_level);
+static void render_children(render_ctx_t *ctx, lxb_dom_node_t *first, int indent_level);
+static bool read_file(const char *path, lxb_char_t **data, size_t *len, const char **message);
+static bool parse_size(const char *value, size_t *out);
+static bool parse_args(int argc, char **argv, cli_options_t *options, const char **message);
+static void print_usage(FILE *stream);
+static void print_version(void);
+static void print_result(render_ctx_t *ctx);
+static void print_cli_error(const char *message);
+static bool context_to_tag(const char *context, lxb_tag_id_t *tag_id, lxb_ns_id_t *ns_id);
+static void render_full_document(render_ctx_t *ctx, const lxb_char_t *input, size_t input_len);
+static void render_fragment(render_ctx_t *ctx, const lxb_char_t *input, size_t input_len, const char *context);
+
+static void
+buffer_init(buffer_t *buf)
+{
+ buf->data = NULL;
+ buf->length = 0;
+ buf->capacity = 0;
+ buf->failed = false;
+}
+
+static void
+buffer_destroy(buffer_t *buf)
+{
+ free(buf->data);
+ buffer_init(buf);
+}
+
+static bool
+buffer_reserve(buffer_t *buf, size_t extra)
+{
+ size_t needed;
+ size_t next_capacity;
+ char *next;
+
+ if (buf->failed) {
+ return false;
+ }
+
+ if (extra > SIZE_MAX - buf->length - 1) {
+ buf->failed = true;
+ return false;
+ }
+
+ needed = buf->length + extra + 1;
+ if (needed <= buf->capacity) {
+ return true;
+ }
+
+ next_capacity = buf->capacity == 0 ? 256 : buf->capacity;
+ while (next_capacity < needed) {
+ if (next_capacity > SIZE_MAX / 2) {
+ next_capacity = needed;
+ break;
+ }
+ next_capacity *= 2;
+ }
+
+ next = (char *) realloc(buf->data, next_capacity);
+ if (next == NULL) {
+ buf->failed = true;
+ return false;
+ }
+
+ buf->data = next;
+ buf->capacity = next_capacity;
+ buf->data[buf->length] = '\0';
+ return true;
+}
+
+static bool
+buffer_append_mem(buffer_t *buf, const char *data, size_t len)
+{
+ if (!buffer_reserve(buf, len)) {
+ return false;
+ }
+
+ if (len > 0) {
+ memcpy(buf->data + buf->length, data, len);
+ buf->length += len;
+ }
+
+ buf->data[buf->length] = '\0';
+ return true;
+}
+
+static bool
+buffer_append_cstr(buffer_t *buf, const char *data)
+{
+ return buffer_append_mem(buf, data, strlen(data));
+}
+
+static bool
+buffer_append_char(buffer_t *buf, char ch)
+{
+ return buffer_append_mem(buf, &ch, 1);
+}
+
+static bool
+buffer_append_repeat(buffer_t *buf, const char *data, size_t len, size_t count)
+{
+ size_t i;
+
+ for (i = 0; i < count; i++) {
+ if (!buffer_append_mem(buf, data, len)) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+static char *
+buffer_take_cstr(buffer_t *buf)
+{
+ char *data;
+
+ if (!buffer_reserve(buf, 0)) {
+ return NULL;
+ }
+
+ data = buf->data;
+ buf->data = NULL;
+ buf->length = 0;
+ buf->capacity = 0;
+ return data;
+}
+
+static bool
+append_escaped_byte(buffer_t *buf, unsigned char byte)
+{
+ char hex[5];
+
+ switch (byte) {
+ case '\n':
+ return buffer_append_cstr(buf, "\\n");
+ case '\r':
+ return buffer_append_cstr(buf, "\\r");
+ case '\t':
+ return buffer_append_cstr(buf, "\\t");
+ case '\0':
+ return buffer_append_cstr(buf, "\\0");
+ case '\\':
+ return buffer_append_cstr(buf, "\\\\");
+ case '"':
+ return buffer_append_cstr(buf, "\\\"");
+ default:
+ if (byte < 0x20 || byte == 0x7f) {
+ snprintf(hex, sizeof(hex), "\\x%02X", byte);
+ return buffer_append_cstr(buf, hex);
+ }
+ return buffer_append_char(buf, (char) byte);
+ }
+}
+
+static bool
+append_escaped_scalar(buffer_t *buf, const lxb_char_t *data, size_t len, bool scrub)
+{
+ size_t i;
+ static const char replacement[] = "\xEF\xBF\xBD";
+
+ if (data == NULL) {
+ len = 0;
+ }
+
+ for (i = 0; i < len; i++) {
+ unsigned char byte = (unsigned char) data[i];
+
+ if (scrub) {
+ if (byte == '\0') {
+ if (!buffer_append_mem(buf, replacement, sizeof(replacement) - 1)) {
+ return false;
+ }
+ continue;
+ }
+
+ if (byte == '\r') {
+ if (i + 1 < len && data[i + 1] == '\n') {
+ i++;
+ }
+ byte = '\n';
+ }
+ }
+
+ if (!append_escaped_byte(buf, byte)) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+static bool
+append_json_string(buffer_t *buf, const char *data, size_t len)
+{
+ size_t i;
+ char hex[7];
+ static const char replacement[] = "\\uFFFD";
+
+ if (!buffer_append_char(buf, '"')) {
+ return false;
+ }
+
+ for (i = 0; i < len; i++) {
+ unsigned char byte = (unsigned char) data[i];
+
+ switch (byte) {
+ case '"':
+ if (!buffer_append_cstr(buf, "\\\"")) {
+ return false;
+ }
+ break;
+ case '\\':
+ if (!buffer_append_cstr(buf, "\\\\")) {
+ return false;
+ }
+ break;
+ case '\b':
+ if (!buffer_append_cstr(buf, "\\b")) {
+ return false;
+ }
+ break;
+ case '\f':
+ if (!buffer_append_cstr(buf, "\\f")) {
+ return false;
+ }
+ break;
+ case '\n':
+ if (!buffer_append_cstr(buf, "\\n")) {
+ return false;
+ }
+ break;
+ case '\r':
+ if (!buffer_append_cstr(buf, "\\r")) {
+ return false;
+ }
+ break;
+ case '\t':
+ if (!buffer_append_cstr(buf, "\\t")) {
+ return false;
+ }
+ break;
+ default:
+ if (byte < 0x20) {
+ snprintf(hex, sizeof(hex), "\\u%04X", byte);
+ if (!buffer_append_cstr(buf, hex)) {
+ return false;
+ }
+ } else if (byte < 0x80) {
+ if (!buffer_append_char(buf, (char) byte)) {
+ return false;
+ }
+ } else {
+ size_t sequence_len = 0;
+ bool valid = false;
+
+ if (byte >= 0xC2 && byte <= 0xDF) {
+ sequence_len = 2;
+ } else if (byte >= 0xE0 && byte <= 0xEF) {
+ sequence_len = 3;
+ } else if (byte >= 0xF0 && byte <= 0xF4) {
+ sequence_len = 4;
+ }
+
+ if (sequence_len > 0 && i + sequence_len <= len) {
+ unsigned char b1 = sequence_len > 1 ? (unsigned char) data[i + 1] : 0;
+ unsigned char b2 = sequence_len > 2 ? (unsigned char) data[i + 2] : 0;
+ unsigned char b3 = sequence_len > 3 ? (unsigned char) data[i + 3] : 0;
+ valid = true;
+ if (sequence_len >= 2 && (b1 < 0x80 || b1 > 0xBF)) {
+ valid = false;
+ }
+ if (sequence_len >= 3 && (b2 < 0x80 || b2 > 0xBF)) {
+ valid = false;
+ }
+ if (sequence_len >= 4 && (b3 < 0x80 || b3 > 0xBF)) {
+ valid = false;
+ }
+ if (byte == 0xE0 && b1 < 0xA0) {
+ valid = false;
+ }
+ if (byte == 0xED && b1 > 0x9F) {
+ valid = false;
+ }
+ if (byte == 0xF0 && b1 < 0x90) {
+ valid = false;
+ }
+ if (byte == 0xF4 && b1 > 0x8F) {
+ valid = false;
+ }
+ }
+
+ if (valid) {
+ if (!buffer_append_mem(buf, data + i, sequence_len)) {
+ return false;
+ }
+ i += sequence_len - 1;
+ } else if (!buffer_append_cstr(buf, replacement)) {
+ return false;
+ }
+ }
+ break;
+ }
+ }
+
+ return buffer_append_char(buf, '"');
+}
+
+static bool
+append_json_base64(buffer_t *buf, const char *data, size_t len)
+{
+ static const char alphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+ size_t i;
+
+ if (!buffer_append_char(buf, '"')) {
+ return false;
+ }
+
+ for (i = 0; i < len; i += 3) {
+ unsigned int b0 = (unsigned char) data[i];
+ unsigned int b1 = i + 1 < len ? (unsigned char) data[i + 1] : 0;
+ unsigned int b2 = i + 2 < len ? (unsigned char) data[i + 2] : 0;
+ char encoded[4];
+
+ encoded[0] = alphabet[b0 >> 2];
+ encoded[1] = alphabet[((b0 & 0x03) << 4) | (b1 >> 4)];
+ encoded[2] = i + 1 < len ? alphabet[((b1 & 0x0F) << 2) | (b2 >> 6)] : '=';
+ encoded[3] = i + 2 < len ? alphabet[b2 & 0x3F] : '=';
+
+ if (!buffer_append_mem(buf, encoded, sizeof(encoded))) {
+ return false;
+ }
+ }
+
+ return buffer_append_char(buf, '"');
+}
+
+static bool
+append_tree_line_indent(buffer_t *buf, int indent_level)
+{
+ return buffer_append_repeat(buf, " ", 2, (size_t) indent_level);
+}
+
+static bool
+append_ascii_lower(buffer_t *buf, const lxb_char_t *data, size_t len)
+{
+ size_t i;
+
+ for (i = 0; i < len; i++) {
+ unsigned char byte = (unsigned char) data[i];
+ if (byte >= 'A' && byte <= 'Z') {
+ byte = (unsigned char) tolower(byte);
+ }
+ if (!buffer_append_char(buf, (char) byte)) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+static bool
+append_display_element_name(buffer_t *buf, lxb_dom_element_t *element)
+{
+ size_t len = 0;
+ const lxb_char_t *name;
+ lxb_ns_id_t ns = lxb_dom_element_ns_id(element);
+
+ name = lxb_dom_element_local_name(element, &len);
+ if (ns == LXB_NS_HTML) {
+ return append_ascii_lower(buf, name, len);
+ }
+
+ if (ns == LXB_NS_SVG) {
+ name = lxb_dom_element_qualified_name(element, &len);
+ return buffer_append_cstr(buf, "svg ") && buffer_append_mem(buf, (const char *) name, len);
+ }
+
+ if (ns == LXB_NS_MATH) {
+ return buffer_append_cstr(buf, "math ") && buffer_append_mem(buf, (const char *) name, len);
+ }
+
+ name = lxb_dom_element_qualified_name(element, &len);
+ return buffer_append_mem(buf, (const char *) name, len);
+}
+
+static bool
+append_escaped_display_element_name(buffer_t *buf, lxb_dom_element_t *element)
+{
+ buffer_t display;
+ bool ok;
+
+ buffer_init(&display);
+ ok = append_display_element_name(&display, element)
+ && append_escaped_scalar(buf, (const lxb_char_t *) display.data, display.length, false);
+ buffer_destroy(&display);
+
+ return ok;
+}
+
+static bool
+append_display_attribute_name(buffer_t *buf, lxb_dom_attr_t *attr)
+{
+ size_t len = 0;
+ const lxb_char_t *name;
+ lxb_ns_id_t ns = (lxb_ns_id_t) lxb_dom_interface_node(attr)->ns;
+
+ if (ns == LXB_NS_XLINK) {
+ name = lxb_dom_attr_local_name(attr, &len);
+ return buffer_append_cstr(buf, "xlink ") && buffer_append_mem(buf, (const char *) name, len);
+ }
+
+ if (ns == LXB_NS_XML) {
+ name = lxb_dom_attr_local_name(attr, &len);
+ return buffer_append_cstr(buf, "xml ") && buffer_append_mem(buf, (const char *) name, len);
+ }
+
+ if (ns == LXB_NS_XMLNS) {
+ name = lxb_dom_attr_local_name(attr, &len);
+ return buffer_append_cstr(buf, "xmlns ") && buffer_append_mem(buf, (const char *) name, len);
+ }
+
+ name = lxb_dom_attr_qualified_name(attr, &len);
+ return buffer_append_mem(buf, (const char *) name, len);
+}
+
+static int
+compare_display_names(const char *a, const char *b)
+{
+ bool a_has_colon = strchr(a, ':') != NULL;
+ bool b_has_colon = strchr(b, ':') != NULL;
+ bool a_has_space = strchr(a, ' ') != NULL;
+ bool b_has_space = strchr(b, ' ') != NULL;
+ int compared;
+
+ if (a_has_colon != b_has_colon) {
+ return a_has_colon ? 1 : -1;
+ }
+
+ if (a_has_space != b_has_space) {
+ return a_has_space ? 1 : -1;
+ }
+
+ compared = strcmp(a, b);
+ if (compared < 0) {
+ return -1;
+ }
+ if (compared > 0) {
+ return 1;
+ }
+ return 0;
+}
+
+static int
+compare_attr_records(const void *a_ptr, const void *b_ptr)
+{
+ const attr_record_t *a = (const attr_record_t *) a_ptr;
+ const attr_record_t *b = (const attr_record_t *) b_ptr;
+ int compared = compare_display_names(a->sort_name, b->sort_name);
+
+ if (compared != 0) {
+ return compared;
+ }
+
+ return compare_display_names(a->render_name, b->render_name);
+}
+
+static bool
+render_attributes(render_ctx_t *ctx, lxb_dom_element_t *element, int indent_level)
+{
+ lxb_dom_attr_t *attr;
+ attr_record_t *records = NULL;
+ size_t count = 0;
+ size_t index = 0;
+ size_t i;
+ bool ok = false;
+
+ for (attr = lxb_dom_element_first_attribute(element); attr != NULL; attr = lxb_dom_element_next_attribute(attr)) {
+ count++;
+ }
+
+ if (count == 0) {
+ return true;
+ }
+
+ records = (attr_record_t *) calloc(count, sizeof(attr_record_t));
+ if (records == NULL) {
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not allocate attribute records.";
+ return false;
+ }
+
+ for (attr = lxb_dom_element_first_attribute(element); attr != NULL; attr = lxb_dom_element_next_attribute(attr)) {
+ buffer_t display;
+ buffer_t sort;
+ buffer_t render;
+ buffer_t value;
+ size_t value_len = 0;
+ const lxb_char_t *value_data;
+
+ buffer_init(&display);
+ buffer_init(&sort);
+ buffer_init(&render);
+ buffer_init(&value);
+
+ value_data = lxb_dom_attr_value(attr, &value_len);
+ if (
+ !append_display_attribute_name(&display, attr) ||
+ !append_escaped_scalar(&sort, (const lxb_char_t *) display.data, display.length, true) ||
+ !append_escaped_scalar(&render, (const lxb_char_t *) display.data, display.length, false) ||
+ !append_escaped_scalar(&value, value_data, value_len, false)
+ ) {
+ buffer_destroy(&display);
+ buffer_destroy(&sort);
+ buffer_destroy(&render);
+ buffer_destroy(&value);
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not render attributes.";
+ goto cleanup;
+ }
+
+ records[index].sort_name = buffer_take_cstr(&sort);
+ records[index].render_name = buffer_take_cstr(&render);
+ records[index].value = buffer_take_cstr(&value);
+
+ buffer_destroy(&display);
+ buffer_destroy(&sort);
+ buffer_destroy(&render);
+ buffer_destroy(&value);
+
+ if (records[index].sort_name == NULL || records[index].render_name == NULL || records[index].value == NULL) {
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not store attribute records.";
+ goto cleanup;
+ }
+
+ index++;
+ }
+
+ qsort(records, count, sizeof(attr_record_t), compare_attr_records);
+
+ for (i = 0; i < count; i++) {
+ if (
+ !append_tree_line_indent(&ctx->tree, indent_level) ||
+ !buffer_append_cstr(&ctx->tree, records[i].render_name) ||
+ !buffer_append_cstr(&ctx->tree, "=\"") ||
+ !buffer_append_cstr(&ctx->tree, records[i].value) ||
+ !buffer_append_cstr(&ctx->tree, "\"\n")
+ ) {
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not append attribute lines.";
+ goto cleanup;
+ }
+ }
+
+ ok = true;
+
+cleanup:
+ destroy_attr_records(records, count);
+ return ok;
+}
+
+static void
+destroy_attr_records(attr_record_t *records, size_t count)
+{
+ size_t i;
+
+ if (records == NULL) {
+ return;
+ }
+
+ for (i = 0; i < count; i++) {
+ free(records[i].sort_name);
+ free(records[i].render_name);
+ free(records[i].value);
+ }
+
+ free(records);
+}
+
+static bool
+increment_node_count(render_ctx_t *ctx)
+{
+ ctx->node_count++;
+ if (ctx->node_count > ctx->max_nodes) {
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "node-limit-exceeded";
+ ctx->message = "DOM node limit exceeded.";
+ return false;
+ }
+
+ return true;
+}
+
+static void
+render_node(render_ctx_t *ctx, lxb_dom_node_t *node, int indent_level)
+{
+ if (ctx->status != ORACLE_OK || node == NULL) {
+ return;
+ }
+
+ if (!increment_node_count(ctx)) {
+ return;
+ }
+
+ switch (node->type) {
+ case LXB_DOM_NODE_TYPE_DOCUMENT_TYPE: {
+ lxb_dom_document_type_t *doctype = lxb_dom_interface_document_type(node);
+ size_t name_len = 0;
+ size_t public_len = 0;
+ size_t system_len = 0;
+ const lxb_char_t *name = lxb_dom_document_type_name(doctype, &name_len);
+ const lxb_char_t *public_id = lxb_dom_document_type_public_id(doctype, &public_len);
+ const lxb_char_t *system_id = lxb_dom_document_type_system_id(doctype, &system_len);
+
+ if (
+ !buffer_append_cstr(&ctx->tree, "tree, name, name_len, false)
+ ) {
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not render doctype.";
+ return;
+ }
+
+ if (public_len > 0 || system_len > 0) {
+ if (
+ !buffer_append_cstr(&ctx->tree, " \"") ||
+ !append_escaped_scalar(&ctx->tree, public_id, public_len, false) ||
+ !buffer_append_cstr(&ctx->tree, "\" \"") ||
+ !append_escaped_scalar(&ctx->tree, system_id, system_len, false) ||
+ !buffer_append_char(&ctx->tree, '"')
+ ) {
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not render doctype identifiers.";
+ return;
+ }
+ }
+
+ if (!buffer_append_cstr(&ctx->tree, ">\n")) {
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not finish doctype.";
+ }
+ return;
+ }
+
+ case LXB_DOM_NODE_TYPE_ELEMENT: {
+ lxb_dom_element_t *element = lxb_dom_interface_element(node);
+
+ if (
+ !append_tree_line_indent(&ctx->tree, indent_level) ||
+ !buffer_append_char(&ctx->tree, '<') ||
+ !append_escaped_display_element_name(&ctx->tree, element) ||
+ !buffer_append_cstr(&ctx->tree, ">\n") ||
+ !render_attributes(ctx, element, indent_level + 1)
+ ) {
+ if (ctx->status == ORACLE_OK) {
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not render element.";
+ }
+ return;
+ }
+
+ if (node->local_name == LXB_TAG_TEMPLATE && node->ns == LXB_NS_HTML) {
+ lxb_html_template_element_t *template_element = lxb_html_interface_template(node);
+ if (!append_tree_line_indent(&ctx->tree, indent_level + 1) || !buffer_append_cstr(&ctx->tree, "content\n")) {
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not render template content marker.";
+ return;
+ }
+ if (template_element->content != NULL) {
+ render_children(ctx, template_element->content->node.first_child, indent_level + 2);
+ }
+ return;
+ }
+
+ render_children(ctx, node->first_child, indent_level + 1);
+ return;
+ }
+
+ case LXB_DOM_NODE_TYPE_TEXT:
+ case LXB_DOM_NODE_TYPE_CDATA_SECTION: {
+ lxb_dom_character_data_t *character_data = lxb_dom_interface_character_data(node);
+ if (character_data->data.length == 0) {
+ return;
+ }
+ if (
+ !append_tree_line_indent(&ctx->tree, indent_level) ||
+ !buffer_append_char(&ctx->tree, '"') ||
+ !append_escaped_scalar(&ctx->tree, character_data->data.data, character_data->data.length, false) ||
+ !buffer_append_cstr(&ctx->tree, "\"\n")
+ ) {
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not render text.";
+ }
+ return;
+ }
+
+ case LXB_DOM_NODE_TYPE_COMMENT: {
+ lxb_dom_character_data_t *character_data = lxb_dom_interface_character_data(node);
+ if (
+ !append_tree_line_indent(&ctx->tree, indent_level) ||
+ !buffer_append_cstr(&ctx->tree, "\n")
+ ) {
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not render comment.";
+ }
+ return;
+ }
+
+ default:
+ return;
+ }
+}
+
+static void
+render_children(render_ctx_t *ctx, lxb_dom_node_t *first, int indent_level)
+{
+ lxb_dom_node_t *child;
+
+ for (child = first; child != NULL && ctx->status == ORACLE_OK; child = child->next) {
+ render_node(ctx, child, indent_level);
+ }
+}
+
+static bool
+read_file(const char *path, lxb_char_t **data, size_t *len, const char **message)
+{
+ FILE *file;
+ long size;
+ size_t read_len;
+ lxb_char_t *bytes;
+
+ file = fopen(path, "rb");
+ if (file == NULL) {
+ *message = strerror(errno);
+ return false;
+ }
+
+ if (fseek(file, 0, SEEK_END) != 0) {
+ fclose(file);
+ *message = "Could not seek input file.";
+ return false;
+ }
+
+ size = ftell(file);
+ if (size < 0) {
+ fclose(file);
+ *message = "Could not determine input size.";
+ return false;
+ }
+
+ if (fseek(file, 0, SEEK_SET) != 0) {
+ fclose(file);
+ *message = "Could not rewind input file.";
+ return false;
+ }
+
+ bytes = (lxb_char_t *) malloc((size_t) size + 1);
+ if (bytes == NULL) {
+ fclose(file);
+ *message = "Could not allocate input buffer.";
+ return false;
+ }
+
+ read_len = fread(bytes, 1, (size_t) size, file);
+ if (read_len != (size_t) size || ferror(file)) {
+ free(bytes);
+ fclose(file);
+ *message = "Could not read input file.";
+ return false;
+ }
+
+ fclose(file);
+ bytes[read_len] = '\0';
+ *data = bytes;
+ *len = read_len;
+ return true;
+}
+
+static bool
+parse_size(const char *value, size_t *out)
+{
+ char *end = NULL;
+ unsigned long parsed;
+
+ errno = 0;
+ parsed = strtoul(value, &end, 10);
+ if (errno != 0 || end == value || *end != '\0' || parsed == 0) {
+ return false;
+ }
+
+ *out = (size_t) parsed;
+ return true;
+}
+
+static bool
+parse_args(int argc, char **argv, cli_options_t *options, const char **message)
+{
+ int i;
+
+ options->mode = NULL;
+ options->context = "body";
+ options->input_path = NULL;
+ options->max_nodes = 3000;
+ options->show_help = false;
+ options->show_version = false;
+
+ for (i = 1; i < argc; i++) {
+ const char *arg = argv[i];
+
+ if (strcmp(arg, "--help") == 0 || strcmp(arg, "-h") == 0) {
+ options->show_help = true;
+ return true;
+ }
+ if (strcmp(arg, "--version") == 0) {
+ options->show_version = true;
+ return true;
+ }
+
+ if (i + 1 >= argc) {
+ *message = "Missing option value.";
+ return false;
+ }
+
+ if (strcmp(arg, "--mode") == 0) {
+ options->mode = argv[++i];
+ } else if (strcmp(arg, "--context") == 0) {
+ options->context = argv[++i];
+ } else if (strcmp(arg, "--input") == 0) {
+ options->input_path = argv[++i];
+ } else if (strcmp(arg, "--max-nodes") == 0) {
+ if (!parse_size(argv[++i], &options->max_nodes)) {
+ *message = "Expected --max-nodes to be a positive integer.";
+ return false;
+ }
+ } else {
+ *message = "Unknown option.";
+ return false;
+ }
+ }
+
+ if (options->mode == NULL) {
+ *message = "Missing --mode.";
+ return false;
+ }
+ if (strcmp(options->mode, "full-document") != 0 && strcmp(options->mode, "fragment-body") != 0) {
+ *message = "Expected --mode full-document or fragment-body.";
+ return false;
+ }
+ if (options->input_path == NULL) {
+ *message = "Missing --input.";
+ return false;
+ }
+
+ return true;
+}
+
+static void
+print_usage(FILE *stream)
+{
+ fprintf(
+ stream,
+ "Usage: lexbor-tree-oracle --mode full-document|fragment-body --input PATH [--context TAG] [--max-nodes N]\n"
+ );
+}
+
+static void
+print_version(void)
+{
+ printf(
+ "{\"status\":\"ok\",\"oracle\":{\"kind\":\"lexbor-source\",\"lexborCommit\":\"%s\",\"lexborVersion\":\"%s\"}}\n",
+ HTML_API_FUZZ_LEXBOR_COMMIT,
+ LXB_HTML_VERSION_STRING
+ );
+}
+
+static void
+print_result(render_ctx_t *ctx)
+{
+ buffer_t json;
+ const char *status_text = ctx->status == ORACLE_OK
+ ? "ok"
+ : (ctx->status == ORACLE_UNSUPPORTED ? "unsupported" : "error");
+
+ buffer_init(&json);
+ buffer_append_cstr(&json, "{\n \"status\": ");
+ append_json_string(&json, status_text, strlen(status_text));
+ buffer_append_cstr(&json, ",\n \"oracle\": {\n \"kind\": \"lexbor-source\",\n \"lexborCommit\": ");
+ append_json_string(&json, HTML_API_FUZZ_LEXBOR_COMMIT, strlen(HTML_API_FUZZ_LEXBOR_COMMIT));
+ buffer_append_cstr(&json, ",\n \"lexborVersion\": ");
+ append_json_string(&json, LXB_HTML_VERSION_STRING, strlen(LXB_HTML_VERSION_STRING));
+ buffer_append_cstr(&json, "\n }");
+
+ if (ctx->status == ORACLE_OK) {
+ if (ctx->tree.length == 0) {
+ buffer_append_char(&ctx->tree, '\n');
+ } else {
+ if (ctx->tree.data[ctx->tree.length - 1] != '\n') {
+ buffer_append_char(&ctx->tree, '\n');
+ }
+ buffer_append_char(&ctx->tree, '\n');
+ }
+ buffer_append_cstr(&json, ",\n \"tree\": ");
+ append_json_string(&json, ctx->tree.data == NULL ? "" : ctx->tree.data, ctx->tree.length);
+ buffer_append_cstr(&json, ",\n \"treeBase64\": ");
+ append_json_base64(&json, ctx->tree.data == NULL ? "" : ctx->tree.data, ctx->tree.length);
+ }
+
+ buffer_append_cstr(&json, ",\n \"nodeCount\": ");
+ {
+ char count[32];
+ snprintf(count, sizeof(count), "%zu", ctx->node_count);
+ buffer_append_cstr(&json, count);
+ }
+
+ if (ctx->failure_class != NULL) {
+ buffer_append_cstr(&json, ",\n \"failureClass\": ");
+ append_json_string(&json, ctx->failure_class, strlen(ctx->failure_class));
+ }
+
+ if (ctx->message != NULL) {
+ const char *key = ctx->status == ORACLE_UNSUPPORTED ? "unsupported" : "error";
+ buffer_append_cstr(&json, ",\n \"");
+ buffer_append_cstr(&json, key);
+ if (ctx->status == ORACLE_UNSUPPORTED) {
+ buffer_append_cstr(&json, "\": {\n \"message\": ");
+ append_json_string(&json, ctx->message, strlen(ctx->message));
+ buffer_append_cstr(&json, "\n }");
+ } else {
+ buffer_append_cstr(&json, "\": ");
+ append_json_string(&json, ctx->message, strlen(ctx->message));
+ }
+ }
+
+ buffer_append_cstr(&json, "\n}\n");
+
+ if (json.failed) {
+ fputs("{\"status\":\"error\",\"failureClass\":\"oracle-renderer-error\",\"error\":\"Could not encode JSON result.\"}\n", stdout);
+ } else {
+ fwrite(json.data, 1, json.length, stdout);
+ }
+
+ buffer_destroy(&json);
+}
+
+static void
+print_cli_error(const char *message)
+{
+ render_ctx_t ctx;
+
+ ctx.status = ORACLE_ERROR;
+ ctx.failure_class = "oracle-cli-error";
+ ctx.message = message;
+ ctx.node_count = 0;
+ ctx.max_nodes = 0;
+ buffer_init(&ctx.tree);
+ print_result(&ctx);
+ buffer_destroy(&ctx.tree);
+}
+
+static bool
+context_to_tag(const char *context, lxb_tag_id_t *tag_id, lxb_ns_id_t *ns_id)
+{
+ *ns_id = LXB_NS_HTML;
+
+ if (strcmp(context, "body") == 0) {
+ *tag_id = LXB_TAG_BODY;
+ } else if (strcmp(context, "div") == 0) {
+ *tag_id = LXB_TAG_DIV;
+ } else if (strcmp(context, "p") == 0) {
+ *tag_id = LXB_TAG_P;
+ } else if (strcmp(context, "td") == 0) {
+ *tag_id = LXB_TAG_TD;
+ } else if (strcmp(context, "tr") == 0) {
+ *tag_id = LXB_TAG_TR;
+ } else if (strcmp(context, "table") == 0) {
+ *tag_id = LXB_TAG_TABLE;
+ } else if (strcmp(context, "caption") == 0) {
+ *tag_id = LXB_TAG_CAPTION;
+ } else if (strcmp(context, "colgroup") == 0) {
+ *tag_id = LXB_TAG_COLGROUP;
+ } else if (strcmp(context, "select") == 0) {
+ *tag_id = LXB_TAG_SELECT;
+ } else if (strcmp(context, "option") == 0) {
+ *tag_id = LXB_TAG_OPTION;
+ } else if (strcmp(context, "template") == 0) {
+ *tag_id = LXB_TAG_TEMPLATE;
+ } else if (strcmp(context, "title") == 0) {
+ *tag_id = LXB_TAG_TITLE;
+ } else if (strcmp(context, "textarea") == 0) {
+ *tag_id = LXB_TAG_TEXTAREA;
+ } else if (strcmp(context, "script") == 0) {
+ *tag_id = LXB_TAG_SCRIPT;
+ } else if (strcmp(context, "style") == 0) {
+ *tag_id = LXB_TAG_STYLE;
+ } else if (strcmp(context, "svg") == 0) {
+ *tag_id = LXB_TAG_SVG;
+ *ns_id = LXB_NS_SVG;
+ } else if (strcmp(context, "math") == 0) {
+ *tag_id = LXB_TAG_MATH;
+ *ns_id = LXB_NS_MATH;
+ } else {
+ return false;
+ }
+
+ return true;
+}
+
+static void
+render_full_document(render_ctx_t *ctx, const lxb_char_t *input, size_t input_len)
+{
+ lxb_status_t status;
+ lxb_html_document_t *document = lxb_html_document_create();
+
+ if (document == NULL) {
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not create Lexbor document.";
+ return;
+ }
+
+ status = lxb_html_document_parse(document, input, input_len);
+ if (status != LXB_STATUS_OK) {
+ lxb_html_document_destroy(document);
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-parse-error";
+ ctx->message = "Lexbor could not parse the input.";
+ return;
+ }
+
+ render_children(ctx, lxb_dom_interface_node(document)->first_child, 0);
+ lxb_html_document_destroy(document);
+}
+
+static void
+render_fragment(render_ctx_t *ctx, const lxb_char_t *input, size_t input_len, const char *context)
+{
+ lxb_status_t status;
+ lxb_html_parser_t *parser = NULL;
+ lxb_html_document_t *document = NULL;
+ lxb_dom_node_t *fragment = NULL;
+ lxb_tag_id_t tag_id;
+ lxb_ns_id_t ns_id;
+
+ if (!context_to_tag(context, &tag_id, &ns_id)) {
+ ctx->status = ORACLE_UNSUPPORTED;
+ ctx->failure_class = "oracle-unsupported";
+ ctx->message = "Unsupported fragment context.";
+ return;
+ }
+
+ parser = lxb_html_parser_create();
+ if (parser == NULL) {
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not create Lexbor parser.";
+ return;
+ }
+
+ status = lxb_html_parser_init(parser);
+ if (status != LXB_STATUS_OK) {
+ lxb_html_parser_destroy(parser);
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not initialize Lexbor parser.";
+ return;
+ }
+
+ document = lxb_html_document_create();
+ if (document == NULL) {
+ lxb_html_parser_destroy(parser);
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-renderer-error";
+ ctx->message = "Could not create Lexbor document.";
+ return;
+ }
+
+ fragment = lxb_html_parse_fragment_by_tag_id(parser, document, tag_id, ns_id, input, input_len);
+ if (fragment == NULL || lxb_html_parser_status(parser) != LXB_STATUS_OK) {
+ lxb_html_document_destroy(document);
+ lxb_html_parser_destroy(parser);
+ ctx->status = ORACLE_ERROR;
+ ctx->failure_class = "oracle-parse-error";
+ ctx->message = "Lexbor could not parse the fragment.";
+ return;
+ }
+
+ render_children(ctx, fragment->first_child, 0);
+ lxb_html_document_destroy(document);
+ lxb_html_parser_destroy(parser);
+}
+
+int
+main(int argc, char **argv)
+{
+ cli_options_t options;
+ const char *message = NULL;
+ lxb_char_t *input = NULL;
+ size_t input_len = 0;
+ render_ctx_t ctx;
+
+ if (!parse_args(argc, argv, &options, &message)) {
+ print_cli_error(message);
+ return EXIT_FAILURE;
+ }
+
+ if (options.show_help) {
+ print_usage(stdout);
+ return EXIT_SUCCESS;
+ }
+
+ if (options.show_version) {
+ print_version();
+ return EXIT_SUCCESS;
+ }
+
+ buffer_init(&ctx.tree);
+ ctx.status = ORACLE_OK;
+ ctx.failure_class = NULL;
+ ctx.message = NULL;
+ ctx.node_count = 0;
+ ctx.max_nodes = options.max_nodes;
+
+ if (!read_file(options.input_path, &input, &input_len, &message)) {
+ ctx.status = ORACLE_ERROR;
+ ctx.failure_class = "oracle-cli-error";
+ ctx.message = message;
+ print_result(&ctx);
+ buffer_destroy(&ctx.tree);
+ return EXIT_FAILURE;
+ }
+
+ if (strcmp(options.mode, "full-document") == 0) {
+ render_full_document(&ctx, input, input_len);
+ } else {
+ render_fragment(&ctx, input, input_len, options.context);
+ }
+
+ if (ctx.tree.failed && ctx.status == ORACLE_OK) {
+ ctx.status = ORACLE_ERROR;
+ ctx.failure_class = "oracle-renderer-error";
+ ctx.message = "Could not allocate tree output.";
+ }
+
+ print_result(&ctx);
+ free(input);
+ buffer_destroy(&ctx.tree);
+
+ return ctx.status == ORACLE_OK || ctx.status == ORACLE_UNSUPPORTED ? EXIT_SUCCESS : EXIT_FAILURE;
+}
diff --git a/tools/html-api-fuzz/preflight.sh b/tools/html-api-fuzz/preflight.sh
new file mode 100755
index 0000000000000..bf0665bb9b4e4
--- /dev/null
+++ b/tools/html-api-fuzz/preflight.sh
@@ -0,0 +1,60 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd -P)"
+MAX_SEEDS="${MAX_SEEDS:-100}"
+START_SEED="${START_SEED:-1}"
+SEED_STRIDE="${SEED_STRIDE:-1}"
+BATCH_SIZE="${BATCH_SIZE:-25}"
+MAX_INPUT_BYTES="${MAX_INPUT_BYTES:-4096}"
+OUTPUT_DIR="${OUTPUT_DIR:-${TMPDIR:-/tmp}/html-api-fuzz-preflight-$(date -u +%Y%m%dT%H%M%SZ)}"
+
+if [[ -e "$OUTPUT_DIR" ]]; then
+ echo "Preflight output already exists: $OUTPUT_DIR" >&2
+ exit 1
+fi
+
+LEXBOR_BIN="$SCRIPT_DIR/oracles/lexbor/build/lexbor-tree-oracle"
+HTML5EVER_BIN="$SCRIPT_DIR/oracles/html5ever/build/html5ever-tree-oracle"
+CHROME_BIN="$($SCRIPT_DIR/oracles/chrome/install.sh --print-path)"
+
+for binary in "$LEXBOR_BIN" "$HTML5EVER_BIN" "$CHROME_BIN"; do
+ if [[ ! -x "$binary" ]]; then
+ echo "Required pinned oracle is not built/installed: $binary" >&2
+ exit 1
+ fi
+done
+
+mkdir -p "$OUTPUT_DIR"
+common=(
+ --start-seed "$START_SEED"
+ --seed-stride "$SEED_STRIDE"
+ --max-seeds "$MAX_SEEDS"
+ --duration-seconds 0
+ --batch-size "$BATCH_SIZE"
+ --max-input-bytes "$MAX_INPUT_BYTES"
+)
+
+cd "$REPO_ROOT"
+php tools/html-api-fuzz/runner.php "${common[@]}" \
+ --output-dir "$OUTPUT_DIR/lexbor" \
+ --dom-oracle lexbor-source \
+ --lexbor-oracle-bin "$LEXBOR_BIN"
+
+php tools/html-api-fuzz/runner.php "${common[@]}" \
+ --output-dir "$OUTPUT_DIR/html5ever" \
+ --dom-oracle html5ever-source \
+ --html5ever-oracle-bin "$HTML5EVER_BIN"
+
+php tools/html-api-fuzz/runner.php "${common[@]}" \
+ --output-dir "$OUTPUT_DIR/chrome" \
+ --dom-oracle chrome-cdp \
+ --chrome-executable "$CHROME_BIN" \
+ --oracle-timeout-ms 15000
+
+php tools/html-api-fuzz/verify-preflight.php \
+ --run-dir "$OUTPUT_DIR" \
+ --max-seeds "$MAX_SEEDS" \
+ --start-seed "$START_SEED" \
+ --seed-stride "$SEED_STRIDE"
diff --git a/tools/html-api-fuzz/replay.php b/tools/html-api-fuzz/replay.php
new file mode 100755
index 0000000000000..490c000260cb4
--- /dev/null
+++ b/tools/html-api-fuzz/replay.php
@@ -0,0 +1,156 @@
+#!/usr/bin/env php
+= 0 ? $store->replay_for_attempt_id( $store_id ) : $store->replay_for_seed( $store_seed );
+ $store->close();
+ } catch ( \Throwable $e ) {
+ fwrite( STDERR, "Could not read store {$store_path}: {$e->getMessage()}\n" );
+ exit( 1 );
+ }
+ if ( null === $store_replay ) {
+ fwrite( STDERR, ( $store_id >= 0 ? "No stored replay for id {$store_id}" : "No stored replay for seed {$store_seed}" ) . " in {$store_path}.\n" );
+ exit( 1 );
+ }
+ $store_label = $store_id >= 0 ? 'id-' . $store_id : 'seed-' . $store_seed;
+ $replay_dir = \HtmlApiFuzz\option_string( $options, 'output-dir', dirname( $store_path ) . '/replay-' . $store_label . '-' . \HtmlApiFuzz\timestamp() );
+ \HtmlApiFuzz\ensure_dir( $replay_dir );
+ $replay_path = $replay_dir . '/source-replay.json';
+ \HtmlApiFuzz\write_json_file( $replay_path, $store_replay );
+ $options['output-dir'] = $replay_dir;
+}
+
+$replay = \HtmlApiFuzz\read_json_file( $replay_path );
+if ( ! $replay || ! array_key_exists( 'inputBase64', $replay ) ) {
+ fwrite( STDERR, "Invalid replay file: {$replay_path}\n" );
+ exit( 1 );
+}
+
+$output_dir = \HtmlApiFuzz\option_string( $options, 'output-dir', dirname( $replay_path ) . '/replay-' . \HtmlApiFuzz\timestamp() );
+$input = base64_decode( $replay['inputBase64'], true );
+if ( false === $input ) {
+ fwrite( STDERR, "Invalid base64 input in replay file: {$replay_path}\n" );
+ exit( 1 );
+}
+\HtmlApiFuzz\ensure_dir( $output_dir );
+$input_path = $output_dir . '/input.bin';
+file_put_contents( $input_path, $input );
+$payload_policy = \HtmlApiFuzz\option_string( $options, 'payload-policy', null );
+if ( null === $payload_policy ) {
+ $payload_policy = \HtmlApiFuzz\normalize_payload_policy_label( $replay['payloadPolicy'] ?? null )
+ ?? \HtmlApiFuzz\normalize_payload_policy_label( $replay['generator']['payloadPolicy'] ?? null );
+}
+$original_generator = is_array( $replay['generator'] ?? null ) ? $replay['generator'] : ( $replay['originalGenerator'] ?? null );
+$source_replay = \HtmlApiFuzz\replay_source_metadata( $replay_path, $replay );
+$git_metadata_base64 = \HtmlApiFuzz\git_metadata_base64( \HtmlApiFuzz\git_metadata() );
+$oracle_options = $options;
+if ( null === \HtmlApiFuzz\option_string( $oracle_options, 'dom-oracle', null ) ) {
+ $oracle_options['dom-oracle'] = $replay['options']['domOracle'] ?? $replay['oracle']['kind'] ?? \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE;
+}
+if ( null === \HtmlApiFuzz\option_string( $oracle_options, 'lexbor-oracle-bin', null ) && is_string( $replay['options']['lexborOracleBin'] ?? null ) ) {
+ $oracle_options['lexbor-oracle-bin'] = $replay['options']['lexborOracleBin'];
+}
+$stored_oracle_options = array(
+ 'html5ever-oracle-bin' => 'html5everOracleBin',
+ 'chrome-oracle-script' => 'chromeOracleScript',
+ 'chrome-executable' => 'chromeExecutable',
+ 'chrome-socket' => 'chromeSocket',
+ 'node-bin' => 'nodeBin',
+);
+foreach ( $stored_oracle_options as $option_name => $replay_key ) {
+ if ( null === \HtmlApiFuzz\option_string( $oracle_options, $option_name, null ) && is_string( $replay['options'][ $replay_key ] ?? null ) ) {
+ $oracle_options[ $option_name ] = $replay['options'][ $replay_key ];
+ }
+}
+$stored_oracle_timeout_ms = $replay['options']['oracleTimeoutMs'] ?? null;
+if ( null === \HtmlApiFuzz\option_string( $oracle_options, 'oracle-timeout-ms', null ) && is_numeric( $stored_oracle_timeout_ms ) ) {
+ $oracle_options['oracle-timeout-ms'] = (string) (int) $stored_oracle_timeout_ms;
+}
+$oracle_renderer = \HtmlApiFuzz\OracleRenderer::from_options( $oracle_options );
+$oracle_renderer->start_run_service( $output_dir );
+$oracle_renderer->assert_replay_compatible( is_array( $replay['oracle'] ?? null ) ? $replay['oracle'] : array() );
+$oracle_worker_args = $oracle_renderer->worker_args();
+
+$args = array(
+ __DIR__ . '/worker.php',
+ '--input-file',
+ $input_path,
+ '--mode',
+ $replay['mode'] ?? \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ '--profile',
+ $replay['profile'] ?? 'replay',
+ '--seed',
+ (string) ( $replay['seed'] ?? 1 ),
+ '--output-dir',
+ $output_dir,
+ '--max-tokens',
+ (string) \HtmlApiFuzz\option_int( $options, 'max-tokens', (int) ( $replay['limits']['maxTokens'] ?? 2000 ) ),
+ '--max-nodes',
+ (string) \HtmlApiFuzz\option_int( $options, 'max-nodes', (int) ( $replay['limits']['maxNodes'] ?? 3000 ) ),
+ '--git-metadata-base64',
+ $git_metadata_base64,
+);
+if ( null !== $payload_policy ) {
+ $args[] = '--payload-policy';
+ $args[] = $payload_policy;
+}
+$fragment_context = $replay['fragmentContext'] ?? null;
+if ( is_string( $fragment_context ) && 'body' !== $fragment_context ) {
+ $args[] = '--fragment-context';
+ $args[] = $fragment_context;
+}
+if ( \HtmlApiFuzz\option_bool( $options, 'fail-unsupported', (bool) ( $replay['options']['failUnsupported'] ?? false ) ) ) {
+ $args[] = '--fail-unsupported';
+}
+foreach ( $oracle_worker_args as $arg ) {
+ $args[] = $arg;
+}
+
+$proc = \HtmlApiFuzz\run_php_process( $args, \HtmlApiFuzz\repo_root(), \HtmlApiFuzz\option_int( $options, 'timeout-ms', 2500 ), $output_dir . '/worker.log' );
+$result = \HtmlApiFuzz\read_json_file( $output_dir . '/result.json' );
+$output_replay = \HtmlApiFuzz\read_json_file( $output_dir . '/replay.json' );
+if ( is_array( $output_replay ) && is_array( $original_generator ) ) {
+ $output_replay['originalGenerator'] = $original_generator;
+}
+if ( is_array( $output_replay ) ) {
+ $output_replay['sourceReplay'] = $source_replay;
+ \HtmlApiFuzz\write_json_file( $output_dir . '/replay.json', $output_replay );
+}
+echo \HtmlApiFuzz\json_encode_safe(
+ array(
+ 'ok' => $result['ok'] ?? false,
+ 'status' => $result['status'] ?? 'missing-result',
+ 'result' => $output_dir . '/result.json',
+ 'replay' => $output_dir . '/replay.json',
+ 'worker' => array(
+ 'code' => $proc['code'],
+ 'timedOut' => $proc['timedOut'],
+ 'durationMs' => $proc['durationMs'],
+ 'logPath' => $proc['logPath'],
+ ),
+ 'signature' => $result['signature'] ?? null,
+ 'oracleFinding' => $result['oracleFinding'] ?? null,
+ )
+) . "\n";
+exit( ( $result['ok'] ?? false ) ? 0 : 2 );
diff --git a/tools/html-api-fuzz/runner.php b/tools/html-api-fuzz/runner.php
new file mode 100755
index 0000000000000..fe63b9fc4ac86
--- /dev/null
+++ b/tools/html-api-fuzz/runner.php
@@ -0,0 +1,519 @@
+#!/usr/bin/env php
+start_run_service( $output_dir );
+$oracle_metadata = $oracle_renderer->metadata();
+if ( true !== ( $oracle_metadata['available'] ?? false ) ) {
+ throw new RuntimeException( 'Selected oracle is unavailable: ' . ( $oracle_metadata['error'] ?? $oracle_metadata['versionError'] ?? 'unknown error' ) );
+}
+$oracle_worker_args = $oracle_renderer->worker_args();
+$oracle_startup_grace_ms = \HtmlApiFuzz\OracleRenderer::KIND_CHROME_CDP === $oracle_renderer->kind()
+ ? max( 15000, $timeout_ms )
+ : 0;
+
+$state = array(
+ 'schemaVersion' => 1,
+ 'kind' => 'html-api-fuzz-runner-state',
+ 'startedAt' => gmdate( 'c' ),
+ 'updatedAt' => gmdate( 'c' ),
+ 'outputDir' => $output_dir,
+ 'cwd' => getcwd() ?: null,
+ 'startSeed' => $start_seed,
+ 'seedStride' => $seed_stride,
+ 'nextSeed' => $start_seed,
+ 'profile' => $profile,
+ 'mode' => $mode,
+ 'payloadPolicy' => $payload_policy,
+ 'maxInputBytes' => $max_input_bytes > 0 ? $max_input_bytes : null,
+ 'git' => $git_metadata,
+ 'oracle' => $oracle_metadata,
+ 'maxKeepPerSignature' => $max_keep_per_signature,
+ 'keepAllArtifacts' => $keep_all_artifacts,
+ 'stopFile' => $stop_file,
+ // Longest legitimate silence between state writes: one full batch worker
+ // run. The watcher floors its dead-runner presumption on this.
+ 'batchBudgetMs' => ( $timeout_ms * $batch_size ) + $oracle_startup_grace_ms,
+ 'successes' => 0,
+ 'failures' => 0,
+ 'unsupported' => 0,
+ 'oracleParseErrors' => 0,
+ 'oracleUnsupported' => 0,
+ 'oracleTolerated' => 0,
+ 'oracleFindings' => 0,
+ 'stopReason' => null,
+);
+\HtmlApiFuzz\write_json_file( $state_path, $state );
+\HtmlApiFuzz\append_ndjson( $events_path, array( 'at' => gmdate( 'c' ), 'kind' => 'runner-start', 'outputDir' => $output_dir, 'git' => $git_metadata, 'oracle' => $oracle_metadata ) );
+file_put_contents( $runner_log, '[' . gmdate( 'c' ) . "] runner started outputDir={$output_dir}\n", FILE_APPEND );
+
+$has_deadline = $duration_seconds > 0;
+$deadline = $has_deadline ? microtime( true ) + $duration_seconds : null;
+$seed = $start_seed;
+$count = 0;
+
+function html_api_fuzz_runner_worker_args( int $seed, string $output_dir, string $profile, string $mode, string $payload_policy, int $max_tokens, int $max_nodes, string $git_metadata_base64, bool $fail_unsupported, int $max_input_bytes, int $corpus_percent, int $batch_count, int $seed_stride, array $oracle_worker_args ): array {
+ $args = array(
+ __DIR__ . '/worker.php',
+ '--seed',
+ (string) $seed,
+ '--profile',
+ $profile,
+ '--mode',
+ $mode,
+ '--payload-policy',
+ $payload_policy,
+ '--output-dir',
+ $output_dir,
+ '--max-tokens',
+ (string) $max_tokens,
+ '--max-nodes',
+ (string) $max_nodes,
+ '--git-metadata-base64',
+ $git_metadata_base64,
+ );
+ if ( $batch_count > 1 ) {
+ $args[] = '--batch-count';
+ $args[] = (string) $batch_count;
+ $args[] = '--seed-stride';
+ $args[] = (string) $seed_stride;
+ }
+ if ( $fail_unsupported ) {
+ $args[] = '--fail-unsupported';
+ }
+ if ( $max_input_bytes > 0 ) {
+ $args[] = '--max-input-bytes';
+ $args[] = (string) $max_input_bytes;
+ }
+ $args[] = '--corpus-mutate-percent';
+ $args[] = (string) $corpus_percent;
+ foreach ( $oracle_worker_args as $arg ) {
+ $args[] = $arg;
+ }
+ return $args;
+}
+
+/**
+ * A batch worker killed mid-write can leave truncated JSON behind; such a
+ * file must behave like a missing one so the seed takes the isolation
+ * fallback instead of fataling the lane.
+ */
+function html_api_fuzz_runner_read_json_or_null( string $path ) {
+ try {
+ return \HtmlApiFuzz\read_json_file( $path );
+ } catch ( \RuntimeException $e ) {
+ return null;
+ }
+}
+
+$pending_batch = array();
+$batch_log = null;
+$batch_keep_log = false;
+
+// Already-computed batch results are always processed; the deadline and seed
+// budget gate only the formation of new batches.
+while ( array() !== $pending_batch || ( ( ! $has_deadline || microtime( true ) < $deadline ) && ( 0 === $max_seeds || $count < $max_seeds ) ) ) {
+ if ( array() === $pending_batch ) {
+ /*
+ * Graceful stop: the already-computed batch above has fully drained
+ * and is recorded; honor a stop request (or a stop-on-failure from
+ * inside the batch) before committing to a new batch.
+ */
+ if ( null !== $state['stopReason'] ) {
+ break;
+ }
+ if ( is_file( $stop_file ) ) {
+ $state['stopReason'] = 'stop-requested';
+ \HtmlApiFuzz\append_ndjson( $events_path, array( 'at' => gmdate( 'c' ), 'kind' => 'stop-requested', 'stopFile' => $stop_file ) );
+ break;
+ }
+
+ /*
+ * Run a batch of seeds in one worker process: per-seed process spawns
+ * dominate wall-clock otherwise. Seeds missing a result.json after
+ * the batch (the batch process died or timed out mid-way) are re-run
+ * individually below.
+ */
+ $batch_count = $batch_size;
+ if ( 0 !== $max_seeds ) {
+ $batch_count = min( $batch_count, $max_seeds - $count );
+ }
+ $batch_count = max( 1, $batch_count );
+ $batch_seeds = array();
+ for ( $i = 0; $i < $batch_count; $i++ ) {
+ $batch_seeds[] = $seed + ( $i * $seed_stride );
+ }
+
+ // Batch logs live outside the prunable seed directories; clean
+ // batches drop theirs once fully recorded (see end of loop).
+ $batch_log = $output_dir . '/logs/batch-' . $batch_seeds[0] . '.log';
+ $batch_keep_log = false;
+ \HtmlApiFuzz\ensure_dir( dirname( $batch_log ) );
+ \HtmlApiFuzz\append_ndjson( $events_path, array( 'at' => gmdate( 'c' ), 'kind' => 'batch-start', 'seeds' => $batch_seeds, 'logPath' => $batch_log ) );
+ $batch_args = html_api_fuzz_runner_worker_args( $batch_seeds[0], $output_dir, $profile, $mode, $payload_policy, $max_tokens, $max_nodes, $git_metadata_base64, $fail_unsupported, $max_input_bytes, $corpus_percent, $batch_count, $seed_stride, $oracle_worker_args );
+ $batch_proc = \HtmlApiFuzz\run_php_process( $batch_args, $repo_root, ( $timeout_ms * $batch_count ) + $oracle_startup_grace_ms, $batch_log );
+ $pending_batch = $batch_seeds;
+ }
+
+ $current_seed = array_shift( $pending_batch );
+ $attempt_dir = $output_dir . '/seed-' . $current_seed . '/primary';
+ \HtmlApiFuzz\ensure_dir( $attempt_dir );
+ $log_path = $attempt_dir . '/worker.log';
+
+ $result = html_api_fuzz_runner_read_json_or_null( $attempt_dir . '/result.json' );
+ $proc = $batch_proc;
+ if ( null === $result ) {
+ // Isolation fallback: re-run this seed in its own process.
+ $batch_keep_log = true;
+ $args = html_api_fuzz_runner_worker_args( $current_seed, $attempt_dir, $profile, $mode, $payload_policy, $max_tokens, $max_nodes, $git_metadata_base64, $fail_unsupported, $max_input_bytes, $corpus_percent, 1, $seed_stride, $oracle_worker_args );
+ $proc = \HtmlApiFuzz\run_php_process( $args, $repo_root, $timeout_ms + $oracle_startup_grace_ms, $log_path );
+ $result = html_api_fuzz_runner_read_json_or_null( $attempt_dir . '/result.json' );
+ }
+
+ if ( null === $result ) {
+ $replay = html_api_fuzz_runner_read_json_or_null( $attempt_dir . '/replay.json' );
+ $result = array(
+ 'ok' => false,
+ 'status' => $proc['timedOut'] ? 'timeout' : 'worker-failed',
+ 'failureClass' => $proc['timedOut'] ? 'timeout' : 'worker-failed',
+ 'failureSnippet' => substr( $proc['output'], -2000 ),
+ 'seed' => $current_seed,
+ 'profile' => is_array( $replay ) ? ( $replay['profile'] ?? $profile ) : $profile,
+ 'mode' => is_array( $replay ) ? ( $replay['mode'] ?? $mode ) : $mode,
+ 'payloadPolicy' => is_array( $replay ) ? ( $replay['payloadPolicy'] ?? $payload_policy ) : $payload_policy,
+ 'generator' => is_array( $replay ) ? ( $replay['generator'] ?? null ) : null,
+ 'inputSource' => is_array( $replay ) ? ( $replay['inputSource'] ?? null ) : null,
+ 'inputSha1' => is_array( $replay ) ? ( $replay['inputSha1'] ?? null ) : null,
+ 'inputLength' => is_array( $replay ) ? ( $replay['inputLength'] ?? null ) : null,
+ 'oracle' => is_array( $replay ) ? ( $replay['oracle'] ?? $oracle_metadata ) : $oracle_metadata,
+ 'paths' => array(
+ 'outputDir' => $attempt_dir,
+ 'resultPath' => $attempt_dir . '/result.json',
+ 'replayPath' => $attempt_dir . '/replay.json',
+ ),
+ );
+ $signature = \HtmlApiFuzz\Signature::from_result( $result );
+ if ( null !== $signature ) {
+ $result['signature'] = $signature;
+ }
+ \HtmlApiFuzz\write_json_file( $attempt_dir . '/result.json', $result );
+ }
+
+ $result['seed'] = $result['seed'] ?? $current_seed;
+ $result['profile'] = $result['profile'] ?? $profile;
+ $result['mode'] = $result['mode'] ?? $mode;
+ $result['payloadPolicy'] = $result['payloadPolicy'] ?? $payload_policy;
+ $result['paths'] = $result['paths'] ?? array(
+ 'outputDir' => $attempt_dir,
+ 'resultPath' => $attempt_dir . '/result.json',
+ 'replayPath' => $attempt_dir . '/replay.json',
+ );
+ if ( ! ( $result['ok'] ?? false ) && empty( $result['signature'] ) ) {
+ $signature = \HtmlApiFuzz\Signature::from_result( $result );
+ if ( null !== $signature ) {
+ $result['signature'] = $signature;
+ }
+ \HtmlApiFuzz\write_json_file( $attempt_dir . '/result.json', $result );
+ }
+
+ $attempt_ok = (bool) ( $result['ok'] ?? false );
+ $has_oracle_finding = is_array( $result['oracleFinding'] ?? null );
+
+ /*
+ * Artifact retention: every attempt is regenerable from its seed, so seed
+ * directories are kept only for failures and oracle findings, and only
+ * until their signature has enough exemplars on disk. Everything else
+ * lives in the SQLite store (failures and oracle findings include their
+ * result and replay JSON there, so a pruned finding remains reproducible).
+ */
+ $retain_artifacts = $keep_all_artifacts;
+ $retain_failure_artifacts = $keep_all_artifacts && ! $attempt_ok;
+ $retain_oracle_artifacts = $keep_all_artifacts && $has_oracle_finding;
+ $replay = null;
+ if ( ! $attempt_ok || $has_oracle_finding ) {
+ $replay_path = $attempt_dir . '/replay.json';
+ $replay = html_api_fuzz_runner_read_json_or_null( $replay_path );
+
+ if ( ! $keep_all_artifacts ) {
+ $retention_targets = array();
+ if ( ! $attempt_ok ) {
+ $retention_targets[] = array(
+ 'hash' => $result['signature']['hash'] ?? null,
+ 'kind' => 'failure',
+ );
+ }
+ if ( $has_oracle_finding ) {
+ $retention_targets[] = array(
+ 'hash' => $result['oracleFinding']['signature']['hash'] ?? null,
+ 'kind' => 'oracle',
+ );
+ }
+
+ if ( ! is_array( $replay ) ) {
+ // Without a replay document the files are the only reproduction.
+ $retain_failure_artifacts = ! $attempt_ok;
+ $retain_oracle_artifacts = $has_oracle_finding;
+ } else {
+ /*
+ * Count exemplar directories still on disk rather than rows
+ * ever written: a restarted runner re-records seeds, and row
+ * counting would saturate the cap without keeping anything.
+ */
+ foreach ( $retention_targets as $target ) {
+ $signature_hash = $target['hash'];
+ if ( null === $signature_hash ) {
+ if ( 'oracle' === $target['kind'] ) {
+ $retain_oracle_artifacts = true;
+ } else {
+ $retain_failure_artifacts = true;
+ }
+ continue;
+ }
+ $retained_seeds = 'oracle' === $target['kind']
+ ? $result_store->oracle_retained_seeds( $signature_hash )
+ : $result_store->retained_seeds( $signature_hash );
+ $retained_on_disk = 0;
+ foreach ( $retained_seeds as $retained_seed ) {
+ if ( is_dir( $output_dir . '/seed-' . $retained_seed ) ) {
+ ++$retained_on_disk;
+ }
+ }
+ if ( $retained_on_disk < $max_keep_per_signature ) {
+ if ( 'oracle' === $target['kind'] ) {
+ $retain_oracle_artifacts = true;
+ } else {
+ $retain_failure_artifacts = true;
+ }
+ }
+ }
+ }
+ $retain_artifacts = $retain_failure_artifacts || $retain_oracle_artifacts;
+ }
+
+ if ( $retain_artifacts ) {
+ // Over-cap repeats of a known signature do not justify keeping
+ // their batch log; retained exemplars and re-runs do.
+ $batch_keep_log = true;
+ }
+
+ if ( is_array( $replay ) ) {
+ $replay['result'] = array(
+ 'ok' => $result['ok'] ?? false,
+ 'status' => $result['status'] ?? 'unknown',
+ 'failureClass' => $result['failureClass'] ?? null,
+ 'signature' => $result['signature'] ?? null,
+ 'oracleFinding' => $result['oracleFinding'] ?? null,
+ 'oracle' => $result['oracle'] ?? $replay['oracle'] ?? $oracle_metadata,
+ 'resultPath' => $retain_artifacts ? $attempt_dir . '/result.json' : null,
+ );
+ $replay['oracle'] = $replay['oracle'] ?? $result['oracle'] ?? $oracle_metadata;
+ $replay['signature'] = $result['signature'] ?? null;
+ $replay['oracleFinding'] = $result['oracleFinding'] ?? null;
+ if ( $retain_artifacts ) {
+ \HtmlApiFuzz\write_json_file( $replay_path, $replay );
+ }
+ }
+ }
+
+ $summary = array(
+ 'kind' => $attempt_ok ? ( $has_oracle_finding ? 'oracle-finding' : 'attempt' ) : 'failure',
+ 'ok' => $attempt_ok,
+ 'status' => $result['status'] ?? 'unknown',
+ 'failureClass' => $result['failureClass'] ?? null,
+ 'seed' => $current_seed,
+ 'profile' => $result['profile'] ?? $profile,
+ 'mode' => $result['mode'] ?? $mode,
+ 'payloadPolicy' => $result['payloadPolicy'] ?? $payload_policy,
+ 'generator' => $result['generator'] ?? null,
+ 'inputSource' => $result['inputSource'] ?? null,
+ 'inputSha1' => $result['inputSha1'] ?? null,
+ 'inputLength' => $result['inputLength'] ?? null,
+ 'signature' => $result['signature'] ?? null,
+ 'oracleFinding' => $result['oracleFinding'] ?? null,
+ 'oracle' => $result['oracle'] ?? $oracle_metadata,
+ 'artifactsRetained' => $retain_artifacts,
+ 'failureArtifactsRetained' => $retain_failure_artifacts,
+ 'oracleArtifactsRetained' => $retain_oracle_artifacts,
+ 'resultPath' => $retain_artifacts ? $attempt_dir . '/result.json' : null,
+ 'replayPath' => $retain_artifacts ? $attempt_dir . '/replay.json' : null,
+ // Batch-executed seeds have no per-seed worker.log; point at a log
+ // that exists (isolation re-run log, else the shared batch log).
+ 'logPath' => $retain_artifacts ? ( is_file( $log_path ) ? $log_path : $batch_log ) : null,
+ 'durationMs' => $proc['durationMs'],
+ 'workerCode' => $proc['code'],
+ 'workerTimedOut'=> $proc['timedOut'],
+ );
+ $attempt_id = $result_store->record_attempt(
+ $summary,
+ ( $attempt_ok && ! $has_oracle_finding ) ? null : $result,
+ ( ( $attempt_ok && ! $has_oracle_finding ) || ! is_array( $replay ) ) ? null : $replay
+ );
+ if ( ! $retain_artifacts && is_array( $replay ) && ( ! $attempt_ok || $has_oracle_finding ) ) {
+ // The stored copy outlives the pruned files; its replay command must
+ // point at this exact row, not just at a seed that a later restart may
+ // record again with different input.
+ $replay['command'] = array(
+ 'program' => PHP_BINARY,
+ 'args' => array(
+ 'tools/html-api-fuzz/replay.php',
+ '--store',
+ $output_dir . '/' . \HtmlApiFuzz\ResultStore::FILENAME,
+ '--id',
+ (string) $attempt_id,
+ ),
+ 'cwd' => $repo_root,
+ );
+ $result_store->update_replay_for_attempt( $attempt_id, $replay );
+ }
+ if ( ! $retain_artifacts && ! $result_store->seed_artifacts_retained( $current_seed ) ) {
+ // The retained check covers earlier rows: a re-run of a previously
+ // retained seed must not delete the exemplar directory they cite.
+ \HtmlApiFuzz\remove_dir_recursive( $output_dir . '/seed-' . $current_seed );
+ }
+
+ if ( $summary['ok'] ) {
+ if ( $has_oracle_finding ) {
+ ++$state['oracleFindings'];
+ }
+ if ( 'unsupported' === $summary['status'] ) {
+ ++$state['unsupported'];
+ } elseif ( 'oracle-parse-error' === $summary['status'] ) {
+ // Inputs the selected oracle cannot parse receive no differential
+ // coverage; track the loss per class so long runs surface how
+ // much of the input space the oracle gives up on.
+ ++$state['oracleParseErrors'];
+ } elseif ( 'oracle-unsupported' === $summary['status'] ) {
+ ++$state['oracleUnsupported'];
+ } elseif ( 'oracle-tolerated' === $summary['status'] ) {
+ ++$state['oracleTolerated'];
+ } else {
+ ++$state['successes'];
+ }
+ } else {
+ ++$state['failures'];
+ if ( $stop_on_failure ) {
+ $state['stopReason'] = 'stop-on-failure';
+ }
+ }
+
+ $seed += $seed_stride;
+ ++$count;
+ $state['nextSeed'] = $seed;
+ $state['updatedAt'] = gmdate( 'c' );
+ \HtmlApiFuzz\write_json_file( $state_path, $state );
+
+ // A stop-on-failure stop reason is honored at the top of the loop, after
+ // the rest of the batch has been recorded and pruned. Under
+ // --keep-all-artifacts every recorded logPath must keep existing.
+ if ( array() === $pending_batch && ! $batch_keep_log && ! $keep_all_artifacts && null !== $batch_log ) {
+ @unlink( $batch_log );
+ }
+}
+
+if ( null === $state['stopReason'] ) {
+ $state['stopReason'] = ( 0 !== $max_seeds && $count >= $max_seeds ) ? 'max-seeds' : 'duration-elapsed';
+}
+$state['updatedAt'] = gmdate( 'c' );
+\HtmlApiFuzz\write_json_file( $state_path, $state );
+\HtmlApiFuzz\append_ndjson( $events_path, array( 'at' => gmdate( 'c' ), 'kind' => 'runner-stop', 'stopReason' => $state['stopReason'], 'nextSeed' => $seed ) );
+$result_store->close();
+$oracle_renderer->stop_run_service();
+echo \HtmlApiFuzz\json_encode_safe( $state ) . "\n";
diff --git a/tools/html-api-fuzz/setup-oracles.sh b/tools/html-api-fuzz/setup-oracles.sh
new file mode 100755
index 0000000000000..0537c8ff18fbc
--- /dev/null
+++ b/tools/html-api-fuzz/setup-oracles.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd -P)"
+
+"$SCRIPT_DIR/oracles/lexbor/build.sh"
+
+"$SCRIPT_DIR/oracles/html5ever/install-rust.sh"
+export CARGO_HOME="$REPO_ROOT/.cache/html5ever/rust/cargo"
+export RUSTUP_HOME="$REPO_ROOT/.cache/html5ever/rust/rustup"
+export PATH="$CARGO_HOME/bin:$PATH"
+"$SCRIPT_DIR/oracles/html5ever/build.sh"
+
+"$SCRIPT_DIR/oracles/chrome/install.sh"
+
+printf '%s\n' 'All pinned HTML API fuzz oracles are ready.'
diff --git a/tools/html-api-fuzz/start-continuous-run-tmux.sh b/tools/html-api-fuzz/start-continuous-run-tmux.sh
new file mode 100755
index 0000000000000..1098f0f3c8b67
--- /dev/null
+++ b/tools/html-api-fuzz/start-continuous-run-tmux.sh
@@ -0,0 +1,193 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd -P)"
+
+SESSION="${SESSION:-html-api-fuzz-$(date -u +%Y%m%dT%H%M%SZ)}"
+RUN_DIR="${RUN_DIR:-artifacts/html-api-fuzz/run-$(date -u +%Y%m%dT%H%M%SZ)}"
+TRIAGE_DIR="${TRIAGE_DIR:-$RUN_DIR/triage}"
+
+LANES="${LANES:-4}"
+MIN_FREE_GB="${MIN_FREE_GB:-25}"
+MAX_INPUT_BYTES="${MAX_INPUT_BYTES:-4096}"
+PAYLOAD_POLICY="${PAYLOAD_POLICY:-}"
+DOM_ORACLE="${DOM_ORACLE:-lexbor-source}"
+LEXBOR_ORACLE_BIN="${LEXBOR_ORACLE_BIN:-}"
+HTML5EVER_ORACLE_BIN="${HTML5EVER_ORACLE_BIN:-}"
+CHROME_ORACLE_SCRIPT="${CHROME_ORACLE_SCRIPT:-}"
+CHROME_EXECUTABLE="${CHROME_EXECUTABLE:-}"
+NODE_BIN="${NODE_BIN:-}"
+MAX_MINIMIZE="${MAX_MINIMIZE:-1}"
+WATCHER_INTERVAL_SECONDS="${WATCHER_INTERVAL_SECONDS:-10}"
+ORCHESTRATOR_INTERVAL_SECONDS="${ORCHESTRATOR_INTERVAL_SECONDS:-120}"
+ORCHESTRATOR_MAX_CONCURRENT="${ORCHESTRATOR_MAX_CONCURRENT:-1}"
+ORCHESTRATOR_MODE="${ORCHESTRATOR_MODE:-classify}"
+if [[ -z "${ORCHESTRATOR_SANDBOX+x}" ]]; then
+ if [[ "$ORCHESTRATOR_MODE" == "fix" ]]; then
+ ORCHESTRATOR_SANDBOX="workspace-write"
+ else
+ ORCHESTRATOR_SANDBOX="read-only"
+ fi
+fi
+CODEX_BIN="${CODEX_BIN:-codex}"
+CODEX_MODEL="${CODEX_MODEL:-}"
+
+if ! command -v tmux >/dev/null 2>&1; then
+ echo "tmux is required." >&2
+ exit 1
+fi
+
+if ! command -v php >/dev/null 2>&1; then
+ echo "php is required." >&2
+ exit 1
+fi
+
+if tmux has-session -t "$SESSION" 2>/dev/null; then
+ echo "tmux session already exists: $SESSION" >&2
+ exit 1
+fi
+
+available_kb="$(df -Pk "$REPO_ROOT" | awk 'NR == 2 { print $4 }')"
+required_kb="$(( MIN_FREE_GB * 1024 * 1024 ))"
+if (( available_kb < required_kb )); then
+ echo "Refusing to start: only $(( available_kb / 1024 / 1024 )) GiB free under $REPO_ROOT; need at least ${MIN_FREE_GB} GiB." >&2
+ echo "Free space or lower MIN_FREE_GB if this is intentional." >&2
+ exit 1
+fi
+
+shell_join() {
+ local out=""
+ local arg
+ for arg in "$@"; do
+ printf -v arg "%q" "$arg"
+ out+=" $arg"
+ done
+ printf "%s" "${out# }"
+}
+
+pane_command() {
+ local label="$1"
+ shift
+
+ local command
+ command="$(shell_join "$@")"
+ printf "export RUN_DIR=%q TRIAGE_DIR=%q; echo '[%s] starting'; echo '[%s] RUN_DIR=%s'; %s; status=\$?; echo; echo '[%s] exited with status '\$status; exec \"\${SHELL:-/bin/zsh}\" -l" \
+ "$RUN_DIR" \
+ "$TRIAGE_DIR" \
+ "$label" \
+ "$label" \
+ "$RUN_DIR" \
+ "$command" \
+ "$label"
+}
+
+launcher_command=(
+ php tools/html-api-fuzz/launcher.php
+ --lanes "$LANES"
+ --duration-seconds 0
+ --max-seeds 0
+ --output-dir "$RUN_DIR"
+ --dom-oracle "$DOM_ORACLE"
+)
+
+if [[ "$MAX_INPUT_BYTES" != "0" ]]; then
+ launcher_command+=( --max-input-bytes "$MAX_INPUT_BYTES" )
+fi
+
+if [[ "$PAYLOAD_POLICY" != "" ]]; then
+ launcher_command+=( --payload-policy "$PAYLOAD_POLICY" )
+fi
+
+if [[ "$LEXBOR_ORACLE_BIN" != "" ]]; then
+ launcher_command+=( --lexbor-oracle-bin "$LEXBOR_ORACLE_BIN" )
+fi
+
+if [[ "$HTML5EVER_ORACLE_BIN" != "" ]]; then
+ launcher_command+=( --html5ever-oracle-bin "$HTML5EVER_ORACLE_BIN" )
+fi
+
+if [[ "$CHROME_ORACLE_SCRIPT" != "" ]]; then
+ launcher_command+=( --chrome-oracle-script "$CHROME_ORACLE_SCRIPT" )
+fi
+
+if [[ "$CHROME_EXECUTABLE" != "" ]]; then
+ launcher_command+=( --chrome-executable "$CHROME_EXECUTABLE" )
+fi
+
+if [[ "$NODE_BIN" != "" ]]; then
+ launcher_command+=( --node-bin "$NODE_BIN" )
+fi
+
+watcher_command=(
+ php tools/html-api-fuzz/watcher.php
+ --run-dir "$RUN_DIR"
+ --state-dir "$TRIAGE_DIR"
+ --interval-seconds "$WATCHER_INTERVAL_SECONDS"
+ --max-minimize "$MAX_MINIMIZE"
+)
+
+orchestrator_command=(
+ php tools/html-api-fuzz/codex-triage-orchestrator.php
+ --triage-dir "$TRIAGE_DIR"
+ --diagnostics-dir "$RUN_DIR/diagnostics"
+ --repo-root "$REPO_ROOT"
+ --codex-bin "$CODEX_BIN"
+ --mode "$ORCHESTRATOR_MODE"
+ --sandbox "$ORCHESTRATOR_SANDBOX"
+ --max-concurrent "$ORCHESTRATOR_MAX_CONCURRENT"
+ --interval-seconds "$ORCHESTRATOR_INTERVAL_SECONDS"
+)
+
+if [[ "$CODEX_MODEL" != "" ]]; then
+ orchestrator_command+=( --model "$CODEX_MODEL" )
+fi
+
+launcher_pane="$(
+ tmux new-session -d -P -F '#{pane_id}' -s "$SESSION" -n fuzz -c "$REPO_ROOT" \
+ "$(pane_command launcher "${launcher_command[@]}")"
+)"
+
+for _ in {1..30}; do
+ if [[ -f "$REPO_ROOT/$RUN_DIR/launcher-state.json" ]]; then
+ break
+ fi
+ sleep 1
+done
+
+if [[ ! -f "$REPO_ROOT/$RUN_DIR/launcher-state.json" ]]; then
+ echo "Launcher did not create $RUN_DIR/launcher-state.json within 30 seconds." >&2
+ echo "Inspect with: tmux attach -t $SESSION" >&2
+ exit 1
+fi
+
+watcher_pane="$(
+ tmux split-window -P -F '#{pane_id}' -t "$launcher_pane" -h -c "$REPO_ROOT" \
+ "$(pane_command watcher "${watcher_command[@]}")"
+)"
+
+for _ in {1..30}; do
+ if [[ -f "$REPO_ROOT/$TRIAGE_DIR/state.json" ]]; then
+ break
+ fi
+ sleep 1
+done
+
+if [[ ! -f "$REPO_ROOT/$TRIAGE_DIR/state.json" ]]; then
+ echo "Watcher did not create $TRIAGE_DIR/state.json within 30 seconds." >&2
+ echo "Inspect with: tmux attach -t $SESSION" >&2
+ exit 1
+fi
+
+tmux split-window -P -F '#{pane_id}' -t "$watcher_pane" -v -c "$REPO_ROOT" \
+ "$(pane_command orchestrator "${orchestrator_command[@]}")" >/dev/null
+
+tmux select-layout -t "$SESSION" tiled >/dev/null
+tmux set-environment -t "$SESSION" RUN_DIR "$RUN_DIR"
+tmux set-environment -t "$SESSION" TRIAGE_DIR "$TRIAGE_DIR"
+
+echo "session=$SESSION"
+echo "run_dir=$RUN_DIR"
+echo "triage_dir=$TRIAGE_DIR"
+echo "attach=tmux attach -t $SESSION"
+tmux list-panes -t "$SESSION" -F '#{pane_index}: #{pane_current_command} dead=#{pane_dead}'
diff --git a/tools/html-api-fuzz/stop.php b/tools/html-api-fuzz/stop.php
new file mode 100644
index 0000000000000..9246d46e94d0b
--- /dev/null
+++ b/tools/html-api-fuzz/stop.php
@@ -0,0 +1,390 @@
+#!/usr/bin/env php
+ 2 && ':' === $path[1] && ( '/' === $path[2] || '\\' === $path[2] ) ) );
+}
+
+function html_api_fuzz_stop_add_advertised_stop_file( array &$stop_files, array &$warnings, string $stop_file, array $state ): void {
+ if ( '' === $stop_file ) {
+ $warnings[] = 'runner stopFile is empty; wrote the run-dir stop file, but the exact watched file may be unknown.';
+ return;
+ }
+
+ if ( html_api_fuzz_stop_path_is_absolute( $stop_file ) ) {
+ html_api_fuzz_stop_add_stop_file( $stop_files, $stop_file );
+ return;
+ }
+
+ if ( is_string( $state['cwd'] ?? null ) && '' !== $state['cwd'] && html_api_fuzz_stop_path_is_absolute( $state['cwd'] ) ) {
+ html_api_fuzz_stop_add_stop_file( $stop_files, rtrim( $state['cwd'], DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR . $stop_file );
+ return;
+ }
+
+ // Older or malformed runner state did not record an absolute cwd; the
+ // exact relative path is unknowable from another process.
+ html_api_fuzz_stop_add_stop_file( $stop_files, $stop_file );
+ $warnings[] = 'relative runner stopFile has no recorded absolute cwd; wrote a caller-cwd candidate, but the exact watched file may be unknown.';
+}
+
+function html_api_fuzz_stop_state_is_stale( array $state, float $stale_seconds, int $fallback_mtime = 0 ): bool {
+ $updated_at = strtotime( (string) ( $state['updatedAt'] ?? '' ) );
+ if ( false === $updated_at && $fallback_mtime > 0 ) {
+ $updated_at = $fallback_mtime;
+ }
+
+ return false !== $updated_at && ( time() - $updated_at ) > $stale_seconds;
+}
+
+function html_api_fuzz_stop_runner_is_active( array $state, float $stale_seconds, int $state_mtime ): bool {
+ if ( ! array_key_exists( 'stopReason', $state ) || null !== $state['stopReason'] ) {
+ return false;
+ }
+
+ $runner_stale_seconds = max( $stale_seconds, 2.0 * ( (int) ( $state['batchBudgetMs'] ?? 0 ) ) / 1000.0 );
+
+ return ! html_api_fuzz_stop_state_is_stale( $state, $runner_stale_seconds, $state_mtime );
+}
+
+function html_api_fuzz_stop_state_looks_runner_like( array $state ): bool {
+ $kind = $state['kind'] ?? null;
+ if ( 'html-api-fuzz-runner-state' === $kind ) {
+ return true;
+ }
+ if ( 'html-api-fuzz-launcher-state' === $kind ) {
+ return false;
+ }
+
+ return array_key_exists( 'stopFile', $state ) || array_key_exists( 'stopReason', $state ) || array_key_exists( 'batchBudgetMs', $state );
+}
+
+function html_api_fuzz_stop_read_state_with_retry( string $state_path, int $attempts = 3 ) {
+ $last_exception = null;
+ for ( $i = 0; $i < $attempts; ++$i ) {
+ try {
+ return \HtmlApiFuzz\read_json_file( $state_path );
+ } catch ( \RuntimeException $e ) {
+ $last_exception = $e;
+ usleep( 50000 );
+ }
+ }
+
+ if ( null !== $last_exception ) {
+ throw $last_exception;
+ }
+
+ return null;
+}
+
+/**
+ * Describes one candidate run directory: whether any of its runners or its
+ * launcher still looks unfinished, and how recently its state files changed.
+ * Directory mtimes are useless here — lanes write into subdirectories.
+ */
+function html_api_fuzz_stop_inspect_run_dir( string $path, float $stale_seconds ): ?array {
+ $default_stop_file = rtrim( $path, DIRECTORY_SEPARATOR ) . '/STOP';
+ $state_paths = array_merge(
+ is_file( $path . '/launcher-state.json' ) ? array( $path . '/launcher-state.json' ) : array(),
+ is_file( $path . '/state.json' ) ? array( $path . '/state.json' ) : array(),
+ glob( $path . '/lane-*/state.json' ) ?: array()
+ );
+ if ( array() === $state_paths ) {
+ return null;
+ }
+
+ $active = false;
+ $latest_mtime = 0;
+ $stop_files = array();
+ $warnings = array();
+ foreach ( $state_paths as $state_path ) {
+ $mtime = filemtime( $state_path );
+ $state_mtime = false !== $mtime ? (int) $mtime : 0;
+ if ( false !== $mtime ) {
+ $latest_mtime = max( $latest_mtime, (int) $mtime );
+ }
+ try {
+ $state = html_api_fuzz_stop_read_state_with_retry( $state_path );
+ } catch ( \RuntimeException $e ) {
+ // Mid-write or corrupt state: the advertised stop file is unknowable.
+ $is_stale = 0 !== $state_mtime && ( time() - $state_mtime ) > $stale_seconds;
+ if ( ! $is_stale ) {
+ $active = true;
+ }
+ html_api_fuzz_stop_add_stop_file( $stop_files, $default_stop_file );
+ $warnings[] = "could not read {$state_path}; writing only the run-dir stop file for that state.";
+ continue;
+ }
+ if ( ! is_array( $state ) ) {
+ $is_stale = 0 !== $state_mtime && ( time() - $state_mtime ) > $stale_seconds;
+ if ( ! $is_stale ) {
+ $active = true;
+ }
+ html_api_fuzz_stop_add_stop_file( $stop_files, $default_stop_file );
+ $warnings[] = "could not read {$state_path}; writing only the run-dir stop file for that state.";
+ continue;
+ }
+ $kind = $state['kind'] ?? null;
+ if ( 'html-api-fuzz-launcher-state' === $kind && false === ( $state['finished'] ?? null ) && ! html_api_fuzz_stop_state_is_stale( $state, $stale_seconds, $state_mtime ) ) {
+ $active = true;
+ html_api_fuzz_stop_add_stop_file( $stop_files, $default_stop_file );
+ }
+ if ( html_api_fuzz_stop_state_looks_runner_like( $state ) ) {
+ if ( 'html-api-fuzz-runner-state' !== $kind ) {
+ $warnings[] = "runner-like state {$state_path} has missing or unknown kind; treating it as runner state.";
+ }
+ $runner_active = html_api_fuzz_stop_runner_is_active( $state, $stale_seconds, $state_mtime );
+ $runner_unknown = ! array_key_exists( 'stopReason', $state );
+ if ( array_key_exists( 'stopFile', $state ) && is_string( $state['stopFile'] ) ) {
+ html_api_fuzz_stop_add_advertised_stop_file( $stop_files, $warnings, $state['stopFile'], $state );
+ } elseif ( $runner_active || $runner_unknown ) {
+ html_api_fuzz_stop_add_stop_file( $stop_files, $default_stop_file );
+ $warnings[] = 'runner stopFile is missing or malformed; wrote the run-dir stop file, but the exact watched file may be unknown.';
+ } else {
+ html_api_fuzz_stop_add_stop_file( $stop_files, $default_stop_file );
+ }
+ if ( $runner_active ) {
+ $active = true;
+ html_api_fuzz_stop_add_stop_file( $stop_files, $default_stop_file );
+ }
+ }
+ }
+ html_api_fuzz_stop_add_stop_file( $stop_files, $default_stop_file );
+
+ return array(
+ 'path' => $path,
+ 'active' => $active,
+ 'mtime' => $latest_mtime,
+ 'stopFiles' => $stop_files,
+ 'warnings' => $warnings,
+ );
+}
+
+/**
+ * The most recently active unfinished run, falling back to the most recently
+ * active run of any state.
+ */
+function html_api_fuzz_stop_candidate_is_better( array $candidate, ?array $best ): bool {
+ if ( null === $best ) {
+ return true;
+ }
+ if ( $candidate['active'] !== $best['active'] ) {
+ return $candidate['active'];
+ }
+ if ( $candidate['mtime'] !== $best['mtime'] ) {
+ return $candidate['mtime'] > $best['mtime'];
+ }
+
+ return strcmp( $candidate['path'], $best['path'] ) > 0;
+}
+
+function html_api_fuzz_stop_latest_run_dir( string $artifacts_dir, float $stale_seconds ): ?array {
+ $items = @scandir( $artifacts_dir );
+ if ( false === $items ) {
+ return null;
+ }
+
+ $best = null;
+ foreach ( $items as $item ) {
+ if ( '.' === $item || '..' === $item ) {
+ continue;
+ }
+ $path = $artifacts_dir . DIRECTORY_SEPARATOR . $item;
+ if ( ! is_dir( $path ) ) {
+ continue;
+ }
+ $candidate = html_api_fuzz_stop_inspect_run_dir( $path, $stale_seconds );
+ if ( null === $candidate ) {
+ continue;
+ }
+ if ( html_api_fuzz_stop_candidate_is_better( $candidate, $best ) ) {
+ $best = $candidate;
+ }
+ }
+
+ return $best;
+}
+
+$options = \HtmlApiFuzz\parse_cli_options( $argv );
+if ( \HtmlApiFuzz\option_bool( $options, 'help', false ) || \HtmlApiFuzz\option_bool( $options, 'h', false ) ) {
+ html_api_fuzz_stop_usage();
+ exit( 0 );
+}
+
+$run_dir = \HtmlApiFuzz\option_string( $options, 'run-dir', $options['_'][0] ?? null );
+if ( array_key_exists( 'run-dir', $options ) && ( true === $options['run-dir'] || null === $run_dir || '' === $run_dir ) ) {
+ fwrite( STDERR, "Expected --run-dir to be a non-empty path.\n" );
+ exit( 1 );
+}
+$stop_file_override = \HtmlApiFuzz\option_string( $options, 'stop-file', null );
+if ( array_key_exists( 'stop-file', $options ) && ( true === $options['stop-file'] || null === $stop_file_override || '' === $stop_file_override ) ) {
+ fwrite( STDERR, "Expected --stop-file to be a non-empty path.\n" );
+ exit( 1 );
+}
+if ( array_key_exists( 'stop-stale-seconds', $options ) && true === $options['stop-stale-seconds'] ) {
+ fwrite( STDERR, "Expected --stop-stale-seconds to be numeric.\n" );
+ exit( 1 );
+}
+if ( array_key_exists( 'stop-stale-seconds', $options ) && ! is_numeric( $options['stop-stale-seconds'] ) ) {
+ fwrite( STDERR, "Expected --stop-stale-seconds to be numeric.\n" );
+ exit( 1 );
+}
+if ( array_key_exists( 'artifacts-dir', $options ) && ( true === $options['artifacts-dir'] || '' === $options['artifacts-dir'] ) ) {
+ fwrite( STDERR, "Expected --artifacts-dir to be a non-empty path.\n" );
+ exit( 1 );
+}
+if ( null === $run_dir && null !== $stop_file_override && array_key_exists( 'artifacts-dir', $options ) ) {
+ fwrite( STDERR, "Pass --run-dir with --artifacts-dir --stop-file, or pass only --stop-file to write a known stop file directly.\n" );
+ exit( 1 );
+}
+
+$looks_finished = false;
+$stale_seconds = max( 10.0, \HtmlApiFuzz\option_float( $options, 'stop-stale-seconds', 120.0 ) );
+$candidate = null;
+$direct_stop_file_only = null === $run_dir && null !== $stop_file_override;
+if ( null === $run_dir && ! $direct_stop_file_only ) {
+ $artifacts_dir = \HtmlApiFuzz\option_string( $options, 'artifacts-dir', \HtmlApiFuzz\repo_root() . '/artifacts/html-api-fuzz' );
+ $candidate = html_api_fuzz_stop_latest_run_dir( $artifacts_dir, $stale_seconds );
+ if ( null === $candidate ) {
+ fwrite( STDERR, "No run directory found under {$artifacts_dir}; pass --run-dir.\n" );
+ exit( 1 );
+ }
+ $run_dir = $candidate['path'];
+ $looks_finished = ! $candidate['active'];
+ if ( $looks_finished ) {
+ fwrite( STDERR, "Warning: no unfinished run found; targeting {$run_dir}, which already looks stopped.\n" );
+ }
+}
+
+if ( null !== $run_dir && ! is_dir( $run_dir ) ) {
+ fwrite( STDERR, "Not a directory: {$run_dir}\n" );
+ exit( 1 );
+}
+
+if ( null !== $run_dir && null === $candidate ) {
+ $candidate = html_api_fuzz_stop_inspect_run_dir( $run_dir, $stale_seconds );
+ if ( null === $candidate && null === $stop_file_override ) {
+ $candidate = array(
+ 'path' => $run_dir,
+ 'active' => true,
+ 'mtime' => 0,
+ 'stopFiles' => array(),
+ 'warnings' => array( 'no run state found; writing only the run-dir stop file.' ),
+ );
+ }
+}
+
+$warnings = array();
+foreach ( $candidate['warnings'] ?? array() as $warning ) {
+ if ( is_string( $warning ) ) {
+ $warnings[] = $warning;
+ }
+}
+
+$stop_files = array();
+if ( null !== $stop_file_override ) {
+ html_api_fuzz_stop_add_stop_file( $stop_files, $stop_file_override );
+}
+foreach ( $candidate['stopFiles'] ?? array() as $stop_file ) {
+ if ( is_string( $stop_file ) ) {
+ html_api_fuzz_stop_add_stop_file( $stop_files, $stop_file );
+ }
+}
+if ( array() === $stop_files && null !== $run_dir ) {
+ html_api_fuzz_stop_add_stop_file( $stop_files, rtrim( $run_dir, DIRECTORY_SEPARATOR ) . '/STOP' );
+}
+if ( null !== $run_dir ) {
+ html_api_fuzz_stop_add_stop_file( $stop_files, rtrim( $run_dir, DIRECTORY_SEPARATOR ) . '/STOP' );
+}
+if ( array() === $stop_files ) {
+ fwrite( STDERR, "No stop file could be determined.\n" );
+ exit( 1 );
+}
+
+$write_stop_files = $stop_files;
+$primary_stop_file = $stop_files[0];
+if ( null !== $run_dir ) {
+ $run_stop_file = rtrim( $run_dir, DIRECTORY_SEPARATOR ) . '/STOP';
+ $primary_stop_file = $run_stop_file;
+ $write_stop_files = array( $run_stop_file );
+ foreach ( $stop_files as $stop_file ) {
+ html_api_fuzz_stop_add_stop_file( $write_stop_files, $stop_file );
+ }
+}
+
+$already = true;
+foreach ( $write_stop_files as $stop_file ) {
+ $already = $already && is_file( $stop_file );
+}
+
+$write_failures = array();
+foreach ( $write_stop_files as $stop_file ) {
+ if ( is_file( $stop_file ) ) {
+ continue;
+ }
+ try {
+ \HtmlApiFuzz\write_json_file(
+ $stop_file,
+ array(
+ 'kind' => 'html-api-fuzz-stop-request',
+ 'requestedAt' => gmdate( 'c' ),
+ )
+ );
+ } catch ( \Throwable $e ) {
+ $write_failures[] = "Could not write {$stop_file}: {$e->getMessage()}";
+ continue;
+ }
+ // write_json_file does not check the file_put_contents result; the file's
+ // existence is the truth condition the runner acts on.
+ if ( ! is_file( $stop_file ) ) {
+ $write_failures[] = "Could not write {$stop_file}.";
+ }
+}
+if ( array() !== $write_failures ) {
+ foreach ( $write_failures as $failure ) {
+ fwrite( STDERR, "{$failure}\n" );
+ }
+ exit( 1 );
+}
+
+$ok = array() === $warnings;
+foreach ( $warnings as $warning ) {
+ fwrite( STDERR, "Warning: {$warning}\n" );
+}
+
+echo \HtmlApiFuzz\json_encode_safe(
+ array(
+ 'ok' => $ok,
+ 'runDir' => $run_dir,
+ 'stopFile' => $primary_stop_file,
+ 'stopFiles' => $write_stop_files,
+ 'alreadyRequested' => $already,
+ 'looksFinished' => $looks_finished,
+ 'warnings' => $warnings,
+ )
+) . "\n";
+exit( $ok ? 0 : 2 );
diff --git a/tools/html-api-fuzz/tests/codex-triage-orchestrator-smoke.php b/tools/html-api-fuzz/tests/codex-triage-orchestrator-smoke.php
new file mode 100755
index 0000000000000..dd835dee19a17
--- /dev/null
+++ b/tools/html-api-fuzz/tests/codex-triage-orchestrator-smoke.php
@@ -0,0 +1,179 @@
+#!/usr/bin/env php
+ array(
+ 123456 => array(
+ 'status' => 'minimized',
+ 'minimizeOutputDir' => $triage,
+ ),
+ "bad\nhash" => array(
+ 'status' => 'minimized',
+ 'minimizeOutputDir' => $triage,
+ ),
+ ),
+ )
+);
+html_api_fuzz_codex_test_assert( 1 === count( $candidates ), 'Numeric signature hashes should be accepted and unsafe hashes skipped.' );
+html_api_fuzz_codex_test_assert( '123456' === $candidates[0][0], 'Numeric signature hash should be cast to a string.' );
+
+$signature = array(
+ 'failureClass' => 'tree-mismatch',
+ 'status' => 'minimized',
+ 'resultPath' => '/etc/passwd',
+ 'minimizeResult' => $run_dir . '/minimize-result.json',
+ 'minimizeOutputDir'=> $run_dir,
+);
+file_put_contents( $run_dir . '/minimize-result.json', "{}\n" );
+$prompt = html_api_fuzz_codex_prompt_for_signature(
+ array(
+ 'triageDir' => $triage,
+ 'diagnosticsDir' => $diag,
+ 'repoRoot' => $repo_root,
+ 'runDir' => $run_dir,
+ 'mode' => 'classify',
+ ),
+ '123456',
+ $signature
+);
+html_api_fuzz_codex_test_assert( false === strpos( $prompt, '/etc/passwd' ), 'Prompt should not include artifacts outside the run or triage directories.' );
+html_api_fuzz_codex_test_assert( false !== strpos( $prompt, $run_dir . '/minimize-result.json' ), 'Prompt should include expected run artifacts.' );
+
+html_api_fuzz_codex_test_expect_exception(
+ static function () use ( $triage, $diag, $repo_root, $run_dir, $signature ): void {
+ $unsafe = $signature;
+ $unsafe['failureClass'] = "tree\nmismatch";
+ html_api_fuzz_codex_prompt_for_signature(
+ array(
+ 'triageDir' => $triage,
+ 'diagnosticsDir' => $diag,
+ 'repoRoot' => $repo_root,
+ 'runDir' => $run_dir,
+ 'mode' => 'classify',
+ ),
+ '123456',
+ $unsafe
+ );
+ },
+ 'Prompt metadata with control characters should be rejected.'
+);
+
+$signature_dir = $diag . '/123456';
+\HtmlApiFuzz\ensure_dir( $signature_dir );
+file_put_contents( $signature_dir . '/claim.json', json_encode( array( 'pid' => getmypid(), 'claimedAtUnix' => 0 ) ) . "\n" );
+$claimed = html_api_fuzz_codex_claim_signature( $diag, '123456', $signature, 1 );
+html_api_fuzz_codex_test_assert( null !== $claimed, 'Stale claims should be reclaimed even when the old PID is alive.' );
+html_api_fuzz_codex_test_assert( 1 === count( glob( $signature_dir . '/claim.stale.*.json' ) ), 'Stale claim should be archived.' );
+
+file_put_contents( $signature_dir . '/done.json', "null\n" );
+$claimed = html_api_fuzz_codex_claim_signature( $diag, '123456', $signature, 1 );
+html_api_fuzz_codex_test_assert( null !== $claimed, 'Malformed done metadata should not be treated as a successful completed job.' );
+html_api_fuzz_codex_test_assert( 1 <= count( glob( $signature_dir . '/done.failed.*.json' ) ), 'Malformed done metadata should be archived for retry.' );
+
+/*
+ * Main-loop STOP handling: with a STOP file in the run directory (resolved
+ * from the watcher state's runDir), the orchestrator must exit cleanly
+ * without launching anything.
+ */
+\HtmlApiFuzz\write_json_file(
+ $triage . '/state.json',
+ array(
+ 'kind' => 'html-api-fuzz-triage-state',
+ 'runDir' => $run_dir,
+ 'signatures' => array(),
+ )
+);
+file_put_contents( $run_dir . '/STOP', "{}\n" );
+$stop_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/codex-triage-orchestrator.php',
+ '--triage-dir',
+ $triage,
+ '--diagnostics-dir',
+ $diag,
+ '--repo-root',
+ $repo_root,
+ '--codex-bin',
+ 'false',
+ ),
+ $repo_root,
+ 30000
+);
+html_api_fuzz_codex_test_assert( 0 === $stop_proc['code'] && ! $stop_proc['timedOut'], 'Orchestrator should exit cleanly when a STOP file is present.' );
+html_api_fuzz_codex_test_assert( false !== strpos( $stop_proc['output'], 'stop requested' ), 'Orchestrator should report the stop request.' );
+html_api_fuzz_codex_test_assert( false === strpos( $stop_proc['output'], 'launched ' ), 'Orchestrator should not launch jobs after a stop request.' );
+
+echo "codex triage orchestrator smoke tests passed\n";
diff --git a/tools/html-api-fuzz/tests/generator-policy-smoke.php b/tools/html-api-fuzz/tests/generator-policy-smoke.php
new file mode 100644
index 0000000000000..5e3bc482358a2
--- /dev/null
+++ b/tools/html-api-fuzz/tests/generator-policy-smoke.php
@@ -0,0 +1,1119 @@
+#!/usr/bin/env php
+' . str_repeat( '<' . $tag . '>', 4 ) ) ) {
+ return true;
+ }
+ }
+ return false;
+
+ case 'active-formatting:same-tag-distinct-attrs':
+ foreach ( $formatting_tags as $tag ) {
+ if ( false !== strpos( $input, '<' . $tag . ' data-af="0"><' . $tag . ' data-af="1"><' . $tag . ' data-af="2"><' . $tag . ' data-af="3">' ) ) {
+ return true;
+ }
+ }
+ return false;
+
+ case 'active-formatting:same-tag-matching-attrs':
+ foreach ( $formatting_tags as $tag ) {
+ foreach ( $attr_signatures as $attrs ) {
+ if ( false !== strpos( $input, '
' . str_repeat( '<' . $tag . $attrs . '>', 4 ) ) ) {
+ return true;
+ }
+ }
+ }
+ return false;
+
+ case 'active-formatting:mixed-formatting':
+ foreach ( $attr_signatures as $attrs ) {
+ $quoted_attrs = preg_quote( $attrs, '~' );
+ if ( 1 === preg_match( '~
[^<>]* [^<>]*
~', $input ) ) {
+ return true;
+ }
+ }
+ return false;
+
+ case 'active-formatting:marker-boundary':
+ foreach ( $formatting_tags as $outer_tag ) {
+ foreach ( $attr_signatures as $outer_attrs ) {
+ $outer_cluster = preg_quote( str_repeat( '<' . $outer_tag . $outer_attrs . '>', 4 ), '~' );
+ $outer_extra = preg_quote( '<' . $outer_tag . $outer_attrs . '>', '~' );
+ foreach ( $formatting_tags as $inner_tag ) {
+ foreach ( $attr_signatures as $inner_attrs ) {
+ $inner_cluster = preg_quote( str_repeat( '<' . $inner_tag . $inner_attrs . '>', 4 ), '~' );
+ $inner_extra = preg_quote( '<' . $inner_tag . $inner_attrs . '>', '~' );
+ if ( 1 === preg_match( '~
' . $outer_cluster . '(?:' . $outer_extra . ')*[^<>]*
[^<>]*' . $inner_cluster . '(?:' . $inner_extra . ')*[^<>]*
[^<>]*
[^<>]*
~', $input ) ) {
+ return true;
+ }
+ }
+ }
+ }
+ }
+ return false;
+ }
+
+ return false;
+}
+
+function html_api_fuzz_smoke_rm_tree( string $path ): void {
+ if ( ! file_exists( $path ) ) {
+ return;
+ }
+ if ( is_file( $path ) || is_link( $path ) ) {
+ @unlink( $path );
+ return;
+ }
+ foreach ( scandir( $path ) ?: array() as $item ) {
+ if ( '.' === $item || '..' === $item ) {
+ continue;
+ }
+ html_api_fuzz_smoke_rm_tree( $path . DIRECTORY_SEPARATOR . $item );
+ }
+ @rmdir( $path );
+}
+
+$valid = null;
+for ( $truncation_seed = 1; $truncation_seed <= 64; $truncation_seed++ ) {
+ $candidate = \HtmlApiFuzz\Generator::generate(
+ $truncation_seed,
+ 'balanced',
+ \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'valid-utf8',
+ 64
+ );
+ html_api_fuzz_smoke_assert( 'valid-utf8' === $candidate['payloadPolicy'], 'valid-utf8 policy should be resolved.' );
+ html_api_fuzz_smoke_assert( html_api_fuzz_smoke_valid_utf8( $candidate['input'] ), 'valid-utf8 policy should produce valid UTF-8 bytes.' );
+ html_api_fuzz_smoke_assert( strlen( $candidate['input'] ) <= 64, 'max-input-bytes should cap generated input.' );
+ if ( true === $candidate['parameters']['truncated'] ) {
+ $valid = $candidate;
+ break;
+ }
+}
+html_api_fuzz_smoke_assert( null !== $valid, 'max-input-bytes smoke should exercise truncation within the seed budget.' );
+html_api_fuzz_smoke_assert( in_array( 'generator:truncated', $valid['parameters']['features'], true ), 'truncation should be recorded as a feature.' );
+html_api_fuzz_smoke_assert( 'valid-utf8' === $valid['parameters']['payloadPolicy'], 'parameters should include payload policy.' );
+html_api_fuzz_smoke_expect_invalid_argument(
+ static function (): void {
+ \HtmlApiFuzz\Generator::generate( 1, 'balanced', 'bogus-mode', 'valid-utf8' );
+ },
+ 'invalid generator mode should throw.'
+);
+html_api_fuzz_smoke_assert( ! in_array( 'invalid-byte-heavy', \HtmlApiFuzz\Generator::payload_policies(), true ), 'invalid-byte-heavy should not be selectable for generated inputs.' );
+html_api_fuzz_smoke_assert( in_array( 'invalid-byte-heavy', \HtmlApiFuzz\Generator::payload_policy_labels(), true ), 'invalid-byte-heavy should remain a recognized replay metadata label.' );
+html_api_fuzz_smoke_expect_invalid_argument(
+ static function (): void {
+ \HtmlApiFuzz\Generator::generate( 1, 'attributes-entities', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, 'invalid-byte-heavy' );
+ },
+ 'invalid-byte-heavy policy should be rejected for generated inputs.'
+);
+
+foreach ( \HtmlApiFuzz\Generator::payload_policies() as $payload_policy ) {
+ foreach ( \HtmlApiFuzz\Generator::profiles() as $profile ) {
+ foreach ( \HtmlApiFuzz\Generator::modes() as $mode ) {
+ for ( $seed = 1; $seed <= 8; ++$seed ) {
+ $generated = \HtmlApiFuzz\Generator::generate( $seed, $profile, $mode, $payload_policy, 4096 );
+ html_api_fuzz_smoke_assert( html_api_fuzz_smoke_valid_utf8( $generated['input'] ), "{$payload_policy}/{$profile}/{$mode}/{$seed} should produce valid UTF-8 bytes." );
+ html_api_fuzz_smoke_assert( ! in_array( 'payload:invalid-byte', $generated['parameters']['features'], true ), "{$payload_policy}/{$profile}/{$mode}/{$seed} should not record invalid-byte generation." );
+ }
+ }
+ }
+}
+$found_non_whitespace_c0_control = false;
+for ( $seed = 1; $seed <= 512; ++$seed ) {
+ $generated = \HtmlApiFuzz\Generator::generate( $seed, 'balanced', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, 'mostly-valid', 4096 );
+ if ( html_api_fuzz_smoke_has_non_whitespace_c0_control( $generated['input'] ) ) {
+ $found_non_whitespace_c0_control = true;
+ html_api_fuzz_smoke_assert( html_api_fuzz_smoke_valid_utf8( $generated['input'] ), 'non-whitespace C0 control sample should still be valid UTF-8.' );
+ break;
+ }
+}
+html_api_fuzz_smoke_assert( $found_non_whitespace_c0_control, 'generated valid UTF-8 payloads should retain non-whitespace C0 control coverage.' );
+
+$required_generator_features = array(
+ 'charref:text',
+ 'charref:attr',
+ 'charref:rcdata',
+ 'charref:text:named-semicolon',
+ 'charref:text:named-missing-semicolon-legacy',
+ 'charref:text:named-missing-semicolon-invalid',
+ 'charref:text:numeric-valid',
+ 'charref:text:numeric-invalid',
+ 'charref:attr:named-semicolon',
+ 'charref:attr:named-missing-semicolon-legacy',
+ 'charref:attr:named-missing-semicolon-invalid',
+ 'charref:attr:numeric-valid',
+ 'charref:attr:numeric-invalid',
+ 'charref:rcdata:named-semicolon',
+ 'charref:rcdata:invalid',
+ 'charref:rcdata:numeric-valid',
+ 'charref:rcdata:numeric-invalid',
+ 'charref:leading-zero',
+ 'attr:weird-name',
+ 'attr:weird-spacing',
+ 'attr:malformed',
+ 'ascii:syntax-char',
+ 'ascii:syntax-ampersand',
+ 'ascii:syntax-less-than',
+ 'ascii:syntax-greater-than',
+ 'ascii:syntax-double-quote',
+ 'ascii:syntax-single-quote',
+ 'ascii:syntax-equals',
+ 'payload:short-ascii',
+ 'payload:empty-ascii',
+ 'payload:medium-ascii',
+ 'payload:ascii-length-0',
+ 'payload:ascii-length-1',
+ 'payload:ascii-length-2',
+ 'payload:ascii-length-3',
+ 'payload:ascii-length-4',
+ 'payload:ascii-length-5',
+ 'payload:ascii-length-6',
+ 'payload:ascii-length-7',
+ 'payload:ascii-length-8',
+ 'payload:ascii-length-9',
+ 'payload:ascii-length-10',
+ 'tag:unusual-name',
+ 'tag:invalid-name',
+ 'tag:alpha-invalid-name',
+ 'tag:alpha-weird-name',
+ 'tag:bogus-open-name',
+ 'tag:weird-spacing',
+ 'attr:duplicate',
+ 'select',
+ 'select:option',
+ 'select:optgroup',
+ 'select:breaker',
+ 'select:nested',
+ 'adoption-agency-pattern',
+ 'adoption:misnested-closers',
+ 'adoption:reconstruction',
+ 'adoption:noahs-ark',
+ 'active-formatting-reconstruction-pattern',
+ 'active-formatting:four-plus-same-tag',
+ 'active-formatting:four-plus-same-signature',
+ 'active-formatting:same-tag-empty-attrs',
+ 'active-formatting:same-tag-distinct-attrs',
+ 'active-formatting:same-tag-matching-attrs',
+ 'active-formatting:mixed-formatting',
+ 'active-formatting:marker-boundary',
+ 'auto-closing-chain',
+ 'special-closers',
+ 'foreign:breakout',
+ 'foreign:annotation-xml-encoding-variant',
+ 'foreign:cdata',
+ 'foreign:case-mangled-name',
+ 'plaintext',
+);
+$found_generator_features = array_fill_keys( $required_generator_features, false );
+$all_generator_features_found = false;
+foreach ( array( 'attributes-entities', 'rawtext-rcdata', 'text-fragment', 'incomplete-malformed', 'balanced', 'select', 'formatting-adoption', 'foreign-content' ) as $feature_profile ) {
+ for ( $seed = 1; $seed <= 128; ++$seed ) {
+ $generated = \HtmlApiFuzz\Generator::generate( $seed, $feature_profile, \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, 'mostly-valid', null );
+ html_api_fuzz_smoke_assert( html_api_fuzz_smoke_valid_utf8( $generated['input'] ), "{$feature_profile}/{$seed} feature-coverage sample should produce valid UTF-8 bytes." );
+ $features = $generated['parameters']['features'];
+ if ( in_array( 'generator:truncated', $features, true ) || in_array( 'generator:hard-truncated', $features, true ) ) {
+ continue;
+ }
+ foreach ( $features as $feature ) {
+ if ( array_key_exists( $feature, $found_generator_features ) ) {
+ $found_generator_features[ $feature ] = true;
+ }
+ }
+ if ( ! in_array( false, $found_generator_features, true ) ) {
+ $all_generator_features_found = true;
+ break 2;
+ }
+ }
+}
+html_api_fuzz_smoke_assert( $all_generator_features_found, 'generated samples should cover all required generator features before exhausting the smoke seed budget.' );
+foreach ( $found_generator_features as $feature => $found ) {
+ html_api_fuzz_smoke_assert( $found, "generated samples should cover {$feature}." );
+}
+
+$required_active_formatting_shapes = array(
+ 'active-formatting:same-tag-empty-attrs',
+ 'active-formatting:same-tag-distinct-attrs',
+ 'active-formatting:same-tag-matching-attrs',
+ 'active-formatting:mixed-formatting',
+ 'active-formatting:marker-boundary',
+);
+$found_active_formatting_shapes = array_fill_keys( $required_active_formatting_shapes, false );
+for ( $seed = 1; $seed <= 512; ++$seed ) {
+ $generated = \HtmlApiFuzz\Generator::generate( $seed, 'formatting-adoption', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, 'mostly-valid', null );
+ html_api_fuzz_smoke_assert( html_api_fuzz_smoke_valid_utf8( $generated['input'] ), "formatting-adoption/{$seed} active-formatting shape sample should produce valid UTF-8 bytes." );
+ foreach ( $required_active_formatting_shapes as $feature ) {
+ if ( ! in_array( $feature, $generated['parameters']['features'], true ) ) {
+ continue;
+ }
+ html_api_fuzz_smoke_assert(
+ html_api_fuzz_smoke_has_active_formatting_shape( $generated['input'], $feature ),
+ "generated samples that record {$feature} should emit the corresponding active-formatting byte shape."
+ );
+ $found_active_formatting_shapes[ $feature ] = true;
+ }
+ if ( ! in_array( false, $found_active_formatting_shapes, true ) ) {
+ break;
+ }
+}
+foreach ( $found_active_formatting_shapes as $feature => $found ) {
+ html_api_fuzz_smoke_assert( $found, "formatting-adoption generation should emit {$feature} byte shapes." );
+}
+
+$required_comment_forms = array(
+ 'comment:ordinary-simple' => array(
+ 'example' => '',
+ 'matches' => static function ( string $input ): bool {
+ return false !== strpos( $input, '' );
+ },
+ ),
+ 'comment:empty' => array(
+ 'example' => '',
+ 'matches' => static function ( string $input ): bool {
+ return false !== strpos( $input, '' );
+ },
+ ),
+ 'comment:space' => array(
+ 'example' => '',
+ 'matches' => static function ( string $input ): bool {
+ return false !== strpos( $input, '' );
+ },
+ ),
+ 'comment:short-empty-end' => array(
+ 'example' => '',
+ 'matches' => static function ( string $input ): bool {
+ return false !== strpos( $input, '' );
+ },
+ ),
+ 'comment:short-hyphen-end' => array(
+ 'example' => '',
+ 'matches' => static function ( string $input ): bool {
+ return false !== strpos( $input, '' );
+ },
+ ),
+ 'comment:nested-hyphens' => array(
+ 'example' => '',
+ 'matches' => static function ( string $input ): bool {
+ return false !== strpos( $input, '' );
+ },
+ ),
+ 'comment:malformed-bang-ending' => array(
+ 'example' => '',
+ 'matches' => static function ( string $input ): bool {
+ return false !== strpos( $input, '' );
+ },
+ ),
+ 'comment:malformed-greater-than-ending' => array(
+ 'example' => '/s', $generated['input'], $matches ) ) {
+ foreach ( $matches[1] as $comment ) {
+ html_api_fuzz_smoke_note_syntax_chars( $comment, $found_syntax_contexts['comment content'] );
+ }
+ }
+ if ( preg_match_all( '~\s[-A-Za-z0-9_:.]+\s*=\s*(?:"([^"]*)"|\'([^\']*)\')~', $generated['input'], $matches, PREG_SET_ORDER ) ) {
+ foreach ( $matches as $attribute ) {
+ html_api_fuzz_smoke_note_syntax_chars( ( $attribute[1] ?? '' ) . ( $attribute[2] ?? '' ), $found_syntax_contexts['quoted attribute value'] );
+ }
+ }
+ }
+}
+foreach ( $found_text_fragment_lengths as $length => $found ) {
+ html_api_fuzz_smoke_assert( $found, "text-fragment generation should cover exact {$length}-byte inputs." );
+}
+html_api_fuzz_smoke_assert( $found_medium_text_fragment, 'text-fragment generation should cover medium-sized inputs.' );
+for ( $seed = 1; $seed <= 16; ++$seed ) {
+ $stress_text_fragment = \HtmlApiFuzz\Generator::generate( $seed, 'text-fragment', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, 'stress-long', null );
+ html_api_fuzz_smoke_assert( html_api_fuzz_smoke_valid_utf8( $stress_text_fragment['input'] ), 'text-fragment stress-long generation should produce valid UTF-8 bytes.' );
+ html_api_fuzz_smoke_assert( strlen( $stress_text_fragment['input'] ) >= 64, 'text-fragment stress-long generation should honor the lower long-input bound.' );
+ html_api_fuzz_smoke_assert( strlen( $stress_text_fragment['input'] ) <= 1024, 'text-fragment stress-long generation should honor the upper long-input bound.' );
+ html_api_fuzz_smoke_assert( in_array( 'input:long', $stress_text_fragment['parameters']['features'], true ), 'text-fragment stress-long generation should record the long-input feature.' );
+}
+foreach ( $found_syntax_contexts as $context => $found_chars ) {
+ foreach ( $found_chars as $char => $found ) {
+ html_api_fuzz_smoke_assert( $found, "{$context} should expose syntax character {$char} in final generated HTML." );
+ }
+}
+
+$found_resource_stress = false;
+$found_resource_stress_long = false;
+for ( $seed = 1; $seed <= 512; ++$seed ) {
+ $generated = \HtmlApiFuzz\Generator::generate( $seed, 'auto', 'auto', 'auto', 4096 );
+ html_api_fuzz_smoke_assert( html_api_fuzz_smoke_valid_utf8( $generated['input'] ), 'auto generation should produce valid UTF-8 bytes.' );
+ html_api_fuzz_smoke_assert( ! in_array( 'payload:invalid-byte', $generated['parameters']['features'], true ), 'auto generation should not record invalid-byte payload features.' );
+ if ( ! in_array( $generated['profile'], array( 'attributes-entities', 'incomplete-malformed' ), true ) ) {
+ html_api_fuzz_smoke_assert( ! in_array( 'attr:malformed', $generated['parameters']['features'], true ), 'auto generation should keep malformed attributes in targeted profiles.' );
+ html_api_fuzz_smoke_assert( ! in_array( 'tag:weird-syntax', $generated['parameters']['features'], true ), 'auto generation should keep weird tag syntax in targeted profiles.' );
+ }
+ if ( 'resource-stress' === $generated['profile'] ) {
+ $found_resource_stress = true;
+ }
+ if ( 'stress-long' === $generated['payloadPolicy'] ) {
+ html_api_fuzz_smoke_assert( 'resource-stress' === $generated['profile'], 'auto stress-long payloads should stay in the resource-stress profile.' );
+ $found_resource_stress_long = true;
+ }
+}
+html_api_fuzz_smoke_assert( $found_resource_stress, 'auto generation should retain the resource-stress bucket.' );
+html_api_fuzz_smoke_assert( $found_resource_stress_long, 'resource-stress auto generation should retain stress-long payload coverage.' );
+for ( $seed = 1; $seed <= 64; ++$seed ) {
+ $generated = \HtmlApiFuzz\Generator::generate( $seed, 'balanced', 'auto', 'auto', 4096 );
+ html_api_fuzz_smoke_assert( 'stress-long' !== $generated['payloadPolicy'], 'non-resource explicit profiles should not auto-resolve stress-long.' );
+}
+
+$tmp = tempnam( sys_get_temp_dir(), 'html-api-fuzz-policy-smoke-' );
+if ( false === $tmp ) {
+ html_api_fuzz_smoke_fail( 'Could not create temp path.' );
+}
+@unlink( $tmp );
+\HtmlApiFuzz\ensure_dir( $tmp );
+register_shutdown_function( 'html_api_fuzz_smoke_rm_tree', $tmp );
+html_api_fuzz_smoke_expect_invalid_argument(
+ static function () use ( $tmp ): void {
+ \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( 'x
' ),
+ 'payload-policy' => 'valid-ut8',
+ 'output-dir' => $tmp . '/invalid-policy',
+ )
+ );
+ },
+ 'invalid direct-input payload policy should throw.'
+);
+html_api_fuzz_smoke_expect_invalid_argument(
+ static function () use ( $tmp ): void {
+ \HtmlApiFuzz\Worker::run(
+ array(
+ 'seed' => '1',
+ 'profile' => 'balanced',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'payload-policy' => 'invalid-byte-heavy',
+ 'output-dir' => $tmp . '/generated-invalid-heavy',
+ )
+ );
+ },
+ 'generated worker inputs should reject legacy invalid-byte-heavy policy.'
+);
+
+$worker_dir = $tmp . '/worker';
+\HtmlApiFuzz\Worker::run(
+ array(
+ 'seed' => '17',
+ 'profile' => 'balanced',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'payload-policy' => 'valid-utf8',
+ 'max-input-bytes' => '2048',
+ 'output-dir' => $worker_dir,
+ )
+);
+$worker_replay = \HtmlApiFuzz\read_json_file( $worker_dir . '/replay.json' );
+$worker_result = \HtmlApiFuzz\read_json_file( $worker_dir . '/result.json' );
+html_api_fuzz_smoke_assert( 'valid-utf8' === ( $worker_replay['payloadPolicy'] ?? null ), 'replay should persist top-level payload policy.' );
+html_api_fuzz_smoke_assert( 'valid-utf8' === ( $worker_replay['generator']['payloadPolicy'] ?? null ), 'replay generator parameters should persist payload policy.' );
+html_api_fuzz_smoke_assert( 'generated' === ( $worker_replay['inputSource'] ?? null ), 'generated replay should record generated input source.' );
+html_api_fuzz_smoke_assert( 'valid-utf8' === ( $worker_result['payloadPolicy'] ?? null ), 'result should persist payload policy.' );
+html_api_fuzz_smoke_assert( ! empty( $worker_result['generator']['features'] ?? array() ), 'result should persist non-empty generator features.' );
+html_api_fuzz_smoke_assert( ( $worker_replay['generator']['features'] ?? null ) === ( $worker_result['generator']['features'] ?? null ), 'result and replay should persist the same generator features.' );
+$git_metadata = \HtmlApiFuzz\git_metadata();
+html_api_fuzz_smoke_assert( array_key_exists( 'available', $git_metadata ), 'git metadata should report availability.' );
+html_api_fuzz_smoke_assert( array_key_exists( 'dirty', $git_metadata ), 'git metadata should report dirty state.' );
+if ( $git_metadata['available'] ?? false ) {
+ html_api_fuzz_smoke_assert( 1 === preg_match( '/^[0-9a-f]{7,}$/', $git_metadata['commit'] ?? '' ), 'git metadata should include a full hex commit hash.' );
+ html_api_fuzz_smoke_assert( ( $git_metadata['commit'] ?? null ) === ( $worker_replay['repoCommit'] ?? null ), 'replay should persist the current commit hash.' );
+ html_api_fuzz_smoke_assert( ( $git_metadata['dirty'] ?? null ) === ( $worker_replay['repoDirty'] ?? null ), 'replay should persist the tracked-file dirty flag.' );
+}
+$ancestor_repo = $tmp . '/ancestor-repo';
+$ancestor_child = $ancestor_repo . '/child';
+\HtmlApiFuzz\ensure_dir( $ancestor_child );
+$ancestor_init = \HtmlApiFuzz\run_git_command( array( 'init' ), 1000, $ancestor_repo );
+if ( 0 === $ancestor_init['code'] ) {
+ $ancestor_child_metadata = \HtmlApiFuzz\git_metadata( 1000, $ancestor_child );
+ html_api_fuzz_smoke_assert( false === ( $ancestor_child_metadata['available'] ?? null ), 'git metadata should not report an ancestor repository as the current repo.' );
+}
+$dirty_repo = $tmp . '/dirty-repo';
+\HtmlApiFuzz\ensure_dir( $dirty_repo );
+$dirty_init = \HtmlApiFuzz\run_git_command( array( 'init' ), 1000, $dirty_repo );
+if ( 0 === $dirty_init['code'] ) {
+ file_put_contents( $dirty_repo . '/tracked.txt', "clean\n" );
+ $dirty_add = \HtmlApiFuzz\run_git_command( array( 'add', 'tracked.txt' ), 1000, $dirty_repo );
+ $dirty_commit = \HtmlApiFuzz\run_git_command(
+ array(
+ '-c',
+ 'user.email=html-api-fuzz@example.invalid',
+ '-c',
+ 'user.name=HTML API Fuzz',
+ '-c',
+ 'commit.gpgsign=false',
+ 'commit',
+ '--no-gpg-sign',
+ '--no-verify',
+ '-m',
+ 'initial',
+ ),
+ 1000,
+ $dirty_repo
+ );
+ html_api_fuzz_smoke_assert( 0 === $dirty_add['code'], 'temp git repo should stage the tracked dirty fixture.' );
+ html_api_fuzz_smoke_assert( 0 === $dirty_commit['code'], 'temp git repo should commit the tracked dirty fixture.' );
+ $clean_repo_metadata = \HtmlApiFuzz\git_metadata( 1000, $dirty_repo, false );
+ html_api_fuzz_smoke_assert( true === ( $clean_repo_metadata['available'] ?? null ), 'temp git repo metadata should be available.' );
+ html_api_fuzz_smoke_assert( false === ( $clean_repo_metadata['dirty'] ?? null ), 'clean tracked temp git repo should report dirty false.' );
+ file_put_contents( $dirty_repo . '/tracked.txt', "dirty\n" );
+ $dirty_repo_metadata = \HtmlApiFuzz\git_metadata( 1000, $dirty_repo, false );
+ html_api_fuzz_smoke_assert( true === ( $dirty_repo_metadata['dirty'] ?? null ), 'modified tracked temp git repo should report dirty true.' );
+}
+$fake_git_root = $tmp . '/fake-git-root';
+$fake_git_bin = $tmp . '/fake-git-bin';
+\HtmlApiFuzz\ensure_dir( $fake_git_root );
+\HtmlApiFuzz\ensure_dir( $fake_git_bin );
+$fake_git = $fake_git_bin . '/git';
+file_put_contents(
+ $fake_git,
+ "#!/bin/sh\n" .
+ "if [ \"\$1\" = \"-C\" ]; then root=\"\$2\"; shift 2; else root=\"\$PWD\"; fi\n" .
+ "if [ \"\$1\" = \"rev-parse\" ] && [ \"\$2\" = \"--show-toplevel\" ]; then printf '%s\\n' \"\$root\"; exit 0; fi\n" .
+ "if [ \"\$1\" = \"rev-parse\" ] && [ \"\$2\" = \"HEAD\" ]; then printf '%s\\n' abcdef1234567890abcdef1234567890abcdef12; exit 0; fi\n" .
+ "if [ \"\$1\" = \"rev-parse\" ] && [ \"\$2\" = \"--short=12\" ]; then printf '%s\\n' abcdef123456; exit 0; fi\n" .
+ "if [ \"\$1\" = \"branch\" ] && [ \"\$2\" = \"--show-current\" ]; then printf '%s\\n' main; exit 0; fi\n" .
+ "if [ \"\$1\" = \"show\" ]; then printf '%s\\n' 2026-01-01T00:00:00+00:00; exit 0; fi\n" .
+ "if [ \"\$1\" = \"diff\" ]; then exit 2; fi\n" .
+ "exit 1\n"
+);
+chmod( $fake_git, 0755 );
+$old_path = getenv( 'PATH' );
+putenv( 'PATH=' . $fake_git_bin . PATH_SEPARATOR . ( false === $old_path ? '' : $old_path ) );
+$unknown_dirty_metadata = \HtmlApiFuzz\git_metadata( 1000, $fake_git_root, false );
+if ( false === $old_path ) {
+ putenv( 'PATH' );
+} else {
+ putenv( 'PATH=' . $old_path );
+}
+html_api_fuzz_smoke_assert( true === ( $unknown_dirty_metadata['available'] ?? null ), 'git metadata should remain available when only dirty detection fails.' );
+html_api_fuzz_smoke_assert( array_key_exists( 'dirty', $unknown_dirty_metadata ) && null === $unknown_dirty_metadata['dirty'], 'dirty detection failures should report dirty null.' );
+
+$replay_cli_dir = $tmp . '/replay-cli';
+$replay_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/replay.php',
+ '--replay',
+ $worker_dir . '/replay.json',
+ '--output-dir',
+ $replay_cli_dir,
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 10000,
+ $tmp . '/replay-cli.log'
+);
+html_api_fuzz_smoke_assert( ! $replay_proc['timedOut'] && in_array( $replay_proc['code'], array( 0, 2 ), true ), 'replay CLI should complete.' );
+$replay_cli_replay = \HtmlApiFuzz\read_json_file( $replay_cli_dir . '/replay.json' );
+html_api_fuzz_smoke_assert( null === ( $replay_cli_replay['generator'] ?? null ), 'replay CLI output should not invent immediate generator metadata.' );
+html_api_fuzz_smoke_assert( 'input-file' === ( $replay_cli_replay['inputSource'] ?? null ), 'replay CLI output should record immediate input source.' );
+html_api_fuzz_smoke_assert( ( $worker_replay['generator'] ?? null ) === ( $replay_cli_replay['originalGenerator'] ?? null ), 'replay CLI output should preserve original generator metadata.' );
+html_api_fuzz_smoke_assert( ( $worker_replay['repoCommit'] ?? null ) === ( $replay_cli_replay['sourceReplay']['repoCommit'] ?? null ), 'replay CLI output should preserve source replay commit metadata.' );
+html_api_fuzz_smoke_assert( ( $worker_replay['repoDirty'] ?? null ) === ( $replay_cli_replay['sourceReplay']['repoDirty'] ?? null ), 'replay CLI output should preserve source replay dirty metadata.' );
+
+$legacy_replay = $worker_replay;
+$legacy_replay['payloadPolicy'] = 'replay';
+if ( isset( $legacy_replay['generator']['payloadPolicy'] ) ) {
+ $legacy_replay['generator']['payloadPolicy'] = 'replay';
+}
+$legacy_replay_path = $tmp . '/legacy-payload-policy-replay.json';
+\HtmlApiFuzz\write_json_file( $legacy_replay_path, $legacy_replay );
+$legacy_replay_dir = $tmp . '/legacy-replay-cli';
+$legacy_replay_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/replay.php',
+ '--replay',
+ $legacy_replay_path,
+ '--output-dir',
+ $legacy_replay_dir,
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 10000,
+ $tmp . '/legacy-replay-cli.log'
+);
+html_api_fuzz_smoke_assert( ! $legacy_replay_proc['timedOut'] && in_array( $legacy_replay_proc['code'], array( 0, 2 ), true ), 'legacy replay payload policy labels should not make replay fatal.' );
+$legacy_replay_cli_replay = \HtmlApiFuzz\read_json_file( $legacy_replay_dir . '/replay.json' );
+html_api_fuzz_smoke_assert( null === ( $legacy_replay_cli_replay['payloadPolicy'] ?? null ), 'legacy replay payload policy labels should be treated as unlabeled direct input.' );
+html_api_fuzz_smoke_assert( 'replay' === ( $legacy_replay_cli_replay['originalGenerator']['payloadPolicy'] ?? null ), 'legacy replay should preserve original generator metadata.' );
+
+$legacy_invalid_replay = $worker_replay;
+$legacy_invalid_replay['payloadPolicy'] = 'invalid-byte-heavy';
+if ( isset( $legacy_invalid_replay['generator']['payloadPolicy'] ) ) {
+ $legacy_invalid_replay['generator']['payloadPolicy'] = 'invalid-byte-heavy';
+}
+$legacy_invalid_replay_path = $tmp . '/legacy-invalid-payload-policy-replay.json';
+\HtmlApiFuzz\write_json_file( $legacy_invalid_replay_path, $legacy_invalid_replay );
+$legacy_invalid_replay_dir = $tmp . '/legacy-invalid-replay-cli';
+$legacy_invalid_replay_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/replay.php',
+ '--replay',
+ $legacy_invalid_replay_path,
+ '--output-dir',
+ $legacy_invalid_replay_dir,
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 10000,
+ $tmp . '/legacy-invalid-replay-cli.log'
+);
+html_api_fuzz_smoke_assert( ! $legacy_invalid_replay_proc['timedOut'] && in_array( $legacy_invalid_replay_proc['code'], array( 0, 2 ), true ), 'legacy invalid-byte-heavy replay payload policy label should not make replay fatal.' );
+$legacy_invalid_replay_cli_replay = \HtmlApiFuzz\read_json_file( $legacy_invalid_replay_dir . '/replay.json' );
+html_api_fuzz_smoke_assert( 'invalid-byte-heavy' === ( $legacy_invalid_replay_cli_replay['payloadPolicy'] ?? null ), 'legacy invalid-byte-heavy replay payload policy label should be preserved as direct-input metadata.' );
+html_api_fuzz_smoke_assert( 'invalid-byte-heavy' === ( $legacy_invalid_replay_cli_replay['originalGenerator']['payloadPolicy'] ?? null ), 'legacy invalid-byte-heavy replay should preserve original generator metadata.' );
+
+$invalid_byte_replay_source_dir = $tmp . '/invalid-byte-replay-source';
+$invalid_byte_replay_source = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( '' . str_repeat( 'a', 220 ) . "\xC0" . '
' ),
+ 'profile' => 'replay',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'payload-policy' => 'invalid-byte-heavy',
+ 'output-dir' => $invalid_byte_replay_source_dir,
+ 'max-tokens' => '2000',
+ 'max-nodes' => '3000',
+ )
+);
+html_api_fuzz_smoke_assert( 'normalize-tree-changed' === ( $invalid_byte_replay_source['failureClass'] ?? null ), 'invalid-byte replay fixture should be a real normalization-preservation failure.' );
+
+$invalid_byte_replay_cli_dir = $tmp . '/invalid-byte-replay-cli';
+$invalid_byte_replay_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/replay.php',
+ '--replay',
+ $invalid_byte_replay_source_dir . '/replay.json',
+ '--output-dir',
+ $invalid_byte_replay_cli_dir,
+ '--timeout-ms',
+ '10000',
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 10000,
+ $tmp . '/invalid-byte-replay-cli.log'
+);
+html_api_fuzz_smoke_assert( ! $invalid_byte_replay_proc['timedOut'] && 2 === $invalid_byte_replay_proc['code'], 'real invalid-byte replay should complete as a replayed failure.' );
+$invalid_byte_replay_cli_replay = \HtmlApiFuzz\read_json_file( $invalid_byte_replay_cli_dir . '/replay.json' );
+html_api_fuzz_smoke_assert( 'invalid-byte-heavy' === ( $invalid_byte_replay_cli_replay['payloadPolicy'] ?? null ), 'real invalid-byte replay should preserve legacy payload policy metadata.' );
+
+$invalid_byte_exact_minimize_dir = $tmp . '/invalid-byte-exact-minimize';
+$invalid_byte_exact_minimize_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/minimize.php',
+ '--replay',
+ $invalid_byte_replay_source_dir . '/replay.json',
+ '--output-dir',
+ $invalid_byte_exact_minimize_dir,
+ '--max-attempts',
+ '1',
+ '--timeout-ms',
+ '10000',
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 20000,
+ $tmp . '/invalid-byte-exact-minimize.log'
+);
+html_api_fuzz_smoke_assert( ! $invalid_byte_exact_minimize_proc['timedOut'] && 0 === $invalid_byte_exact_minimize_proc['code'], 'exact-signature invalid-byte replay should minimize.' );
+$invalid_byte_exact_minimize_result = \HtmlApiFuzz\read_json_file( $invalid_byte_exact_minimize_dir . '/minimize-result.json' );
+html_api_fuzz_smoke_assert( true === ( $invalid_byte_exact_minimize_result['ok'] ?? null ), 'exact-signature invalid-byte minimization should preserve the target signature.' );
+html_api_fuzz_smoke_assert( 'process' === ( $invalid_byte_exact_minimize_result['probeMode'] ?? null ), 'auto exact-signature minimization should use timeout-enforced process probes by default.' );
+html_api_fuzz_smoke_assert( true === ( $invalid_byte_exact_minimize_result['candidateArtifactsRetained'] ?? null ), 'auto process minimization should report retained candidate artifacts.' );
+html_api_fuzz_smoke_assert( 1 === ( $invalid_byte_exact_minimize_result['attempts'] ?? null ), 'exact-signature smoke minimization should run the requested single probe.' );
+html_api_fuzz_smoke_assert( is_array( $invalid_byte_exact_minimize_result['probeTiming'] ?? null ), 'exact-signature minimization should report probe timing.' );
+html_api_fuzz_smoke_assert( is_dir( $invalid_byte_exact_minimize_dir . '/candidates' ), 'auto process minimization should retain per-candidate artifact directories.' );
+
+$invalid_byte_in_process_minimize_dir = $tmp . '/invalid-byte-in-process-minimize';
+$invalid_byte_in_process_minimize_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/minimize.php',
+ '--replay',
+ $invalid_byte_replay_source_dir . '/replay.json',
+ '--output-dir',
+ $invalid_byte_in_process_minimize_dir,
+ '--probe-mode',
+ 'in-process',
+ '--max-attempts',
+ '1',
+ '--timeout-ms',
+ '10000',
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 20000,
+ $tmp . '/invalid-byte-in-process-minimize.log'
+);
+html_api_fuzz_smoke_assert( ! $invalid_byte_in_process_minimize_proc['timedOut'] && 0 === $invalid_byte_in_process_minimize_proc['code'], 'explicit in-process invalid-byte replay should minimize.' );
+$invalid_byte_in_process_minimize_result = \HtmlApiFuzz\read_json_file( $invalid_byte_in_process_minimize_dir . '/minimize-result.json' );
+html_api_fuzz_smoke_assert( true === ( $invalid_byte_in_process_minimize_result['ok'] ?? null ), 'explicit in-process minimization should preserve the target signature.' );
+html_api_fuzz_smoke_assert( 'in-process' === ( $invalid_byte_in_process_minimize_result['probeMode'] ?? null ), 'explicit in-process minimization should use in-process probes.' );
+html_api_fuzz_smoke_assert( false === ( $invalid_byte_in_process_minimize_result['candidateArtifactsRetained'] ?? null ), 'in-process minimization should not retain candidate artifacts by default.' );
+html_api_fuzz_smoke_assert( ! is_dir( $invalid_byte_in_process_minimize_dir . '/candidates' ), 'in-process minimization should avoid per-candidate artifact directories by default.' );
+
+$invalid_byte_minimize_dir = $tmp . '/invalid-byte-minimize';
+$invalid_byte_minimize_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/minimize.php',
+ '--replay',
+ $invalid_byte_replay_source_dir . '/replay.json',
+ '--output-dir',
+ $invalid_byte_minimize_dir,
+ '--any-failure',
+ '--max-attempts',
+ '1',
+ '--timeout-ms',
+ '10000',
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 20000,
+ $tmp . '/invalid-byte-minimize.log'
+);
+html_api_fuzz_smoke_assert( ! $invalid_byte_minimize_proc['timedOut'] && 0 === $invalid_byte_minimize_proc['code'], 'real invalid-byte replay should remain minimizable.' );
+$invalid_byte_minimize_result = \HtmlApiFuzz\read_json_file( $invalid_byte_minimize_dir . '/minimize-result.json' );
+$invalid_byte_minimize_replay = \HtmlApiFuzz\read_json_file( $invalid_byte_minimize_result['minimizedReplay'] ?? '' );
+$invalid_byte_source_replay = \HtmlApiFuzz\read_json_file( $invalid_byte_replay_source_dir . '/replay.json' );
+html_api_fuzz_smoke_assert( 'process' === ( $invalid_byte_minimize_result['probeMode'] ?? null ), 'any-failure minimization should use process probes by default.' );
+html_api_fuzz_smoke_assert( true === ( $invalid_byte_minimize_result['candidateArtifactsRetained'] ?? null ), 'process-mode minimization should report retained candidate artifacts.' );
+html_api_fuzz_smoke_assert( is_dir( $invalid_byte_minimize_dir . '/candidates' ), 'process-mode minimization should retain per-candidate artifact directories.' );
+html_api_fuzz_smoke_assert( 'invalid-byte-heavy' === ( $invalid_byte_minimize_result['payloadPolicy'] ?? null ), 'invalid-byte minimization should preserve legacy payload policy metadata.' );
+html_api_fuzz_smoke_assert( ( $invalid_byte_source_replay['repoCommit'] ?? null ) === ( $invalid_byte_minimize_result['sourceReplay']['repoCommit'] ?? null ), 'minimize result should preserve source replay commit metadata.' );
+html_api_fuzz_smoke_assert( ( $invalid_byte_source_replay['repoDirty'] ?? null ) === ( $invalid_byte_minimize_result['sourceReplay']['repoDirty'] ?? null ), 'minimize result should preserve source replay dirty metadata.' );
+html_api_fuzz_smoke_assert( ( $invalid_byte_source_replay['repoCommit'] ?? null ) === ( $invalid_byte_minimize_replay['sourceReplay']['repoCommit'] ?? null ), 'minimized replay should preserve source replay commit metadata.' );
+html_api_fuzz_smoke_assert( ( $invalid_byte_source_replay['repoDirty'] ?? null ) === ( $invalid_byte_minimize_replay['sourceReplay']['repoDirty'] ?? null ), 'minimized replay should preserve source replay dirty metadata.' );
+
+$bad_runner_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/runner.php',
+ '--payload-policy',
+ 'valid-ut8',
+ '--max-seeds',
+ '1',
+ '--output-dir',
+ $tmp . '/bad-runner',
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 5000,
+ $tmp . '/bad-runner.log'
+);
+html_api_fuzz_smoke_assert( 0 !== $bad_runner_proc['code'], 'runner CLI should reject invalid payload policy before starting workers.' );
+
+$legacy_invalid_runner_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/runner.php',
+ '--payload-policy',
+ 'invalid-byte-heavy',
+ '--max-seeds',
+ '1',
+ '--output-dir',
+ $tmp . '/legacy-invalid-runner',
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 5000,
+ $tmp . '/legacy-invalid-runner.log'
+);
+html_api_fuzz_smoke_assert( 0 !== $legacy_invalid_runner_proc['code'], 'runner CLI should reject legacy invalid-byte-heavy generation policy.' );
+
+$legacy_invalid_launcher_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/launcher.php',
+ '--payload-policy',
+ 'invalid-byte-heavy',
+ '--max-seeds',
+ '1',
+ '--duration-seconds',
+ '0',
+ '--output-dir',
+ $tmp . '/legacy-invalid-launcher',
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 5000,
+ $tmp . '/legacy-invalid-launcher.log'
+);
+html_api_fuzz_smoke_assert( 0 !== $legacy_invalid_launcher_proc['code'], 'launcher CLI should reject legacy invalid-byte-heavy generation policy.' );
+
+$metadata_runner_dir = $tmp . '/metadata-runner';
+$metadata_runner_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/runner.php',
+ '--max-seeds',
+ '1',
+ '--duration-seconds',
+ '0',
+ // Passing seed directories are pruned by default; this run asserts on
+ // the on-disk replay document.
+ '--keep-all-artifacts',
+ '--output-dir',
+ $metadata_runner_dir,
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 15000,
+ $tmp . '/metadata-runner.log'
+);
+html_api_fuzz_smoke_assert( ! $metadata_runner_proc['timedOut'] && 0 === $metadata_runner_proc['code'], 'runner metadata smoke run should complete.' );
+$metadata_runner_state = \HtmlApiFuzz\read_json_file( $metadata_runner_dir . '/state.json' );
+html_api_fuzz_smoke_assert( 'html-api-fuzz-runner-state' === ( $metadata_runner_state['kind'] ?? null ), 'runner state should be written.' );
+html_api_fuzz_smoke_assert( is_array( $metadata_runner_state['git'] ?? null ), 'runner state should include compact git metadata.' );
+$metadata_runner_events = \HtmlApiFuzz\read_ndjson_records( $metadata_runner_dir . '/events.ndjson' );
+html_api_fuzz_smoke_assert( is_array( $metadata_runner_events[0]['git'] ?? null ), 'runner start event should include compact git metadata.' );
+$metadata_runner_replay = \HtmlApiFuzz\read_json_file( $metadata_runner_dir . '/seed-1/primary/replay.json' );
+if ( $git_metadata['available'] ?? false ) {
+ html_api_fuzz_smoke_assert( $git_metadata['commit'] === ( $metadata_runner_state['git']['commit'] ?? null ), 'runner state git metadata should match the current commit.' );
+ html_api_fuzz_smoke_assert( $git_metadata['commit'] === ( $metadata_runner_events[0]['git']['commit'] ?? null ), 'runner start event git metadata should match the current commit.' );
+ html_api_fuzz_smoke_assert( $metadata_runner_state['git']['commit'] === ( $metadata_runner_replay['repoCommit'] ?? null ), 'runner worker replay should use runner-provided git metadata.' );
+ html_api_fuzz_smoke_assert( $metadata_runner_state['git']['dirty'] === ( $metadata_runner_replay['repoDirty'] ?? null ), 'runner worker replay should use runner-provided dirty metadata.' );
+}
+
+$metadata_launcher_dir = $tmp . '/metadata-launcher';
+$metadata_launcher_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/launcher.php',
+ '--lanes',
+ '1',
+ '--max-seeds',
+ '1',
+ '--duration-seconds',
+ '0',
+ // Passing seed directories are pruned by default; this run asserts on
+ // the on-disk replay document.
+ '--keep-all-artifacts',
+ // Non-default value pins the launcher-to-lane flag passthrough.
+ '--max-keep-per-signature',
+ '3',
+ '--output-dir',
+ $metadata_launcher_dir,
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 20000,
+ $tmp . '/metadata-launcher.log'
+);
+html_api_fuzz_smoke_assert( ! $metadata_launcher_proc['timedOut'] && 0 === $metadata_launcher_proc['code'], 'launcher metadata smoke run should complete.' );
+$metadata_launcher_state = \HtmlApiFuzz\read_json_file( $metadata_launcher_dir . '/launcher-state.json' );
+html_api_fuzz_smoke_assert( 'html-api-fuzz-launcher-state' === ( $metadata_launcher_state['kind'] ?? null ), 'launcher state should be written.' );
+html_api_fuzz_smoke_assert( is_array( $metadata_launcher_state['git'] ?? null ), 'launcher state should include compact git metadata.' );
+$metadata_launcher_events = \HtmlApiFuzz\read_ndjson_records( $metadata_launcher_dir . '/events.ndjson' );
+html_api_fuzz_smoke_assert( is_array( $metadata_launcher_events[0]['git'] ?? null ), 'launcher start event should include compact git metadata.' );
+$metadata_launcher_lane_state = \HtmlApiFuzz\read_json_file( $metadata_launcher_dir . '/lane-00/state.json' );
+html_api_fuzz_smoke_assert( 3 === ( $metadata_launcher_lane_state['maxKeepPerSignature'] ?? null ), 'launcher should pass --max-keep-per-signature through to lanes.' );
+$metadata_launcher_replay = \HtmlApiFuzz\read_json_file( $metadata_launcher_dir . '/lane-00/seed-1/primary/replay.json' );
+if ( $git_metadata['available'] ?? false ) {
+ html_api_fuzz_smoke_assert( $git_metadata['commit'] === ( $metadata_launcher_state['git']['commit'] ?? null ), 'launcher state git metadata should match the current commit.' );
+ html_api_fuzz_smoke_assert( $git_metadata['commit'] === ( $metadata_launcher_events[0]['git']['commit'] ?? null ), 'launcher start event git metadata should match the current commit.' );
+ html_api_fuzz_smoke_assert( $metadata_launcher_state['git']['commit'] === ( $metadata_launcher_replay['repoCommit'] ?? null ), 'launcher worker replay should use launcher-provided git metadata.' );
+ html_api_fuzz_smoke_assert( $metadata_launcher_state['git']['dirty'] === ( $metadata_launcher_replay['repoDirty'] ?? null ), 'launcher worker replay should use launcher-provided dirty metadata.' );
+}
+
+$launcher_oracle_watcher_dir = $tmp . '/launcher-oracle-watcher';
+$launcher_oracle_watcher_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/launcher.php',
+ '--lanes',
+ '1',
+ '--max-seeds',
+ '1',
+ '--duration-seconds',
+ '0',
+ '--watcher',
+ '--no-minimize',
+ '--triage-oracle-findings',
+ '--output-dir',
+ $launcher_oracle_watcher_dir,
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 30000,
+ $tmp . '/launcher-oracle-watcher.log'
+);
+html_api_fuzz_smoke_assert( ! $launcher_oracle_watcher_proc['timedOut'] && 0 === $launcher_oracle_watcher_proc['code'], 'launcher oracle watcher passthrough run should complete.' );
+$launcher_oracle_watcher = json_decode( $launcher_oracle_watcher_proc['stdout'], true );
+html_api_fuzz_smoke_assert( 0 === ( $launcher_oracle_watcher['watcherResult']['code'] ?? null ), 'launcher oracle watcher should exit cleanly.' );
+$launcher_oracle_watcher_log = trim( (string) file_get_contents( $launcher_oracle_watcher['watcherResult']['logPath'] ?? '' ) );
+$launcher_oracle_watcher_scan = json_decode( $launcher_oracle_watcher_log, true );
+html_api_fuzz_smoke_assert( true === ( $launcher_oracle_watcher_scan['triageOracleFindings'] ?? null ), 'launcher should pass --triage-oracle-findings through to watcher.' );
+
+$bad_stride_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/runner.php',
+ '--seed-stride',
+ '0',
+ '--max-seeds',
+ '1',
+ '--output-dir',
+ $tmp . '/bad-stride-runner',
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 5000,
+ $tmp . '/bad-stride-runner.log'
+);
+html_api_fuzz_smoke_assert( 0 !== $bad_stride_proc['code'], 'runner CLI should reject non-positive seed strides before starting workers.' );
+
+$unlabeled_dir = $tmp . '/unlabeled-direct';
+$unlabeled_result = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( 'x
' ),
+ 'profile' => 'replay',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'output-dir' => $unlabeled_dir,
+ )
+);
+$unlabeled_replay = \HtmlApiFuzz\read_json_file( $unlabeled_dir . '/replay.json' );
+html_api_fuzz_smoke_assert( null === ( $unlabeled_result['payloadPolicy'] ?? null ), 'unlabeled direct input result should leave payloadPolicy null.' );
+html_api_fuzz_smoke_assert( null === ( $unlabeled_replay['payloadPolicy'] ?? null ), 'unlabeled direct input replay should leave payloadPolicy null.' );
+html_api_fuzz_smoke_assert( null === ( $unlabeled_result['generator'] ?? null ), 'unlabeled direct input should not invent generator metadata.' );
+html_api_fuzz_smoke_assert( 'input-base64' === ( $unlabeled_replay['inputSource'] ?? null ), 'unlabeled direct input replay should record inputSource.' );
+html_api_fuzz_smoke_assert( 'idempotent' === ( $unlabeled_result['tagProcessor']['normalize']['status'] ?? null ), 'worker result should persist normalize() idempotence metadata.' );
+
+$normalize_idempotent = \HtmlApiFuzz\TagInvariants::check( 'One 
', array( 'maxTokens' => 2000 ) );
+html_api_fuzz_smoke_assert( true === ( $normalize_idempotent['ok'] ?? null ), 'normalizable input should pass tag invariants.' );
+html_api_fuzz_smoke_assert( 'idempotent' === ( $normalize_idempotent['normalize']['status'] ?? null ), 'normalizable input should record an idempotent normalize() status.' );
+html_api_fuzz_smoke_assert( is_string( $normalize_idempotent['normalize']['normalizedSha1'] ?? null ), 'idempotent normalize() metadata should include the normalized hash.' );
+
+$normalize_unsupported = \HtmlApiFuzz\TagInvariants::check( '', array( 'maxTokens' => 2000 ) );
+html_api_fuzz_smoke_assert( true === ( $normalize_unsupported['ok'] ?? null ), 'unsupported normalize() input should not fail unrelated tag invariants.' );
+html_api_fuzz_smoke_assert( 'unsupported' === ( $normalize_unsupported['normalize']['status'] ?? null ), 'unsupported normalize() input should be recorded without an idempotence failure.' );
+
+$normalize_not_idempotent = \HtmlApiFuzz\TagInvariants::check( ' ', array( 'maxTokens' => 2000 ) );
+html_api_fuzz_smoke_assert( true === ( $normalize_not_idempotent['ok'] ?? null ), 'normalize() metadata should not fail unrelated tag invariants directly.' );
+if ( false === ( $normalize_not_idempotent['normalize']['ok'] ?? true ) ) {
+ html_api_fuzz_smoke_assert( 'normalize-not-idempotent' === ( $normalize_not_idempotent['normalize']['failure']['name'] ?? null ), 'non-idempotent normalize() input should report the normalize-not-idempotent invariant.' );
+ html_api_fuzz_smoke_assert( 'failed' === ( $normalize_not_idempotent['normalize']['status'] ?? null ), 'non-idempotent normalize() input should record failed normalize() metadata.' );
+ html_api_fuzz_smoke_assert( is_int( $normalize_not_idempotent['normalize']['firstDifference']['firstByteOffset'] ?? null ), 'non-idempotent normalize() metadata should include a first byte difference.' );
+}
+
+$resource_dir = $tmp . '/resource-limit';
+$resource_result = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( str_repeat( '', 12 ) ),
+ 'profile' => 'replay',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'payload-policy' => 'ascii-structural',
+ 'output-dir' => $resource_dir,
+ 'max-tokens' => '1',
+ 'max-nodes' => '100',
+ )
+);
+html_api_fuzz_smoke_assert( 'resource-limit' === ( $resource_result['failureClass'] ?? null ), 'token ceilings should be bucketed as resource-limit.' );
+html_api_fuzz_smoke_assert( 'resource-limit' === ( $resource_result['status'] ?? null ), 'token ceilings should use resource-limit status.' );
+html_api_fuzz_smoke_assert( in_array( 'tag-token-limit-exceeded', $resource_result['signature']['facts']['limitFailures'] ?? array(), true ), 'resource-limit signature should include concrete limit failure names.' );
+html_api_fuzz_smoke_assert( 'input-base64' === ( $resource_result['inputSource'] ?? null ), 'provided base64 input should record inputSource.' );
+html_api_fuzz_smoke_assert( null === ( $resource_result['generator'] ?? null ), 'provided input should not invent generator metadata.' );
+
+$normalize_resource_dir = $tmp . '/normalize-resource-limit';
+$normalize_resource_result = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( ' ' ),
+ 'profile' => 'replay',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'payload-policy' => 'ascii-structural',
+ 'output-dir' => $normalize_resource_dir,
+ 'max-tokens' => '1',
+ 'max-nodes' => '100',
+ )
+);
+html_api_fuzz_smoke_assert( 'resource-limit' === ( $normalize_resource_result['failureClass'] ?? null ), 'tag token ceilings should not be masked by normalize() failures.' );
+html_api_fuzz_smoke_assert( 'resource-limit' === ( $normalize_resource_result['status'] ?? null ), 'tag token ceilings should retain resource-limit status when normalize() would otherwise fail.' );
+html_api_fuzz_smoke_assert( 'skipped-resource-limit' === ( $normalize_resource_result['tagProcessor']['normalize']['status'] ?? null ), 'normalize() idempotence should be skipped after tag resource limits.' );
+html_api_fuzz_smoke_assert( in_array( 'tag-token-limit-exceeded', $normalize_resource_result['signature']['facts']['limitFailures'] ?? array(), true ), 'resource-limit signature should still include tag token limit failures when normalize() is skipped.' );
+
+$dom_resource_dir = $tmp . '/dom-resource-limit';
+$dom_resource_result = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( 'x
' ),
+ 'profile' => 'replay',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'payload-policy' => 'ascii-structural',
+ 'output-dir' => $dom_resource_dir,
+ 'max-tokens' => '100',
+ 'max-nodes' => '1',
+ )
+);
+html_api_fuzz_smoke_assert( 'resource-limit' === ( $dom_resource_result['failureClass'] ?? null ), 'DOM node ceilings should be bucketed as resource-limit.' );
+html_api_fuzz_smoke_assert( 'resource-limit' === ( $dom_resource_result['status'] ?? null ), 'DOM node ceilings should use resource-limit status.' );
+html_api_fuzz_smoke_assert( 'node-limit-exceeded' === ( $dom_resource_result['dom']['failureClass'] ?? null ), 'DOM result should preserve the concrete node limit failure.' );
+html_api_fuzz_smoke_assert( in_array( 'dom-node-limit-exceeded', $dom_resource_result['signature']['facts']['limitFailures'] ?? array(), true ), 'resource-limit signature should include DOM node limit failures.' );
+
+
+$wp_resource_dir = $tmp . '/wordpress-resource-limit';
+$wp_resource_result = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( 'x
' ),
+ 'profile' => 'replay',
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FULL_DOCUMENT,
+ 'payload-policy' => 'ascii-structural',
+ 'output-dir' => $wp_resource_dir,
+ 'max-tokens' => '3',
+ 'max-nodes' => '100',
+ )
+);
+html_api_fuzz_smoke_assert( 'resource-limit' === ( $wp_resource_result['failureClass'] ?? null ), 'WordPress tree token ceilings should be bucketed as resource-limit.' );
+html_api_fuzz_smoke_assert( 'resource-limit' === ( $wp_resource_result['status'] ?? null ), 'WordPress tree token ceilings should use resource-limit status.' );
+html_api_fuzz_smoke_assert( 'token-limit-exceeded' === ( $wp_resource_result['wordpress']['failureClass'] ?? null ), 'WordPress result should preserve the concrete token limit failure.' );
+html_api_fuzz_smoke_assert( in_array( 'wordpress-token-limit-exceeded', $wp_resource_result['signature']['facts']['limitFailures'] ?? array(), true ), 'resource-limit signature should include WordPress token limit failures.' );
+
+$resource_watcher_run_dir = $tmp . '/resource-watcher-run';
+\HtmlApiFuzz\ensure_dir( $resource_watcher_run_dir );
+\HtmlApiFuzz\append_ndjson(
+ $resource_watcher_run_dir . '/summary.ndjson',
+ array(
+ 'ok' => false,
+ 'status' => $resource_result['status'] ?? null,
+ 'failureClass' => $resource_result['failureClass'] ?? null,
+ 'profile' => $resource_result['profile'] ?? null,
+ 'mode' => $resource_result['mode'] ?? null,
+ 'payloadPolicy' => $resource_result['payloadPolicy'] ?? null,
+ 'generator' => $resource_result['generator'] ?? null,
+ 'inputSource' => $resource_result['inputSource'] ?? null,
+ 'inputSha1' => $resource_result['inputSha1'] ?? null,
+ 'inputLength' => $resource_result['inputLength'] ?? null,
+ 'signature' => $resource_result['signature'] ?? null,
+ 'resultPath' => $resource_dir . '/result.json',
+ 'replayPath' => $resource_dir . '/replay.json',
+ )
+);
+$resource_watcher_state_dir = $tmp . '/resource-watcher-state';
+$resource_watcher_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/watcher.php',
+ '--run-dir',
+ $resource_watcher_run_dir,
+ '--state-dir',
+ $resource_watcher_state_dir,
+ '--once',
+ '--no-minimize',
+ '--max-minimize',
+ '1',
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 10000,
+ $tmp . '/resource-watcher.log'
+);
+html_api_fuzz_smoke_assert( ! $resource_watcher_proc['timedOut'] && 0 === $resource_watcher_proc['code'], 'watcher should process resource-limit summaries.' );
+$resource_watcher_state = \HtmlApiFuzz\read_json_file( $resource_watcher_state_dir . '/state.json' );
+$resource_watcher_hash = $resource_result['signature']['hash'] ?? null;
+$resource_watcher_record = is_string( $resource_watcher_hash ) ? ( $resource_watcher_state['signatures'][ $resource_watcher_hash ] ?? array() ) : array();
+html_api_fuzz_smoke_assert( 'queued' === ( $resource_watcher_record['status'] ?? null ), 'watcher should queue resource-limit signatures for minimization.' );
+html_api_fuzz_smoke_assert( ! isset( $resource_watcher_record['minimizeResult'] ), 'watcher --no-minimize should not start resource-limit minimization.' );
+$resource_watcher_second_proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/watcher.php',
+ '--run-dir',
+ $resource_watcher_run_dir,
+ '--state-dir',
+ $resource_watcher_state_dir,
+ '--once',
+ '--no-minimize',
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 10000,
+ $tmp . '/resource-watcher-second.log'
+);
+$resource_watcher_second = json_decode( $resource_watcher_second_proc['stdout'], true );
+html_api_fuzz_smoke_assert( ! $resource_watcher_second_proc['timedOut'] && 0 === $resource_watcher_second_proc['code'], 'watcher should process a second scan.' );
+html_api_fuzz_smoke_assert( 0 === ( $resource_watcher_second['failuresSeen'] ?? null ), 'watcher should not reread already-scanned summary records.' );
+
+
+echo "OK\n";
diff --git a/tools/html-api-fuzz/tests/lexbor-oracle-smoke.php b/tools/html-api-fuzz/tests/lexbor-oracle-smoke.php
new file mode 100755
index 0000000000000..ab0175278b73b
--- /dev/null
+++ b/tools/html-api-fuzz/tests/lexbor-oracle-smoke.php
@@ -0,0 +1,141 @@
+#!/usr/bin/env php
+ \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE,
+ 'lexbor-oracle-bin' => $binary,
+ )
+);
+$metadata = $oracle->metadata();
+html_api_fuzz_lexbor_smoke_assert( \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE === ( $metadata['kind'] ?? null ), 'Expected Lexbor source oracle metadata.' );
+html_api_fuzz_lexbor_smoke_assert( is_string( $metadata['lexborCommit'] ?? null ) && 1 === preg_match( '/^[0-9a-f]{40}$/', $metadata['lexborCommit'] ), 'Expected the resolved Lexbor commit in oracle metadata.' );
+$pinned_commit = trim( file_get_contents( \HtmlApiFuzz\repo_root() . '/tools/html-api-fuzz/oracles/lexbor/COMMIT' ) );
+html_api_fuzz_lexbor_smoke_assert( $pinned_commit === ( $metadata['lexborCommit'] ?? null ), 'Expected the built Lexbor commit to match the tracked pin.' );
+
+$limits = array(
+ 'maxTokens' => 200,
+ 'maxNodes' => 200,
+);
+
+$work_dir = sys_get_temp_dir() . '/html-api-fuzz-lexbor-oracle-' . \HtmlApiFuzz\timestamp();
+\HtmlApiFuzz\ensure_dir( $work_dir );
+
+$empty_fragment = $oracle->render( '', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, $limits, 'body' );
+html_api_fuzz_lexbor_smoke_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $empty_fragment['status'] ?? null ), 'Expected Lexbor to parse an empty fragment.' );
+html_api_fuzz_lexbor_smoke_assert( "\n" === ( $empty_fragment['tree'] ?? null ), 'Expected Lexbor empty fragment rendering to match the fuzzer tree newline contract.' );
+
+$empty_worker_result = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( '' ),
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'profile' => 'replay',
+ 'seed' => '100',
+ 'output-dir' => $work_dir . '/empty-fragment',
+ 'max-tokens' => '200',
+ 'max-nodes' => '200',
+ 'dom-oracle' => \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE,
+ 'lexbor-oracle-bin' => $binary,
+ )
+);
+html_api_fuzz_lexbor_smoke_assert( true === ( $empty_worker_result['ok'] ?? null ), 'Expected empty fragment Worker run to pass against the Lexbor source oracle.' );
+
+$escaped_tag_name = $oracle->render( ' ', \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, $limits, 'body' );
+html_api_fuzz_lexbor_smoke_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $escaped_tag_name['status'] ?? null ), 'Expected Lexbor to parse a quoted tag-name fixture.' );
+html_api_fuzz_lexbor_smoke_assert( false !== strpos( $escaped_tag_name['tree'] ?? '', "\n" ), 'Expected Lexbor to escape odd tag-name bytes in tree output.' );
+
+$adjusted_svg_names = '
';
+$rendered_adjusted_svg_names = $oracle->render( $adjusted_svg_names, \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, $limits, 'body' );
+html_api_fuzz_lexbor_smoke_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $rendered_adjusted_svg_names['status'] ?? null ), 'Expected Lexbor to parse adjusted SVG names fixture.' );
+html_api_fuzz_lexbor_smoke_assert( false !== strpos( $rendered_adjusted_svg_names['tree'] ?? '', "\n" ), 'Expected Lexbor to render adjusted SVG foreignObject casing.' );
+html_api_fuzz_lexbor_smoke_assert( false !== strpos( $rendered_adjusted_svg_names['tree'] ?? '', "\n" ), 'Expected Lexbor to render adjusted SVG altGlyph casing.' );
+html_api_fuzz_lexbor_smoke_assert( false !== strpos( $rendered_adjusted_svg_names['tree'] ?? '', "\n" ), 'Expected Lexbor to render adjusted SVG linearGradient casing.' );
+html_api_fuzz_lexbor_smoke_assert( false !== strpos( $rendered_adjusted_svg_names['tree'] ?? '', "attributeName=\"x\"" ), 'Expected Lexbor to render adjusted SVG attributeName casing.' );
+html_api_fuzz_lexbor_smoke_assert( false !== strpos( $rendered_adjusted_svg_names['tree'] ?? '', "attributeType=\"XML\"" ), 'Expected Lexbor to render adjusted SVG attributeType casing.' );
+
+$issue_372 = ' ';
+$rendered_372 = $oracle->render( $issue_372, \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, $limits, 'body' );
+html_api_fuzz_lexbor_smoke_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $rendered_372['status'] ?? null ), 'Expected Lexbor to parse issue 372 fixture.' );
+html_api_fuzz_lexbor_smoke_assert( false !== strpos( $rendered_372['tree'] ?? '', "href=\"plain\"" ), 'Expected Lexbor to keep the bare href attribute from issue 372.' );
+html_api_fuzz_lexbor_smoke_assert( false !== strpos( $rendered_372['tree'] ?? '', "xlink href=\"qual\"" ), 'Expected Lexbor to keep the namespaced xlink:href attribute from issue 372.' );
+
+$issue_373 = 'x k';
+$rendered_373 = $oracle->render( $issue_373, \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY, $limits, 'body' );
+html_api_fuzz_lexbor_smoke_assert( \HtmlApiFuzz\TreeRenderer::STATUS_OK === ( $rendered_373['status'] ?? null ), 'Expected Lexbor to parse issue 373 fixture.' );
+html_api_fuzz_lexbor_smoke_assert( false !== strpos( $rendered_373['tree'] ?? '', "\n \"xk\"" ), 'Expected Lexbor to keep post-heading text in the MathML mi element for issue 373.' );
+
+$worker_result_372 = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( $issue_372 ),
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'profile' => 'replay',
+ 'seed' => '372',
+ 'output-dir' => $work_dir . '/issue-372',
+ 'max-tokens' => '200',
+ 'max-nodes' => '200',
+ 'dom-oracle' => \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE,
+ 'lexbor-oracle-bin' => $binary,
+ )
+);
+html_api_fuzz_lexbor_smoke_assert( true === ( $worker_result_372['ok'] ?? null ), 'Expected issue 372 to pass Worker against the Lexbor source oracle.' );
+html_api_fuzz_lexbor_smoke_assert( \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE === ( $worker_result_372['oracle']['kind'] ?? null ), 'Expected Worker result to record Lexbor source oracle kind.' );
+
+$worker_replay_372 = \HtmlApiFuzz\read_json_file( $work_dir . '/issue-372/replay.json' );
+html_api_fuzz_lexbor_smoke_assert( \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE === ( $worker_replay_372['options']['domOracle'] ?? null ), 'Expected replay options to preserve the Lexbor source oracle kind.' );
+html_api_fuzz_lexbor_smoke_assert( $binary === ( $worker_replay_372['options']['lexborOracleBin'] ?? null ), 'Expected replay options to preserve the Lexbor source oracle binary.' );
+html_api_fuzz_lexbor_smoke_assert( ( $metadata['lexborCommit'] ?? null ) === ( $worker_replay_372['oracle']['lexborCommit'] ?? null ), 'Expected replay metadata to preserve the Lexbor source commit.' );
+
+$replay_dir = $work_dir . '/issue-372-replay';
+$proc = \HtmlApiFuzz\run_php_process(
+ array(
+ dirname( __DIR__ ) . '/replay.php',
+ '--replay',
+ $work_dir . '/issue-372/replay.json',
+ '--output-dir',
+ $replay_dir,
+ ),
+ \HtmlApiFuzz\repo_root(),
+ 10000,
+ $work_dir . '/replay.log'
+);
+html_api_fuzz_lexbor_smoke_assert( 0 === $proc['code'], 'Expected replay to pass while preserving the Lexbor source oracle.' );
+$replayed = \HtmlApiFuzz\read_json_file( $replay_dir . '/result.json' );
+html_api_fuzz_lexbor_smoke_assert( \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE === ( $replayed['oracle']['kind'] ?? null ), 'Expected replayed result to use the Lexbor source oracle.' );
+
+$worker_result_373 = \HtmlApiFuzz\Worker::run(
+ array(
+ 'input-base64' => base64_encode( $issue_373 ),
+ 'mode' => \HtmlApiFuzz\Generator::MODE_FRAGMENT_BODY,
+ 'profile' => 'replay',
+ 'seed' => '373',
+ 'output-dir' => $work_dir . '/issue-373',
+ 'max-tokens' => '200',
+ 'max-nodes' => '200',
+ 'dom-oracle' => \HtmlApiFuzz\OracleRenderer::KIND_LEXBOR_SOURCE,
+ 'lexbor-oracle-bin' => $binary,
+ )
+);
+html_api_fuzz_lexbor_smoke_assert( true === ( $worker_result_373['ok'] ?? null ), 'Expected issue 373 to pass Worker against the Lexbor source oracle.' );
+
+\HtmlApiFuzz\remove_dir_recursive( $work_dir );
+html_api_fuzz_lexbor_smoke_assert( ! is_dir( $work_dir ), 'Expected smoke work directory cleanup.' );
+
+echo "OK lexbor-oracle-smoke\n";
diff --git a/tools/html-api-fuzz/tests/result-store-smoke.php b/tools/html-api-fuzz/tests/result-store-smoke.php
new file mode 100644
index 0000000000000..44dc72c6701bb
--- /dev/null
+++ b/tools/html-api-fuzz/tests/result-store-smoke.php
@@ -0,0 +1,235 @@
+#!/usr/bin/env php
+ 'chrome-cdp',
+ 'browserVersion' => '150.0.7871.114',
+ 'chromeExecutable' => '/tmp/chrome-for-testing',
+);
+$lexbor_oracle = array(
+ 'kind' => 'lexbor-source',
+ 'lexborVersion' => '2.10.0',
+ 'lexborCommit' => '481c444261a132190a3fb746d6d2f60824af3717',
+ 'binary' => '/tmp/lexbor-tree-oracle',
+);
+
+$pass_summary = array(
+ 'kind' => 'attempt',
+ 'ok' => true,
+ 'status' => 'passed',
+ 'failureClass' => null,
+ 'seed' => 11,
+ 'profile' => 'document',
+ 'mode' => 'document',
+ 'payloadPolicy' => 'utf8',
+ 'inputSource' => 'generated',
+ 'inputSha1' => sha1( 'pass' ),
+ 'inputLength' => 4,
+ 'signature' => null,
+ 'oracle' => $chrome_oracle,
+ 'artifactsRetained' => false,
+ 'resultPath' => null,
+ 'replayPath' => null,
+ 'logPath' => null,
+ 'durationMs' => 12,
+ 'workerCode' => 0,
+ 'workerTimedOut' => false,
+);
+$pass_id = $store->record_attempt( $pass_summary );
+
+$failure_summary = array(
+ 'kind' => 'failure',
+ 'ok' => false,
+ 'status' => 'failed',
+ 'failureClass' => 'tree-mismatch',
+ 'seed' => 12,
+ 'profile' => 'document',
+ 'mode' => 'document',
+ 'payloadPolicy' => 'utf8',
+ 'inputSource' => 'generated',
+ 'inputSha1' => sha1( 'fail' ),
+ 'inputLength' => 8,
+ 'signature' => array(
+ 'hash' => 'abc123def456',
+ 'familyKey' => 'fam456789abc',
+ ),
+ 'oracle' => $lexbor_oracle,
+ 'artifactsRetained' => true,
+ 'resultPath' => $work_dir . '/seed-12/primary/result.json',
+ 'replayPath' => $work_dir . '/seed-12/primary/replay.json',
+ 'logPath' => null,
+ 'durationMs' => 30,
+ 'workerCode' => 2,
+ 'workerTimedOut' => false,
+);
+$failure_result = array(
+ 'ok' => false,
+ 'status' => 'failed',
+ 'failureClass' => 'tree-mismatch',
+ 'signature' => array( 'hash' => 'abc123def456' ),
+);
+$failure_replay = array(
+ 'kind' => 'html-api-fuzz-replay',
+ 'seed' => 12,
+ 'inputBase64' => base64_encode( 'fail ' ),
+);
+$failure_summary['failureArtifactsRetained'] = true;
+$failure_summary['oracleArtifactsRetained'] = false;
+$failure_id = $store->record_attempt( $failure_summary, $failure_result, $failure_replay );
+
+$pruned_summary = $failure_summary;
+$pruned_summary['seed'] = 13;
+$pruned_summary['artifactsRetained'] = false;
+$pruned_summary['failureArtifactsRetained'] = false;
+$pruned_summary['oracleArtifactsRetained'] = false;
+$pruned_summary['resultPath'] = null;
+$pruned_summary['replayPath'] = null;
+$pruned_id = $store->record_attempt( $pruned_summary, $failure_result, $failure_replay );
+
+$same_seed_summary = $pruned_summary;
+$same_seed_summary['signature'] = array(
+ 'hash' => 'newseedabc123',
+ 'familyKey' => 'newseedfam456',
+);
+$same_seed_result = array(
+ 'ok' => false,
+ 'status' => 'failed',
+ 'failureClass' => 'tree-mismatch',
+ 'signature' => array( 'hash' => 'newseedabc123' ),
+);
+$same_seed_replay = array(
+ 'kind' => 'html-api-fuzz-replay',
+ 'seed' => 13,
+ 'inputBase64' => base64_encode( 'new replay ' ),
+);
+$same_seed_id = $store->record_attempt( $same_seed_summary, $same_seed_result, $same_seed_replay );
+
+$oracle_summary = array(
+ 'kind' => 'oracle-finding',
+ 'ok' => true,
+ 'status' => 'oracle-tolerated',
+ 'failureClass' => 'oracle-tolerated',
+ 'seed' => 14,
+ 'profile' => 'document',
+ 'mode' => 'document',
+ 'payloadPolicy' => 'utf8',
+ 'inputSource' => 'generated',
+ 'inputSha1' => sha1( 'oracle' ),
+ 'inputLength' => 12,
+ 'signature' => null,
+ 'oracle' => $chrome_oracle,
+ 'oracleFinding' => array(
+ 'classification' => 'oracle-bug',
+ 'type' => 'dom-xlink-dropped-local-name-after-xlink',
+ 'suspectedOwner' => 'Legacy Lexbor oracle',
+ 'signature' => array(
+ 'hash' => 'oracle-abc123',
+ 'familyKey' => 'oracle-fam123',
+ ),
+ ),
+ 'artifactsRetained' => true,
+ 'failureArtifactsRetained' => false,
+ 'oracleArtifactsRetained' => true,
+ 'resultPath' => $work_dir . '/seed-14/primary/result.json',
+ 'replayPath' => $work_dir . '/seed-14/primary/replay.json',
+ 'logPath' => null,
+ 'durationMs' => 18,
+ 'workerCode' => 0,
+ 'workerTimedOut' => false,
+);
+$oracle_result = array(
+ 'ok' => true,
+ 'status' => 'oracle-tolerated',
+ 'oracleFinding' => $oracle_summary['oracleFinding'],
+);
+$oracle_replay = array(
+ 'kind' => 'html-api-fuzz-replay',
+ 'seed' => 14,
+ 'inputBase64' => base64_encode( ' ' ),
+ 'oracleFinding' => $oracle_summary['oracleFinding'],
+);
+$oracle_id = $store->record_attempt( $oracle_summary, $oracle_result, $oracle_replay );
+
+html_api_fuzz_smoke_assert( 5 === $store->count_attempts(), 'Expected five recorded attempts.' );
+html_api_fuzz_smoke_assert( array( 12 ) === $store->retained_seeds( 'abc123def456' ), 'Expected seed 12 as the retained exemplar for the signature.' );
+html_api_fuzz_smoke_assert( array() === $store->retained_seeds( 'unseen' ), 'Expected no retained exemplars for an unseen signature.' );
+html_api_fuzz_smoke_assert( array( 14 ) === $store->oracle_retained_seeds( 'oracle-abc123' ), 'Expected seed 14 as the retained exemplar for the oracle signature.' );
+html_api_fuzz_smoke_assert( $store->seed_artifacts_retained( 12 ), 'Expected seed 12 to be marked as retained.' );
+html_api_fuzz_smoke_assert( ! $store->seed_artifacts_retained( 13 ), 'Expected seed 13 not to be marked as retained.' );
+html_api_fuzz_smoke_assert( 5 === $store->max_id(), 'Expected max id of five.' );
+
+$stored_replay = $store->replay_for_seed( 13 );
+html_api_fuzz_smoke_assert( is_array( $stored_replay ) && base64_encode( 'new replay ' ) === ( $stored_replay['inputBase64'] ?? null ), 'Expected seed replay lookup to return the most recent replay for compatibility.' );
+html_api_fuzz_smoke_assert( is_array( $store->replay_for_attempt_id( $pruned_id ) ) && base64_encode( 'fail ' ) === ( $store->replay_for_attempt_id( $pruned_id )['inputBase64'] ?? null ), 'Expected exact attempt replay lookup to survive same-seed reruns.' );
+html_api_fuzz_smoke_assert( is_array( $store->replay_for_attempt_id( $same_seed_id ) ) && base64_encode( 'new replay ' ) === ( $store->replay_for_attempt_id( $same_seed_id )['inputBase64'] ?? null ), 'Expected exact attempt replay lookup to retrieve the newer same-seed replay.' );
+html_api_fuzz_smoke_assert( null === $store->replay_for_seed( 11 ), 'Expected no stored replay for a passing seed.' );
+$stored_oracle_replay = $store->replay_for_seed( 14 );
+html_api_fuzz_smoke_assert( is_array( $stored_oracle_replay ) && base64_encode( ' ' ) === ( $stored_oracle_replay['inputBase64'] ?? null ), 'Expected the oracle finding replay to be retrievable from the store.' );
+html_api_fuzz_smoke_assert( is_array( $store->replay_for_attempt_id( $oracle_id ) ) && base64_encode( ' ' ) === ( $store->replay_for_attempt_id( $oracle_id )['inputBase64'] ?? null ), 'Expected the oracle finding replay to be retrievable by attempt id.' );
+
+$failures = $store->failures_after( 0, $store->max_id() );
+html_api_fuzz_smoke_assert( 3 === count( $failures ), 'Expected three failure rows.' );
+html_api_fuzz_smoke_assert( 12 === ( $failures[0]['record']['seed'] ?? null ), 'Expected the first failure record to be seed 12.' );
+html_api_fuzz_smoke_assert( 'abc123def456' === ( $failures[0]['record']['signature']['hash'] ?? null ), 'Expected the failure record to carry its signature.' );
+
+$tail = $store->failures_after( $failures[0]['id'], $store->max_id() );
+html_api_fuzz_smoke_assert( 2 === count( $tail ) && 13 === ( $tail[0]['record']['seed'] ?? null ) && 13 === ( $tail[1]['record']['seed'] ?? null ), 'Expected incremental reads to resume after an offset.' );
+
+$oracle_findings = $store->oracle_findings_after( 0, $store->max_id() );
+html_api_fuzz_smoke_assert( 1 === count( $oracle_findings ), 'Expected one oracle finding row.' );
+html_api_fuzz_smoke_assert( 14 === ( $oracle_findings[0]['record']['seed'] ?? null ), 'Expected the oracle finding record to be seed 14.' );
+html_api_fuzz_smoke_assert( 'oracle-abc123' === ( $oracle_findings[0]['record']['oracleFinding']['signature']['hash'] ?? null ), 'Expected the oracle finding record to carry its oracle signature.' );
+
+$store->close();
+
+// Reopen read-only as the watcher does and confirm persistence.
+$reader = new \HtmlApiFuzz\ResultStore( $db_path, true );
+html_api_fuzz_smoke_assert( 5 === $reader->count_attempts(), 'Expected attempts to persist across reopen.' );
+html_api_fuzz_smoke_assert( 3 === count( $reader->failures_after( 0, $reader->max_id() ) ), 'Expected failures to persist across reopen.' );
+html_api_fuzz_smoke_assert( 1 === count( $reader->oracle_findings_after( 0, $reader->max_id() ) ), 'Expected oracle findings to persist across reopen.' );
+$reader->close();
+
+// The grouping columns must be queryable without json_extract.
+$raw = new SQLite3( $db_path, SQLITE3_OPEN_READONLY );
+html_api_fuzz_smoke_assert( 2 === (int) $raw->querySingle( "SELECT COUNT(*) FROM attempts WHERE family_key = 'fam456789abc'" ), 'Expected family_key to be stored per failure row.' );
+html_api_fuzz_smoke_assert( 1 === (int) $raw->querySingle( "SELECT COUNT(*) FROM attempts WHERE oracle_family_key = 'oracle-fam123'" ), 'Expected oracle_family_key to be stored per oracle finding row.' );
+html_api_fuzz_smoke_assert( 1 === (int) $raw->querySingle( "SELECT COUNT(*) FROM attempts WHERE oracle_signature_hash = 'oracle-abc123'" ), 'Expected oracle_signature_hash to be queryable.' );
+html_api_fuzz_smoke_assert( 1 === (int) $raw->querySingle( "SELECT COUNT(*) FROM attempts WHERE signature_hash = 'abc123def456' AND failure_artifacts_retained = 1" ), 'Expected failure retention to use its own budget flag.' );
+html_api_fuzz_smoke_assert( 1 === (int) $raw->querySingle( "SELECT COUNT(*) FROM attempts WHERE oracle_signature_hash = 'oracle-abc123' AND oracle_artifacts_retained = 1" ), 'Expected oracle retention to use its own budget flag.' );
+html_api_fuzz_smoke_assert( 1 === (int) $raw->querySingle( "SELECT COUNT(*) FROM attempts WHERE seed = 11 AND oracle_kind = 'chrome-cdp' AND oracle_version = '150.0.7871.114' AND oracle_binary = '/tmp/chrome-for-testing'" ), 'Expected passing rows to keep Chrome oracle metadata in scalar columns.' );
+html_api_fuzz_smoke_assert( 3 === (int) $raw->querySingle( "SELECT COUNT(*) FROM attempts WHERE oracle_kind = 'lexbor-source' AND oracle_version = '2.10.0' AND oracle_commit = '481c444261a132190a3fb746d6d2f60824af3717'" ), 'Expected Lexbor oracle metadata to be queryable for failure rows.' );
+html_api_fuzz_smoke_assert( 3 === (int) $raw->querySingle( "SELECT COUNT(*) FROM attempts WHERE oracle_binary = '/tmp/lexbor-tree-oracle'" ), 'Expected Lexbor oracle binary to be stored in a scalar column.' );
+$raw->close();
+
+$future_db_path = $work_dir . '/future.sqlite';
+$future = new SQLite3( $future_db_path, SQLITE3_OPEN_READWRITE | SQLITE3_OPEN_CREATE );
+$future->exec( 'PRAGMA user_version = 99' );
+$future->close();
+$future_store = new \HtmlApiFuzz\ResultStore( $future_db_path );
+$future_store->close();
+$future = new SQLite3( $future_db_path, SQLITE3_OPEN_READONLY );
+html_api_fuzz_smoke_assert( 99 === (int) $future->querySingle( 'PRAGMA user_version' ), 'Opening a future schema should not downgrade user_version.' );
+$future->close();
+
+\HtmlApiFuzz\remove_dir_recursive( $work_dir );
+html_api_fuzz_smoke_assert( ! is_dir( $work_dir ), 'Expected remove_dir_recursive to delete the work directory.' );
+
+echo "OK result-store-smoke\n";
diff --git a/tools/html-api-fuzz/tests/runner-retention-smoke.php b/tools/html-api-fuzz/tests/runner-retention-smoke.php
new file mode 100644
index 0000000000000..2189cf84b4c6f
--- /dev/null
+++ b/tools/html-api-fuzz/tests/runner-retention-smoke.php
@@ -0,0 +1,232 @@
+#!/usr/bin/env php
+querySingle( 'SELECT COUNT(*) FROM attempts' ), 'Expected six recorded attempts.' );
+html_api_fuzz_smoke_assert( 0 === (int) $db->querySingle( 'SELECT COUNT(*) FROM attempts WHERE ok = 1 AND artifacts_retained = 1' ), 'Expected no retained artifacts for passing attempts.' );
+$rows = $db->query( 'SELECT seed, artifacts_retained FROM attempts' );
+while ( false !== ( $row = $rows->fetchArray( SQLITE3_ASSOC ) ) ) {
+ html_api_fuzz_smoke_assert(
+ is_dir( $run_dir . '/seed-' . $row['seed'] ) === (bool) $row['artifacts_retained'],
+ "Expected seed {$row['seed']} directory presence to match artifacts_retained={$row['artifacts_retained']}."
+ );
+}
+$db->close();
+
+/*
+ * 2. The failure path, deterministically: --fail-unsupported turns the many
+ * unsupported fragment contexts into failures with repeating signatures, so
+ * a cap of 1 must prune repeats while archiving their replay documents.
+ */
+$cap = 1;
+$fail_run_dir = $work_dir . '/fail-run';
+$proc = \HtmlApiFuzz\run_php_process(
+ array(
+ $runner,
+ '--output-dir',
+ $fail_run_dir,
+ '--max-seeds',
+ '40',
+ '--duration-seconds',
+ '0',
+ '--batch-size',
+ '10',
+ '--max-input-bytes',
+ '512',
+ '--fail-unsupported',
+ '--max-keep-per-signature',
+ (string) $cap,
+ ),
+ $repo_root,
+ 300000
+);
+html_api_fuzz_smoke_assert( 0 === $proc['code'], 'Expected failure-path runner to exit cleanly: ' . substr( $proc['output'], -1000 ) );
+
+$db = new SQLite3( $fail_run_dir . '/' . \HtmlApiFuzz\ResultStore::FILENAME, SQLITE3_OPEN_READONLY );
+// Precondition guard: this run must actually exercise pruning. If generator
+// or signature changes stop producing repeated signatures here, fail loudly
+// so the test can be re-tuned instead of silently going vacuous.
+$pruned_failures = (int) $db->querySingle( 'SELECT COUNT(*) FROM attempts WHERE ok = 0 AND artifacts_retained = 0' );
+html_api_fuzz_smoke_assert( $pruned_failures > 0, 'Expected the failure-path run to prune at least one over-cap failure; re-tune the seed range.' );
+html_api_fuzz_smoke_assert( 0 === (int) $db->querySingle( 'SELECT COUNT(*) FROM attempts WHERE ok = 0 AND summary_json IS NULL' ), 'Expected failures to store their summary JSON.' );
+html_api_fuzz_smoke_assert( 0 === (int) $db->querySingle( 'SELECT COUNT(*) FROM attempts WHERE ok = 0 AND artifacts_retained = 0 AND replay_json IS NULL' ), 'Expected pruned failures to archive their replay JSON.' );
+
+$max_retained_per_signature = (int) $db->querySingle(
+ 'SELECT COALESCE( MAX( n ), 0 ) FROM ( SELECT COUNT(*) AS n FROM attempts WHERE artifacts_retained = 1 AND signature_hash IS NOT NULL GROUP BY signature_hash )'
+);
+html_api_fuzz_smoke_assert( $max_retained_per_signature <= $cap, 'Expected retained exemplars per signature to respect the cap.' );
+
+// Every signature with failures keeps its first exemplar on disk.
+$sig_rows = $db->query( 'SELECT signature_hash, MAX(artifacts_retained) AS retained FROM attempts WHERE ok = 0 AND signature_hash IS NOT NULL GROUP BY signature_hash' );
+while ( false !== ( $row = $sig_rows->fetchArray( SQLITE3_ASSOC ) ) ) {
+ html_api_fuzz_smoke_assert( 1 === (int) $row['retained'], "Expected signature {$row['signature_hash']} to retain its first exemplar." );
+}
+
+$rows = $db->query( 'SELECT seed, artifacts_retained FROM attempts' );
+while ( false !== ( $row = $rows->fetchArray( SQLITE3_ASSOC ) ) ) {
+ html_api_fuzz_smoke_assert(
+ is_dir( $fail_run_dir . '/seed-' . $row['seed'] ) === (bool) $row['artifacts_retained'],
+ "Expected seed {$row['seed']} directory presence to match artifacts_retained={$row['artifacts_retained']}."
+ );
+}
+
+$pruned = $db->querySingle( 'SELECT seed, signature_hash FROM attempts WHERE ok = 0 AND artifacts_retained = 0 LIMIT 1', true );
+$db->close();
+
+// A pruned failure must be reproducible from the store alone.
+$proc = \HtmlApiFuzz\run_php_process(
+ array(
+ $replay,
+ '--store',
+ $fail_run_dir . '/' . \HtmlApiFuzz\ResultStore::FILENAME,
+ '--seed',
+ (string) $pruned['seed'],
+ '--output-dir',
+ $work_dir . '/store-replay',
+ ),
+ $repo_root,
+ 60000
+);
+$replay_report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( is_array( $replay_report ), 'Expected replay --store to produce a JSON report: ' . substr( $proc['output'], -1000 ) );
+html_api_fuzz_smoke_assert( false === ( $replay_report['ok'] ?? true ), 'Expected the store replay to reproduce a failure.' );
+html_api_fuzz_smoke_assert(
+ ( $replay_report['signature']['hash'] ?? null ) === $pruned['signature_hash'],
+ 'Expected the store replay to reproduce the original signature.'
+);
+
+/*
+ * 3. A pre-existing stop file must refuse to start rather than silently
+ * succeed with zero seeds.
+ */
+$stop_run_dir = $work_dir . '/stop-run';
+\HtmlApiFuzz\ensure_dir( $stop_run_dir );
+file_put_contents( $stop_run_dir . '/STOP', "{}\n" );
+$proc = \HtmlApiFuzz\run_php_process(
+ array( $runner, '--output-dir', $stop_run_dir, '--max-seeds', '0', '--duration-seconds', '0' ),
+ $repo_root,
+ 60000
+);
+html_api_fuzz_smoke_assert( 0 !== $proc['code'], 'Expected runner to refuse to start over a pre-existing stop file.' );
+html_api_fuzz_smoke_assert( false !== strpos( $proc['output'], 'Stop file already exists' ), 'Expected a clear stale stop file message.' );
+html_api_fuzz_smoke_assert( ! is_file( $stop_run_dir . '/state.json' ), 'Expected no state to be written when refusing to start.' );
+
+/*
+ * 4. Mid-run graceful stop: an indefinite runner must finish its in-flight
+ * batch, record it, and exit with stopReason stop-requested.
+ */
+$mid_run_dir = $work_dir . '/mid-run';
+$spec = array( 0 => array( 'pipe', 'r' ), 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) );
+$process = proc_open(
+ array( PHP_BINARY, $runner, '--output-dir', $mid_run_dir, '--max-seeds', '0', '--duration-seconds', '0', '--batch-size', '5', '--max-input-bytes', '512' ),
+ $spec,
+ $pipes,
+ $repo_root
+);
+html_api_fuzz_smoke_assert( is_resource( $process ), 'Expected the indefinite runner to start.' );
+fclose( $pipes[0] );
+stream_set_blocking( $pipes[1], false );
+stream_set_blocking( $pipes[2], false );
+
+$deadline = microtime( true ) + 120.0;
+$progress = false;
+while ( microtime( true ) < $deadline ) {
+ $mid_state = is_file( $mid_run_dir . '/state.json' ) ? @json_decode( (string) @file_get_contents( $mid_run_dir . '/state.json' ), true ) : null;
+ $attempted = is_array( $mid_state )
+ ? (int) ( $mid_state['successes'] ?? 0 ) + (int) ( $mid_state['failures'] ?? 0 ) + (int) ( $mid_state['unsupported'] ?? 0 )
+ + (int) ( $mid_state['oracleParseErrors'] ?? 0 ) + (int) ( $mid_state['oracleUnsupported'] ?? 0 ) + (int) ( $mid_state['oracleTolerated'] ?? 0 )
+ : 0;
+ if ( $attempted > 0 ) {
+ $progress = true;
+ break;
+ }
+ usleep( 100000 );
+}
+html_api_fuzz_smoke_assert( $progress, 'Expected the indefinite runner to record progress before the stop request.' );
+
+// Request the stop through the stop tool to cover its run-dir path.
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $mid_run_dir ), $repo_root, 30000 );
+html_api_fuzz_smoke_assert( 0 === $proc['code'], 'Expected stop.php to succeed: ' . substr( $proc['output'], -500 ) );
+html_api_fuzz_smoke_assert( is_file( $mid_run_dir . '/STOP' ), 'Expected stop.php to create the stop file.' );
+
+$deadline = microtime( true ) + 120.0;
+$exited = false;
+$code = null;
+while ( microtime( true ) < $deadline ) {
+ stream_get_contents( $pipes[1] );
+ stream_get_contents( $pipes[2] );
+ $status = proc_get_status( $process );
+ if ( ! $status['running'] ) {
+ $exited = true;
+ $code = $status['exitcode'];
+ break;
+ }
+ usleep( 100000 );
+}
+if ( ! $exited ) {
+ proc_terminate( $process, 9 );
+}
+fclose( $pipes[1] );
+fclose( $pipes[2] );
+proc_close( $process );
+html_api_fuzz_smoke_assert( $exited, 'Expected the runner to exit after the stop request.' );
+html_api_fuzz_smoke_assert( 0 === $code, 'Expected the stopped runner to exit cleanly.' );
+
+$mid_state = \HtmlApiFuzz\read_json_file( $mid_run_dir . '/state.json' );
+html_api_fuzz_smoke_assert( 'stop-requested' === ( $mid_state['stopReason'] ?? null ), 'Expected stopReason stop-requested after a mid-run stop.' );
+
+$db = new SQLite3( $mid_run_dir . '/' . \HtmlApiFuzz\ResultStore::FILENAME, SQLITE3_OPEN_READONLY );
+html_api_fuzz_smoke_assert( 0 < (int) $db->querySingle( 'SELECT COUNT(*) FROM attempts' ), 'Expected the in-flight batch to be recorded before stopping.' );
+$db->close();
+
+\HtmlApiFuzz\remove_dir_recursive( $work_dir );
+
+echo "OK runner-retention-smoke\n";
diff --git a/tools/html-api-fuzz/tests/stop-smoke.php b/tools/html-api-fuzz/tests/stop-smoke.php
new file mode 100644
index 0000000000000..54d904381edba
--- /dev/null
+++ b/tools/html-api-fuzz/tests/stop-smoke.php
@@ -0,0 +1,531 @@
+#!/usr/bin/env php
+ 'html-api-fuzz-runner-state',
+ 'updatedAt' => gmdate( 'c' ),
+ 'stopFile' => dirname( $path ) . '/STOP',
+ 'stopReason' => null,
+ ),
+ $overrides
+ )
+ );
+}
+
+function html_api_fuzz_smoke_touch( string $path, int $mtime ): void {
+ html_api_fuzz_smoke_assert( touch( $path, $mtime ), "Expected touch to succeed for {$path}." );
+ clearstatcache( true, $path );
+ html_api_fuzz_smoke_assert( $mtime === (int) filemtime( $path ), "Expected mtime {$mtime} for {$path}." );
+}
+
+$stop_tool = dirname( __DIR__ ) . '/stop.php';
+$runner_tool = dirname( __DIR__ ) . '/runner.php';
+$repo_root = \HtmlApiFuzz\repo_root();
+$work_dir = sys_get_temp_dir() . '/html-api-fuzz-stop-' . \HtmlApiFuzz\timestamp();
+$repo_artifacts_dir = $repo_root . '/artifacts';
+$repo_fuzz_artifacts_dir = $repo_artifacts_dir . '/html-api-fuzz';
+$had_repo_artifacts_dir = is_dir( $repo_artifacts_dir );
+$had_repo_fuzz_artifacts_dir = is_dir( $repo_fuzz_artifacts_dir );
+$repo_relative_run_dir = $repo_fuzz_artifacts_dir . '/run-stop-smoke-' . basename( $work_dir );
+
+register_shutdown_function(
+ static function () use ( $work_dir, $repo_relative_run_dir, $repo_fuzz_artifacts_dir, $repo_artifacts_dir, $had_repo_fuzz_artifacts_dir, $had_repo_artifacts_dir ): void {
+ \HtmlApiFuzz\remove_dir_recursive( $work_dir );
+ \HtmlApiFuzz\remove_dir_recursive( $repo_relative_run_dir );
+ if ( ! $had_repo_fuzz_artifacts_dir ) {
+ @rmdir( $repo_fuzz_artifacts_dir );
+ }
+ if ( ! $had_repo_artifacts_dir ) {
+ @rmdir( $repo_artifacts_dir );
+ }
+ }
+);
+
+/*
+ * Discovery must prefer an unfinished launcher run over a more recently
+ * touched but already finished one.
+ */
+$launcher_artifacts = $work_dir . '/launcher-discovery';
+$finished_run = $launcher_artifacts . '/run-finished';
+\HtmlApiFuzz\ensure_dir( $finished_run );
+html_api_fuzz_smoke_write_runner_state(
+ $finished_run . '/state.json',
+ array(
+ 'stopReason' => 'max-seeds',
+ )
+);
+
+$active_run = $launcher_artifacts . '/run-active';
+\HtmlApiFuzz\ensure_dir( $active_run );
+\HtmlApiFuzz\write_json_file(
+ $active_run . '/launcher-state.json',
+ array(
+ 'kind' => 'html-api-fuzz-launcher-state',
+ 'finished' => false,
+ 'updatedAt' => gmdate( 'c' ),
+ )
+);
+// Make the finished run the more recently touched one.
+html_api_fuzz_smoke_touch( $finished_run . '/state.json', time() + 5 );
+
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--artifacts-dir', $launcher_artifacts ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && is_array( $report ), 'Expected stop.php launcher discovery to succeed: ' . substr( $proc['output'], -500 ) );
+html_api_fuzz_smoke_assert( $active_run === ( $report['runDir'] ?? null ), 'Expected discovery to prefer the unfinished launcher run.' );
+html_api_fuzz_smoke_assert( is_file( $active_run . '/STOP' ), 'Expected the stop file in the active launcher run.' );
+html_api_fuzz_smoke_assert( ! is_file( $finished_run . '/STOP' ), 'Expected no stop file in the finished run.' );
+html_api_fuzz_smoke_assert( false === ( $report['looksFinished'] ?? null ), 'Expected the chosen launcher run not to look finished.' );
+
+// Lane runner state alone is also enough to mark a launch run unfinished.
+$lane_artifacts = $work_dir . '/lane-discovery';
+$lane_run = $lane_artifacts . '/run-lane-active';
+\HtmlApiFuzz\ensure_dir( $lane_run . '/lane-00' );
+html_api_fuzz_smoke_write_runner_state( $lane_run . '/lane-00/state.json' );
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--artifacts-dir', $lane_artifacts ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && $lane_run === ( $report['runDir'] ?? null ), 'Expected lane runner state to mark a launch run unfinished.' );
+html_api_fuzz_smoke_assert( is_file( $lane_run . '/STOP' ), 'Expected the stop file in the lane-active run.' );
+
+/*
+ * A standalone runner writes root state.json and may advertise a custom
+ * stopFile. A newer malformed runner state without stopReason must not be
+ * treated as unfinished.
+ */
+$standalone_artifacts = $work_dir . '/standalone-discovery';
+$missing_run = $standalone_artifacts . '/run-missing-stop-reason';
+\HtmlApiFuzz\ensure_dir( $missing_run );
+\HtmlApiFuzz\write_json_file(
+ $missing_run . '/state.json',
+ array(
+ 'kind' => 'html-api-fuzz-runner-state',
+ 'updatedAt' => gmdate( 'c' ),
+ )
+);
+html_api_fuzz_smoke_touch( $missing_run . '/state.json', time() + 10 );
+
+$standalone_run = $standalone_artifacts . '/run-standalone-active';
+$custom_stop = $standalone_artifacts . '/custom-stop/STOP';
+\HtmlApiFuzz\ensure_dir( $standalone_run );
+html_api_fuzz_smoke_write_runner_state(
+ $standalone_run . '/state.json',
+ array(
+ 'stopFile' => $custom_stop,
+ )
+);
+
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--artifacts-dir', $standalone_artifacts ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && is_array( $report ), 'Expected stop.php standalone discovery to succeed: ' . substr( $proc['output'], -500 ) );
+html_api_fuzz_smoke_assert( $standalone_run === ( $report['runDir'] ?? null ), 'Expected discovery to prefer the unfinished standalone run.' );
+html_api_fuzz_smoke_assert( $standalone_run . '/STOP' === ( $report['stopFile'] ?? null ), 'Expected discovery to report the run-dir stop file as the primary stop file.' );
+html_api_fuzz_smoke_assert( in_array( $custom_stop, $report['stopFiles'] ?? array(), true ), 'Expected discovery to include the standalone runner custom stop file.' );
+html_api_fuzz_smoke_assert( is_file( $custom_stop ), 'Expected the custom stop file to be created.' );
+html_api_fuzz_smoke_assert( is_file( $standalone_run . '/STOP' ), 'Expected the run-dir stop file to be created for watcher and orchestrator paths.' );
+html_api_fuzz_smoke_assert( ! is_file( $missing_run . '/STOP' ), 'Expected no stop file in the malformed runner-state run.' );
+html_api_fuzz_smoke_assert( false === ( $report['looksFinished'] ?? null ), 'Expected the standalone run not to look finished.' );
+
+// A second invocation reports the existing request instead of failing.
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--artifacts-dir', $standalone_artifacts ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && true === ( $report['alreadyRequested'] ?? null ), 'Expected a repeat stop request to be reported as already requested.' );
+
+// Explicit --run-dir inspection also honors the custom stop file.
+unlink( $custom_stop );
+unlink( $standalone_run . '/STOP' );
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $standalone_run ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && $standalone_run . '/STOP' === ( $report['stopFile'] ?? null ), 'Expected --run-dir to report the run-dir stop file as primary.' );
+html_api_fuzz_smoke_assert( in_array( $custom_stop, $report['stopFiles'] ?? array(), true ), 'Expected --run-dir to include the standalone runner custom stop file.' );
+html_api_fuzz_smoke_assert( is_file( $custom_stop ), 'Expected --run-dir to create the custom stop file.' );
+html_api_fuzz_smoke_assert( is_file( $standalone_run . '/STOP' ), 'Expected --run-dir to create the run-dir stop file too.' );
+
+// The README-documented relative command works from the repo root.
+$repo_relative_run_arg = 'artifacts/html-api-fuzz/' . basename( $repo_relative_run_dir );
+\HtmlApiFuzz\ensure_dir( $repo_relative_run_dir );
+\HtmlApiFuzz\write_json_file(
+ $repo_relative_run_dir . '/launcher-state.json',
+ array(
+ 'kind' => 'html-api-fuzz-launcher-state',
+ 'finished' => false,
+ 'updatedAt' => gmdate( 'c' ),
+ )
+);
+$proc = \HtmlApiFuzz\run_php_process( array( 'tools/html-api-fuzz/stop.php', '--run-dir', $repo_relative_run_arg ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && $repo_relative_run_arg === ( $report['runDir'] ?? null ), 'Expected README-style relative --run-dir to succeed: ' . substr( $proc['output'], -500 ) );
+html_api_fuzz_smoke_assert( $repo_relative_run_arg . '/STOP' === ( $report['stopFile'] ?? null ), 'Expected README-style relative --run-dir to report a relative run-dir stop file.' );
+html_api_fuzz_smoke_assert( is_file( $repo_relative_run_dir . '/STOP' ), 'Expected README-style relative --run-dir to create RUN_DIR/STOP.' );
+
+// Explicit --stop-file is added to the discovered stop files.
+unlink( $custom_stop );
+unlink( $standalone_run . '/STOP' );
+$override_stop = $standalone_artifacts . '/override-stop/STOP';
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $standalone_run, '--stop-file', $override_stop ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && $standalone_run . '/STOP' === ( $report['stopFile'] ?? null ), 'Expected --run-dir --stop-file to report the run-dir stop file as primary.' );
+html_api_fuzz_smoke_assert( in_array( $override_stop, $report['stopFiles'] ?? array(), true ), 'Expected --stop-file to be included in stopFiles.' );
+html_api_fuzz_smoke_assert( is_file( $override_stop ), 'Expected --stop-file to create the override stop file.' );
+html_api_fuzz_smoke_assert( is_file( $custom_stop ), 'Expected --run-dir --stop-file to create the advertised custom stop file too.' );
+html_api_fuzz_smoke_assert( is_file( $standalone_run . '/STOP' ), 'Expected --run-dir --stop-file to create the run-dir stop file too.' );
+
+// Explicit --stop-file also works as a direct write without run discovery.
+$direct_stop = $work_dir . '/direct-stop/STOP';
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--stop-file', $direct_stop ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && is_array( $report ), 'Expected direct --stop-file to succeed without run discovery: ' . substr( $proc['output'], -500 ) );
+html_api_fuzz_smoke_assert( null === ( $report['runDir'] ?? null ), 'Expected direct --stop-file to report no run directory.' );
+html_api_fuzz_smoke_assert( is_file( $direct_stop ), 'Expected direct --stop-file to create the requested file.' );
+
+// A run directory without state can only be stopped unambiguously with --stop-file.
+$no_state_run = $work_dir . '/no-state-run';
+\HtmlApiFuzz\ensure_dir( $no_state_run );
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $no_state_run ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 2 === $proc['code'] && false === ( $report['ok'] ?? null ), 'Expected --run-dir without state to report warning status.' );
+$no_state_custom_stop = $work_dir . '/no-state-custom-stop/STOP';
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $no_state_run, '--stop-file', $no_state_custom_stop ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && is_file( $no_state_custom_stop ) && is_file( $no_state_run . '/STOP' ), 'Expected --run-dir --stop-file without state to write both stop files.' );
+
+// Bare and ambiguous CLI invocations fail.
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--stop-file' ), $repo_root, 30000 );
+html_api_fuzz_smoke_assert( 0 !== $proc['code'] && false !== strpos( $proc['stderr'], 'non-empty path' ), 'Expected bare --stop-file to fail with a path error.' );
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', '--stop-file', $work_dir . '/bare-run-dir-stop/STOP' ), $repo_root, 30000 );
+html_api_fuzz_smoke_assert( 0 !== $proc['code'] && false !== strpos( $proc['stderr'], 'non-empty path' ), 'Expected bare --run-dir to fail with a path error.' );
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--stop-stale-seconds' ), $repo_root, 30000 );
+html_api_fuzz_smoke_assert( 0 !== $proc['code'] && false !== strpos( $proc['stderr'], 'numeric' ), 'Expected bare --stop-stale-seconds to fail with a numeric error.' );
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--stop-stale-seconds', 'nope' ), $repo_root, 30000 );
+html_api_fuzz_smoke_assert( 0 !== $proc['code'] && false !== strpos( $proc['stderr'], 'numeric' ), 'Expected non-numeric --stop-stale-seconds to fail with a numeric error.' );
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--artifacts-dir' ), $repo_root, 30000 );
+html_api_fuzz_smoke_assert( 0 !== $proc['code'] && false !== strpos( $proc['stderr'], 'non-empty path' ), 'Expected bare --artifacts-dir to fail with a path error.' );
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--artifacts-dir', $launcher_artifacts, '--stop-file', $work_dir . '/ambiguous-stop/STOP' ), $repo_root, 30000 );
+html_api_fuzz_smoke_assert( 0 !== $proc['code'] && false !== strpos( $proc['stderr'], 'Pass --run-dir' ), 'Expected --artifacts-dir --stop-file without --run-dir to fail as ambiguous.' );
+$proc = \HtmlApiFuzz\run_php_process( array( $runner_tool, '--max-seeds', '1', '--stop-file=' ), $repo_root, 30000 );
+html_api_fuzz_smoke_assert( 0 !== $proc['code'] && false !== strpos( $proc['stderr'], 'non-empty path' ), 'Expected runner --stop-file= to fail with a path error.' );
+
+// Relative advertised stop files are resolved from real runner cwd, not stop.php's cwd.
+$relative_artifacts = $work_dir . '/relative-discovery';
+$relative_run = $relative_artifacts . '/run-relative-stop';
+$relative_cwd = $work_dir . '/relative-cwd';
+$relative_invoke_cwd = $work_dir . '/relative-invoke-cwd';
+$relative_stop = 'custom-relative-stop/STOP';
+\HtmlApiFuzz\ensure_dir( $relative_cwd );
+\HtmlApiFuzz\ensure_dir( $relative_invoke_cwd );
+$relative_cwd_real = realpath( $relative_cwd );
+html_api_fuzz_smoke_assert( is_string( $relative_cwd_real ), 'Expected relative runner cwd realpath.' );
+$proc = \HtmlApiFuzz\run_php_process(
+ array(
+ $runner_tool,
+ '--output-dir',
+ $relative_run,
+ '--max-seeds',
+ '1',
+ '--stop-file',
+ $relative_stop,
+ ),
+ $relative_cwd,
+ 30000
+);
+html_api_fuzz_smoke_assert( 0 === $proc['code'], 'Expected real runner with relative stop file to finish: ' . substr( $proc['output'], -500 ) );
+$runner_state = \HtmlApiFuzz\read_json_file( $relative_run . '/state.json' );
+html_api_fuzz_smoke_assert( is_array( $runner_state ) && $relative_cwd_real === ( $runner_state['cwd'] ?? null ), 'Expected runner state to record its cwd.' );
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $relative_run ), $relative_invoke_cwd, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+$expected_relative_stop = rtrim( $relative_cwd_real, DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR . $relative_stop;
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && in_array( $expected_relative_stop, $report['stopFiles'] ?? array(), true ), 'Expected relative advertised stopFile to resolve from runner cwd.' );
+html_api_fuzz_smoke_assert( is_file( $expected_relative_stop ), 'Expected the runner-cwd-relative stop file to be created.' );
+html_api_fuzz_smoke_assert( ! is_file( $relative_invoke_cwd . '/' . $relative_stop ), 'Expected no stop file relative to stop.php invocation cwd.' );
+
+// On POSIX, a leading backslash is still relative to runner cwd.
+if ( '\\' !== DIRECTORY_SEPARATOR ) {
+ $backslash_run = $work_dir . '/backslash-relative-stop';
+ $backslash_cwd = $work_dir . '/backslash-cwd';
+ $backslash_stop = '\\custom-backslash-stop/STOP';
+ \HtmlApiFuzz\ensure_dir( $backslash_run );
+ \HtmlApiFuzz\ensure_dir( $backslash_cwd );
+ html_api_fuzz_smoke_write_runner_state(
+ $backslash_run . '/state.json',
+ array(
+ 'stopFile' => $backslash_stop,
+ 'cwd' => $backslash_cwd,
+ )
+ );
+ $proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $backslash_run ), $relative_invoke_cwd, 30000 );
+ $report = json_decode( trim( $proc['stdout'] ), true );
+ $expected_backslash_stop = rtrim( $backslash_cwd, DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR . $backslash_stop;
+ html_api_fuzz_smoke_assert( 0 === $proc['code'] && in_array( $expected_backslash_stop, $report['stopFiles'] ?? array(), true ), 'Expected POSIX leading-backslash stopFile to resolve from runner cwd.' );
+ html_api_fuzz_smoke_assert( is_file( $expected_backslash_stop ), 'Expected POSIX leading-backslash stop file to be created under runner cwd.' );
+}
+
+// Legacy active relative stopFile state without cwd warns because the watched file is ambiguous.
+$legacy_relative_run = $work_dir . '/legacy-relative-stop';
+$legacy_relative_cwd = $work_dir . '/legacy-relative-cwd';
+$legacy_relative_stop = 'legacy-relative-caller-cwd/STOP';
+\HtmlApiFuzz\ensure_dir( $legacy_relative_run );
+\HtmlApiFuzz\ensure_dir( $legacy_relative_cwd );
+html_api_fuzz_smoke_write_runner_state(
+ $legacy_relative_run . '/state.json',
+ array(
+ 'stopFile' => $legacy_relative_stop,
+ )
+);
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $legacy_relative_run ), $legacy_relative_cwd, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 2 === $proc['code'] && false === ( $report['ok'] ?? null ), 'Expected legacy relative stopFile without cwd to report warning status.' );
+html_api_fuzz_smoke_assert( false !== strpos( $proc['stderr'], 'exact watched file may be unknown' ), 'Expected legacy relative stopFile without absolute cwd to warn.' );
+html_api_fuzz_smoke_assert( is_file( $legacy_relative_run . '/STOP' ), 'Expected legacy relative stopFile warning path to write RUN_DIR/STOP.' );
+html_api_fuzz_smoke_assert( is_file( $legacy_relative_cwd . '/' . $legacy_relative_stop ), 'Expected legacy relative stopFile warning path to write a caller-cwd candidate.' );
+
+$finished_legacy_relative_run = $work_dir . '/finished-legacy-relative-stop';
+$finished_legacy_relative_cwd = $work_dir . '/finished-legacy-relative-cwd';
+$finished_legacy_relative_stop = 'finished-legacy-relative-caller-cwd/STOP';
+\HtmlApiFuzz\ensure_dir( $finished_legacy_relative_run );
+\HtmlApiFuzz\ensure_dir( $finished_legacy_relative_cwd );
+html_api_fuzz_smoke_write_runner_state(
+ $finished_legacy_relative_run . '/state.json',
+ array(
+ 'stopFile' => $finished_legacy_relative_stop,
+ 'stopReason' => 'max-seeds',
+ )
+);
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $finished_legacy_relative_run ), $finished_legacy_relative_cwd, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 2 === $proc['code'] && false === ( $report['ok'] ?? null ), 'Expected finished legacy relative stopFile without cwd to report warning status.' );
+html_api_fuzz_smoke_assert( false !== strpos( $proc['stderr'], 'exact watched file may be unknown' ), 'Expected finished legacy relative stopFile without cwd to warn.' );
+html_api_fuzz_smoke_assert( is_file( $finished_legacy_relative_run . '/STOP' ), 'Expected finished legacy relative stopFile warning path to write RUN_DIR/STOP.' );
+html_api_fuzz_smoke_assert( is_file( $finished_legacy_relative_cwd . '/' . $finished_legacy_relative_stop ), 'Expected finished legacy relative stopFile warning path to write a caller-cwd candidate.' );
+
+// Active malformed advertised stop files must not report unqualified success.
+$bad_advertised_run = $work_dir . '/bad-advertised-stop';
+\HtmlApiFuzz\ensure_dir( $bad_advertised_run );
+html_api_fuzz_smoke_write_runner_state(
+ $bad_advertised_run . '/state.json',
+ array(
+ 'stopFile' => '',
+ )
+);
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $bad_advertised_run ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 2 === $proc['code'] && is_file( $bad_advertised_run . '/STOP' ), 'Expected RUN_DIR/STOP to be written with warning status when advertised stopFile is empty.' );
+html_api_fuzz_smoke_assert( false === ( $report['ok'] ?? null ) && false !== strpos( $proc['stderr'], 'exact watched file may be unknown' ), 'Expected empty advertised stopFile to warn.' );
+
+$unknown_kind_run = $work_dir . '/unknown-kind-runner-state';
+$unknown_kind_stop = $work_dir . '/unknown-kind-stop/STOP';
+\HtmlApiFuzz\ensure_dir( $unknown_kind_run );
+\HtmlApiFuzz\write_json_file(
+ $unknown_kind_run . '/state.json',
+ array(
+ 'updatedAt' => gmdate( 'c' ),
+ 'stopFile' => $unknown_kind_stop,
+ 'stopReason' => null,
+ )
+);
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $unknown_kind_run ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 2 === $proc['code'] && false === ( $report['ok'] ?? null ), 'Expected runner-like state with missing kind to report warning status.' );
+html_api_fuzz_smoke_assert( is_file( $unknown_kind_stop ), 'Expected runner-like state with missing kind to write the advertised custom stop file.' );
+html_api_fuzz_smoke_assert( is_file( $unknown_kind_run . '/STOP' ), 'Expected runner-like state with missing kind to write RUN_DIR/STOP.' );
+html_api_fuzz_smoke_assert( false !== strpos( $proc['stderr'], 'missing or unknown kind' ), 'Expected runner-like state with missing kind to warn.' );
+
+$missing_stop_file_run = $work_dir . '/missing-stop-file';
+\HtmlApiFuzz\ensure_dir( $missing_stop_file_run );
+\HtmlApiFuzz\write_json_file(
+ $missing_stop_file_run . '/state.json',
+ array(
+ 'kind' => 'html-api-fuzz-runner-state',
+ 'updatedAt' => gmdate( 'c' ),
+ 'stopReason' => null,
+ )
+);
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $missing_stop_file_run ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 2 === $proc['code'] && false === ( $report['ok'] ?? null ), 'Expected active runner state missing stopFile to report warning status.' );
+html_api_fuzz_smoke_assert( false !== strpos( $proc['stderr'], 'missing or malformed' ), 'Expected active runner state missing stopFile to warn.' );
+
+$relative_cwd_run = $work_dir . '/relative-cwd-stop';
+$relative_bad_cwd = $work_dir . '/relative-bad-cwd';
+\HtmlApiFuzz\ensure_dir( $relative_cwd_run );
+\HtmlApiFuzz\ensure_dir( $relative_bad_cwd );
+html_api_fuzz_smoke_write_runner_state(
+ $relative_cwd_run . '/state.json',
+ array(
+ 'stopFile' => 'relative-cwd-caller-cwd/STOP',
+ 'cwd' => 'not-absolute',
+ )
+);
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $relative_cwd_run ), $relative_bad_cwd, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 2 === $proc['code'] && false === ( $report['ok'] ?? null ), 'Expected relative recorded cwd to report warning status.' );
+html_api_fuzz_smoke_assert( false !== strpos( $proc['stderr'], 'no recorded absolute cwd' ), 'Expected relative recorded cwd to warn.' );
+html_api_fuzz_smoke_assert( is_file( $relative_bad_cwd . '/relative-cwd-caller-cwd/STOP' ), 'Expected relative recorded cwd warning path to write a caller-cwd candidate.' );
+
+$unknown_runner_run = $work_dir . '/unknown-runner-stop-file';
+$unknown_runner_cwd = $work_dir . '/unknown-runner-cwd';
+\HtmlApiFuzz\ensure_dir( $unknown_runner_run );
+\HtmlApiFuzz\ensure_dir( $unknown_runner_cwd );
+\HtmlApiFuzz\write_json_file(
+ $unknown_runner_run . '/state.json',
+ array(
+ 'kind' => 'html-api-fuzz-runner-state',
+ 'updatedAt' => gmdate( 'c' ),
+ 'stopFile' => 'unknown-runner-caller-cwd/STOP',
+ )
+);
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $unknown_runner_run ), $unknown_runner_cwd, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 2 === $proc['code'] && false === ( $report['ok'] ?? null ), 'Expected runner state without stopReason and relative stopFile to warn.' );
+
+// Unreadable state warns and still writes RUN_DIR/STOP.
+if ( '\\' !== DIRECTORY_SEPARATOR ) {
+ $unreadable_run = $work_dir . '/unreadable-state';
+ \HtmlApiFuzz\ensure_dir( $unreadable_run );
+ $unreadable_state = $unreadable_run . '/state.json';
+ html_api_fuzz_smoke_write_runner_state( $unreadable_state );
+ html_api_fuzz_smoke_assert( chmod( $unreadable_state, 0000 ), 'Expected chmod to make state unreadable.' );
+ if ( ! is_readable( $unreadable_state ) ) {
+ $proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $unreadable_run ), $repo_root, 30000 );
+ chmod( $unreadable_state, 0600 );
+ $report = json_decode( trim( $proc['stdout'] ), true );
+ html_api_fuzz_smoke_assert( 2 === $proc['code'] && false === ( $report['ok'] ?? null ), 'Expected unreadable state to report warning status.' );
+ html_api_fuzz_smoke_assert( is_file( $unreadable_run . '/STOP' ), 'Expected unreadable state fallback to write RUN_DIR/STOP.' );
+ } else {
+ chmod( $unreadable_state, 0600 );
+ }
+}
+
+// Unreadable in-progress state warns and still writes RUN_DIR/STOP.
+$corrupt_run = $work_dir . '/corrupt-state';
+\HtmlApiFuzz\ensure_dir( $corrupt_run );
+file_put_contents( $corrupt_run . '/state.json', "{not-json\n" );
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--run-dir', $corrupt_run ), $repo_root, 30000 );
+html_api_fuzz_smoke_assert( 2 === $proc['code'] && is_file( $corrupt_run . '/STOP' ), 'Expected corrupt state fallback to write RUN_DIR/STOP with warning status.' );
+html_api_fuzz_smoke_assert( false !== strpos( $proc['stderr'], 'could not read' ), 'Expected corrupt state fallback to warn.' );
+
+// Stale corrupt state is not preferred during discovery.
+$stale_corrupt_artifacts = $work_dir . '/stale-corrupt-discovery';
+$stale_corrupt_run = $stale_corrupt_artifacts . '/run-stale-corrupt';
+$recent_finished_run = $stale_corrupt_artifacts . '/run-recent-finished';
+\HtmlApiFuzz\ensure_dir( $stale_corrupt_run );
+file_put_contents( $stale_corrupt_run . '/state.json', "{not-json\n" );
+html_api_fuzz_smoke_touch( $stale_corrupt_run . '/state.json', time() - 3600 );
+\HtmlApiFuzz\ensure_dir( $recent_finished_run );
+html_api_fuzz_smoke_write_runner_state(
+ $recent_finished_run . '/state.json',
+ array(
+ 'stopReason' => 'max-seeds',
+ )
+);
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--artifacts-dir', $stale_corrupt_artifacts, '--stop-stale-seconds', '10' ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && $recent_finished_run === ( $report['runDir'] ?? null ), 'Expected stale corrupt state not to be preferred during discovery.' );
+html_api_fuzz_smoke_assert( true === ( $report['looksFinished'] ?? null ), 'Expected recent finished fallback to report looksFinished.' );
+html_api_fuzz_smoke_assert( is_file( $recent_finished_run . '/STOP' ), 'Expected recent finished fallback to create STOP.' );
+html_api_fuzz_smoke_assert( false !== strpos( $proc['stderr'], 'already looks stopped' ), 'Expected recent finished fallback to warn.' );
+
+// A stale runner state does not count as unfinished and therefore warns.
+$stale_artifacts = $work_dir . '/stale-discovery';
+$stale_run = $stale_artifacts . '/run-stale';
+$stale_custom = $stale_artifacts . '/custom-stale-stop/STOP';
+\HtmlApiFuzz\ensure_dir( $stale_run );
+html_api_fuzz_smoke_write_runner_state(
+ $stale_run . '/state.json',
+ array(
+ 'updatedAt' => gmdate( 'c', time() - 3600 ),
+ 'batchBudgetMs' => 0,
+ 'stopFile' => $stale_custom,
+ )
+);
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--artifacts-dir', $stale_artifacts, '--stop-stale-seconds', '10' ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && true === ( $report['looksFinished'] ?? null ), 'Expected a stale-only artifacts dir to be reported as looksFinished.' );
+html_api_fuzz_smoke_assert( is_file( $stale_custom ), 'Expected stale custom stop file to be created for the targeted run.' );
+html_api_fuzz_smoke_assert( is_file( $stale_run . '/STOP' ), 'Expected stale run-dir stop file to be created for watcher and orchestrator paths.' );
+html_api_fuzz_smoke_assert( false !== strpos( $proc['stderr'], 'already looks stopped' ), 'Expected a warning when only stale runs exist.' );
+
+// Missing updatedAt falls back to state file mtime for stale detection.
+$mtime_stale_artifacts = $work_dir . '/mtime-stale-discovery';
+$mtime_stale_run = $mtime_stale_artifacts . '/run-mtime-stale';
+\HtmlApiFuzz\ensure_dir( $mtime_stale_run );
+\HtmlApiFuzz\write_json_file(
+ $mtime_stale_run . '/state.json',
+ array(
+ 'kind' => 'html-api-fuzz-runner-state',
+ 'batchBudgetMs' => 0,
+ 'stopReason' => null,
+ )
+);
+html_api_fuzz_smoke_touch( $mtime_stale_run . '/state.json', time() - 3600 );
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--artifacts-dir', $mtime_stale_artifacts, '--stop-stale-seconds', '10' ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && true === ( $report['looksFinished'] ?? null ), 'Expected missing updatedAt to use stale file mtime.' );
+
+// A large batch budget floors the stale threshold so long-running batches still look active.
+$budget_artifacts = $work_dir . '/batch-budget-discovery';
+$budget_run = $budget_artifacts . '/run-budget-active';
+\HtmlApiFuzz\ensure_dir( $budget_run );
+html_api_fuzz_smoke_write_runner_state(
+ $budget_run . '/state.json',
+ array(
+ 'updatedAt' => gmdate( 'c', time() - 30 ),
+ 'batchBudgetMs' => 600000,
+ )
+);
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--artifacts-dir', $budget_artifacts, '--stop-stale-seconds', '10' ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && false === ( $report['looksFinished'] ?? null ), 'Expected batch budget floor to keep the old runner state active.' );
+
+// A stale launcher state does not count as unfinished and therefore warns.
+$stale_launcher_artifacts = $work_dir . '/stale-launcher-discovery';
+$stale_launcher_run = $stale_launcher_artifacts . '/run-stale-launcher';
+\HtmlApiFuzz\ensure_dir( $stale_launcher_run );
+\HtmlApiFuzz\write_json_file(
+ $stale_launcher_run . '/launcher-state.json',
+ array(
+ 'kind' => 'html-api-fuzz-launcher-state',
+ 'finished' => false,
+ 'updatedAt' => gmdate( 'c', time() - 3600 ),
+ )
+);
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--artifacts-dir', $stale_launcher_artifacts, '--stop-stale-seconds', '10' ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && true === ( $report['looksFinished'] ?? null ), 'Expected a stale launcher-only artifacts dir to be reported as looksFinished.' );
+html_api_fuzz_smoke_assert( false !== strpos( $proc['stderr'], 'already looks stopped' ), 'Expected a warning when only stale launcher state exists.' );
+
+// Same-second active runs are ordered deterministically by the run path.
+$tie_artifacts = $work_dir . '/tie-discovery';
+$first_tie_run = $tie_artifacts . '/run-20260101T000000000001Z';
+$next_tie_run = $tie_artifacts . '/run-20260101T000000000002Z';
+\HtmlApiFuzz\ensure_dir( $first_tie_run );
+\HtmlApiFuzz\ensure_dir( $next_tie_run );
+html_api_fuzz_smoke_write_runner_state( $first_tie_run . '/state.json' );
+html_api_fuzz_smoke_write_runner_state( $next_tie_run . '/state.json' );
+$same_mtime = time() + 20;
+html_api_fuzz_smoke_touch( $first_tie_run . '/state.json', $same_mtime );
+html_api_fuzz_smoke_touch( $next_tie_run . '/state.json', $same_mtime );
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--artifacts-dir', $tie_artifacts ), $repo_root, 30000 );
+$report = json_decode( trim( $proc['stdout'] ), true );
+html_api_fuzz_smoke_assert( 0 === $proc['code'] && $next_tie_run === ( $report['runDir'] ?? null ), 'Expected same-second active runs to prefer the later path.' );
+
+// An empty artifacts dir is an error, not a silent success.
+$empty_dir = $work_dir . '/empty';
+\HtmlApiFuzz\ensure_dir( $empty_dir );
+$proc = \HtmlApiFuzz\run_php_process( array( $stop_tool, '--artifacts-dir', $empty_dir ), $repo_root, 30000 );
+html_api_fuzz_smoke_assert( 0 !== $proc['code'], 'Expected stop.php to fail when no run directory exists.' );
+
+echo "OK stop-smoke\n";
diff --git a/tools/html-api-fuzz/tests/tree-renderer-normalization-smoke.php b/tools/html-api-fuzz/tests/tree-renderer-normalization-smoke.php
new file mode 100644
index 0000000000000..cf9fedbcd9af9
--- /dev/null
+++ b/tools/html-api-fuzz/tests/tree-renderer-normalization-smoke.php
@@ -0,0 +1,396 @@
+#!/usr/bin/env php
+ base64_encode( $input ),
+ 'profile' => 'replay',
+ 'mode' => $mode,
+ 'output-dir' => $tmp . '/' . $name,
+ 'max-tokens' => '2000',
+ 'max-nodes' => '3000',
+ )
+ );
+}
+
+/*
+ * Synthetic compare_trees() cases exercise the comparison logic directly and
+ * need no external oracle process.
+ *
+ * The comparison must keep failing on structural differences: scalar
+ * tolerance only applies when the spec substitution explains the entire
+ * differing line.
+ */
+$synthetic_mismatch = \HtmlApiFuzz\TreeRenderer::compare_trees( "\n \"a\"\n\n", "
\n \"b\"\n\n" );
+html_api_fuzz_tree_normalization_assert( false === ( $synthetic_mismatch['ok'] ?? null ), 'Structural tree mismatches should still fail.' );
+html_api_fuzz_tree_normalization_assert( is_array( $synthetic_mismatch['firstDifference'] ?? null ) && 2 === ( $synthetic_mismatch['firstDifference']['line'] ?? null ), 'Structural mismatch should report the first differing line.' );
+
+$synthetic_structure_with_nul = \HtmlApiFuzz\TreeRenderer::compare_trees( "
\n x=\"\\0\"\n \"a\"\n\n", "
\n x=\"\xEF\xBF\xBD\"\n \"b\"\n\n" );
+html_api_fuzz_tree_normalization_assert( false === ( $synthetic_structure_with_nul['ok'] ?? null ), 'Scalar tolerance must not mask structural differences on other lines.' );
+
+$synthetic_tolerated = \HtmlApiFuzz\TreeRenderer::compare_trees( "
\n x=\"\\0\"\n\n", "
\n x=\"\xEF\xBF\xBD\"\n\n" );
+html_api_fuzz_tree_normalization_assert( false === ( $synthetic_tolerated['ok'] ?? null ), 'Raw NUL differences must be reported now that deferred byte processing is applied at read interfaces.' );
+html_api_fuzz_tree_normalization_assert( ! isset( $synthetic_tolerated['scalarToleratedLines'] ), 'Raw NUL differences should not report tolerated lines.' );
+
+$synthetic_nul_with_agreed_cr = \HtmlApiFuzz\TreeRenderer::compare_trees( "
\n x=\"\\0)\\r\"\n\n", "
\n x=\"\xEF\xBF\xBD)\\r\"\n\n" );
+html_api_fuzz_tree_normalization_assert( false === ( $synthetic_nul_with_agreed_cr['ok'] ?? null ), 'A raw NUL must fail even beside an agreed escaped CR.' );
+
+$synthetic_cr_only_wordpress = \HtmlApiFuzz\TreeRenderer::compare_trees( "
\n x=\"a\\nb\"\n\n", "
\n x=\"a\\rb\"\n\n" );
+html_api_fuzz_tree_normalization_assert( false === ( $synthetic_cr_only_wordpress['ok'] ?? null ), 'A DOM-side CR where WordPress holds LF is not the spec substitution and must fail.' );
+
+$synthetic_cr_before_decoded_lf = \HtmlApiFuzz\TreeRenderer::compare_trees( "
\n x=\"\\r\\n\"\n\n", "
\n x=\"\\n\\n\"\n\n" );
+html_api_fuzz_tree_normalization_assert( false === ( $synthetic_cr_before_decoded_lf['ok'] ?? null ), 'WordPress CR+LF opposite DOM LF+LF must be reported.' );
+
+$synthetic_raw_crlf = \HtmlApiFuzz\TreeRenderer::compare_trees( "
\n x=\"\\r\\nX\"\n\n", "
\n x=\"\\nX\"\n\n" );
+html_api_fuzz_tree_normalization_assert( false === ( $synthetic_raw_crlf['ok'] ?? null ), 'Raw CRLF opposite a DOM LF must be reported.' );
+
+$synthetic_backslash_collision = \HtmlApiFuzz\TreeRenderer::compare_trees( "
\n x=\"\\\\r\"\n\n", "
\n x=\"\\\\n\"\n\n" );
+html_api_fuzz_tree_normalization_assert( false === ( $synthetic_backslash_collision['ok'] ?? null ), 'A literal backslash followed by r must not be rewritten as a CR escape.' );
+
+$synthetic_repeated_cr_lf = \HtmlApiFuzz\TreeRenderer::compare_trees(
+ "
\n x=\"" . str_repeat( '\\r\\n', 500 ) . "\"\n\n",
+ "
\n x=\"" . str_repeat( '\\n\\n', 500 ) . "\"\n\n"
+);
+html_api_fuzz_tree_normalization_assert( false === ( $synthetic_repeated_cr_lf['ok'] ?? null ), 'A long run of raw CR plus decoded LF pairs must be reported.' );
+
+/* Current trunk applies the HTML input preprocessing at the read boundary,
+ * so every synthetic NUL/CR tree difference is a real mismatch.
+ */
+$synthetic_text_nul = \HtmlApiFuzz\TreeRenderer::compare_trees( "
\n \"a\\0b\"\n\n", "
\n \"a\xEF\xBF\xBDb\"\n\n" );
+html_api_fuzz_tree_normalization_assert( false === ( $synthetic_text_nul['ok'] ?? null ), 'NUL differences on text lines must be reported.' );
+
+$synthetic_text_cr = \HtmlApiFuzz\TreeRenderer::compare_trees( "
\n \"x\\ry\"\n\n", "
\n \"x\\ny\"\n\n" );
+html_api_fuzz_tree_normalization_assert( false === ( $synthetic_text_cr['ok'] ?? null ), 'CR differences on text lines must be reported.' );
+
+$synthetic_comment_nul = \HtmlApiFuzz\TreeRenderer::compare_trees( "
\n \n\n", "
\n \n\n" );
+html_api_fuzz_tree_normalization_assert( false === ( $synthetic_comment_nul['ok'] ?? null ), 'NUL differences on comment lines must be reported.' );
+
+$synthetic_tag_name_nul = \HtmlApiFuzz\TreeRenderer::compare_trees( "
\n
\n\n", "\n
\n\n" );
+html_api_fuzz_tree_normalization_assert( false === ( $synthetic_tag_name_nul['ok'] ?? null ), 'NUL differences on tag-name lines must be reported.' );
+
+$synthetic_quoted_attribute_name_nul = \HtmlApiFuzz\TreeRenderer::compare_trees( "\n \"a\\0\"=\"\"\n\n", "
\n \"a\xEF\xBF\xBD\"=\"\"\n\n" );
+html_api_fuzz_tree_normalization_assert( false === ( $synthetic_quoted_attribute_name_nul['ok'] ?? null ), 'NUL differences in quoted attribute names must be reported.' );
+
+/*
+ * The tokenizer permits `<` and `!` in attribute names, so `
`
+ * carries an attribute named `