#!/usr/bin/env node /** * F-01: machine-scan tests/browser_*.mjs for the three E2E compatibility * inventories (bare window globals, S field read/write, DOM selectors). * * Repeatable and sort-stable. Writes tests/webui-contract.md and refreshes the * CONTRACT_GLOBALS typeof preamble in tests/browser_smoke.mjs. * * node scripts/extract_webui_contract.mjs # write * node scripts/extract_webui_contract.mjs --check # fail if drifted * node scripts/extract_webui_contract.mjs --self-test */ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const TESTS_DIR = path.join(ROOT, "tests"); const CONTRACT_MD = path.join(TESTS_DIR, "webui-contract.md"); const SMOKE_PATH = path.join(TESTS_DIR, "browser_smoke.mjs"); const SMOKE_BEGIN = "// === CONTRACT_GLOBALS BEGIN (generated by scripts/extract_webui_contract.mjs; do not edit by hand) ==="; const SMOKE_END = "// === CONTRACT_GLOBALS END ==="; const KEYWORDS = new Set([ "break", "case", "catch", "class", "const", "continue", "debugger", "default", "delete", "do", "else", "enum", "export", "extends", "false", "finally", "for", "function", "if", "implements", "import", "in", "instanceof", "interface", "let", "new", "null", "package", "private", "protected", "public", "return", "static", "super", "switch", "this", "throw", "true", "try", "typeof", "var", "void", "while", "with", "yield", "async", "await", "of", "as", "from", "get", "set", ]); const BUILTINS = new Set([ "undefined", "NaN", "Infinity", "arguments", "globalThis", "global", "window", "self", "document", "console", "location", "navigator", "history", "screen", "localStorage", "sessionStorage", "indexedDB", "crypto", "performance", "Event", "CustomEvent", "MouseEvent", "KeyboardEvent", "PointerEvent", "FocusEvent", "MessageEvent", "ErrorEvent", "PromiseRejectionEvent", "EventTarget", "Node", "Element", "HTMLElement", "Document", "DocumentFragment", "Text", "Range", "Selection", "MutationObserver", "IntersectionObserver", "ResizeObserver", "AbortController", "AbortSignal", "URL", "URLSearchParams", "FormData", "Headers", "Request", "Response", "Blob", "File", "FileReader", "Image", "ImageData", "WebSocket", "Worker", "SharedWorker", "MessageChannel", "MessagePort", "BroadcastChannel", "fetch", "XMLHttpRequest", "requestAnimationFrame", "cancelAnimationFrame", "requestIdleCallback", "cancelIdleCallback", "setTimeout", "clearTimeout", "setInterval", "clearInterval", "queueMicrotask", "structuredClone", "atob", "btoa", "encodeURI", "decodeURI", "encodeURIComponent", "decodeURIComponent", "escape", "unescape", "isNaN", "isFinite", "parseInt", "parseFloat", "Number", "String", "Boolean", "Array", "Object", "Map", "Set", "WeakMap", "WeakSet", "Date", "Math", "JSON", "Error", "TypeError", "RangeError", "SyntaxError", "ReferenceError", "URIError", "EvalError", "AggregateError", "Function", "Symbol", "Proxy", "Reflect", "Intl", "BigInt", "ArrayBuffer", "DataView", "Uint8Array", "Uint16Array", "Uint32Array", "Int8Array", "Int16Array", "Int32Array", "Float32Array", "Float64Array", "BigInt64Array", "BigUint64Array", "SharedArrayBuffer", "Atomics", "WeakRef", "FinalizationRegistry", "Promise", "RegExp", "CSS", "getComputedStyle", "matchMedia", "getSelection", "createImageBitmap", "DOMParser", "NodeFilter", "CSSStyleSheet", "ShadowRoot", "customElements", "origin", "isSecureContext", "devicePixelRatio", "innerWidth", "innerHeight", "outerWidth", "outerHeight", "pageXOffset", "pageYOffset", "scrollX", "scrollY", "closed", "opener", "parent", "top", "frames", "frameElement", "length", "status", "name", "event", "alert", "confirm", "prompt", "print", "focus", "blur", "open", "close", "stop", "moveTo", "moveBy", "resizeTo", "resizeBy", "scroll", "scrollTo", "scrollBy", "getComputedStyle", "postMessage", "reportError", "queueMicrotask", "structuredClone", ]); const MUTATING_METHODS = new Set([ "add", "clear", "delete", "set", "push", "pop", "shift", "unshift", "splice", "sort", "reverse", "fill", "copyWithin", ]); const CALL_RE = /\b[A-Za-z_$][\w$]*\.(evaluate|waitForFunction)\s*\(/g; const IDENT_RE = /^[A-Za-z_$][\w$]*$/; function listBrowserHarnesses() { return fs.readdirSync(TESTS_DIR) .filter((name) => name.startsWith("browser_") && name.endsWith(".mjs")) .sort() .map((name) => path.join(TESTS_DIR, name)); } function lineAt(source, index) { let line = 1; for (let i = 0; i < index && i < source.length; i += 1) { if (source.charCodeAt(i) === 10) line += 1; } return line; } function skipLineComment(source, i) { while (i < source.length && source[i] !== "\n") i += 1; return i; } function skipBlockComment(source, i) { const end = source.indexOf("*/", i + 2); return end === -1 ? source.length : end + 2; } function readString(source, i) { const quote = source[i]; let out = quote; i += 1; while (i < source.length) { const ch = source[i]; out += ch; if (ch === "\\") { if (i + 1 < source.length) { out += source[i + 1]; i += 2; continue; } } if (ch === quote) return { value: out, next: i + 1 }; if (ch === "\n" && quote !== "`") return { value: out, next: i }; i += 1; } return { value: out, next: i }; } function readTemplate(source, i) { let out = "`"; i += 1; const interpolations = []; while (i < source.length) { const ch = source[i]; if (ch === "\\") { out += ch + (source[i + 1] || ""); i += 2; continue; } if (ch === "`") { out += ch; return { value: out, next: i + 1, interpolations }; } if (ch === "$" && source[i + 1] === "{") { const inner = readBalanced(source, i + 2, "{", "}"); interpolations.push(inner.value); out += "${" + inner.value + "}"; i = inner.next; continue; } out += ch; i += 1; } return { value: out, next: i, interpolations }; } function readBalanced(source, i, open, close) { // `i` is the first character inside the opener (opener already consumed). let depth = 1; const start = i; while (i < source.length && depth > 0) { const ch = source[i]; if (ch === "/" && source[i + 1] === "/") { i = skipLineComment(source, i); continue; } if (ch === "/" && source[i + 1] === "*") { i = skipBlockComment(source, i); continue; } if (ch === "'" || ch === '"') { i = readString(source, i).next; continue; } if (ch === "`") { i = readTemplate(source, i).next; continue; } if (ch === open) depth += 1; else if (ch === close) { depth -= 1; if (depth === 0) { return { value: source.slice(start, i), next: i + 1 }; } } i += 1; } return { value: source.slice(start), next: i }; } function isIdentStart(ch) { return /[A-Za-z_$]/.test(ch); } function isIdentPart(ch) { return /[A-Za-z0-9_$]/.test(ch); } function isRegexContext(prev) { if (!prev) return true; if (prev.type === "keyword") { return [ "return", "case", "throw", "new", "typeof", "void", "delete", "await", "in", "of", "instanceof", "else", "do", ].includes(prev.value); } if (prev.type !== "punct") return false; return [ "(", "{", "[", ",", ";", ":", "?", "!", "~", "&", "|", "+", "-", "*", "%", "=", "<", ">", "^", "=>", "||", "&&", "??", "==", "===", "!=", "!==", "+=", "-=", "*=", "/=", "%=", "&&", "||", "??", "?.", ].includes(prev.value); } function readRegex(source, i) { let out = "/"; i += 1; let inClass = false; while (i < source.length) { const ch = source[i]; out += ch; if (ch === "\\") { if (i + 1 < source.length) { out += source[i + 1]; i += 2; continue; } } if (ch === "[" && !inClass) inClass = true; else if (ch === "]" && inClass) inClass = false; else if (ch === "/" && !inClass) { i += 1; while (i < source.length && /[a-z]/i.test(source[i])) { out += source[i]; i += 1; } return { value: out, next: i }; } if (ch === "\n") return { value: out, next: i }; i += 1; } return { value: out, next: i }; } function tokenize(source) { const tokens = []; let i = 0; while (i < source.length) { const ch = source[i]; if (ch === " " || ch === "\t" || ch === "\r" || ch === "\n") { i += 1; continue; } if (ch === "/" && source[i + 1] === "/") { i = skipLineComment(source, i); continue; } if (ch === "/" && source[i + 1] === "*") { i = skipBlockComment(source, i); continue; } if (ch === "'" || ch === '"') { const str = readString(source, i); tokens.push({ type: "string", value: str.value, index: i }); i = str.next; continue; } if (ch === "`") { const tmpl = readTemplate(source, i); tokens.push({ type: "template", value: tmpl.value, interpolations: tmpl.interpolations || [], index: i, }); i = tmpl.next; continue; } if (ch === "/" && isRegexContext(tokens[tokens.length - 1])) { const re = readRegex(source, i); tokens.push({ type: "regex", value: re.value, index: i }); i = re.next; continue; } if (isIdentStart(ch)) { let j = i + 1; while (j < source.length && isIdentPart(source[j])) j += 1; const value = source.slice(i, j); tokens.push({ type: KEYWORDS.has(value) ? "keyword" : "ident", value, index: i, }); i = j; continue; } if (/[0-9]/.test(ch) || (ch === "." && /[0-9]/.test(source[i + 1] || ""))) { let j = i + 1; while (j < source.length && /[0-9A-Za-z_.]/.test(source[j])) j += 1; tokens.push({ type: "number", value: source.slice(i, j), index: i }); i = j; continue; } const two = source.slice(i, i + 2); const three = source.slice(i, i + 3); if (["===", "!==", ">>>", "**=", "&&=", "||=", "??="].includes(three)) { tokens.push({ type: "punct", value: three, index: i }); i += 3; continue; } if ([ "=>", "==", "!=", "<=", ">=", "&&", "||", "??", "?.", "++", "--", "+=", "-=", "*=", "/=", "%=", "&=", "|=", "^=", "<<", ">>", "**", ].includes(two)) { tokens.push({ type: "punct", value: two, index: i }); i += 2; continue; } tokens.push({ type: "punct", value: ch, index: i }); i += 1; } return tokens; } function skipWsComments(source, i) { while (i < source.length) { const ch = source[i]; if (ch === " " || ch === "\t" || ch === "\r" || ch === "\n") { i += 1; continue; } if (ch === "/" && source[i + 1] === "/") { i = skipLineComment(source, i); continue; } if (ch === "/" && source[i + 1] === "*") { i = skipBlockComment(source, i); continue; } break; } return i; } function parseFirstCallArg(source, openParenIndex) { let i = skipWsComments(source, openParenIndex + 1); if (i >= source.length) return null; const start = i; if (source[i] === "'" || source[i] === '"') { const str = readString(source, i); return { kind: "string", text: str.value, start, end: str.next }; } if (source[i] === "`") { const tmpl = readTemplate(source, i); return { kind: "template", text: tmpl.value, start, end: tmpl.next }; } // Function / arrow / identifier expression: read until comma or closing // paren at depth 0, respecting strings, templates, comments, and braces. let depthParen = 0; let depthBrace = 0; let depthBracket = 0; while (i < source.length) { const ch = source[i]; if (ch === "/" && source[i + 1] === "/") { i = skipLineComment(source, i); continue; } if (ch === "/" && source[i + 1] === "*") { i = skipBlockComment(source, i); continue; } if (ch === "'" || ch === '"') { i = readString(source, i).next; continue; } if (ch === "`") { i = readTemplate(source, i).next; continue; } if (ch === "(") depthParen += 1; else if (ch === ")") { if (depthParen === 0 && depthBrace === 0 && depthBracket === 0) break; depthParen -= 1; } else if (ch === "{") depthBrace += 1; else if (ch === "}") depthBrace -= 1; else if (ch === "[") depthBracket += 1; else if (ch === "]") depthBracket -= 1; else if (ch === "," && depthParen === 0 && depthBrace === 0 && depthBracket === 0) break; i += 1; } return { kind: "expr", text: source.slice(start, i).trim(), start, end: i }; } function splitArrowFunction(text) { const trimmed = text.trim(); let i = 0; let asyncPrefix = false; if (trimmed.startsWith("async")) { const after = trimmed.slice(5); if (!after || /\s|\(/.test(after[0])) { asyncPrefix = true; i = 5; while (i < trimmed.length && /\s/.test(trimmed[i])) i += 1; } } const rest = trimmed.slice(i); if (rest.startsWith("function")) { let j = 8; while (j < rest.length && /\s/.test(rest[j])) j += 1; if (isIdentStart(rest[j] || "")) { while (j < rest.length && isIdentPart(rest[j])) j += 1; while (j < rest.length && /\s/.test(rest[j])) j += 1; } if (rest[j] !== "(") return null; const params = readBalanced(rest, j + 1, "(", ")"); let k = skipWsComments(rest, params.next); if (rest[k] !== "{") return null; const body = readBalanced(rest, k + 1, "{", "}"); return { params: params.value, body: body.value, name: null }; } if (rest[0] === "(") { const params = readBalanced(rest, 1, "(", ")"); let k = skipWsComments(rest, params.next); if (rest.slice(k, k + 2) !== "=>") return null; k = skipWsComments(rest, k + 2); if (rest[k] === "{") { const body = readBalanced(rest, k + 1, "{", "}"); return { params: params.value, body: body.value, name: null }; } return { params: params.value, body: rest.slice(k), name: null }; } if (isIdentStart(rest[0] || "")) { let j = 1; while (j < rest.length && isIdentPart(rest[j])) j += 1; const ident = rest.slice(0, j); let k = skipWsComments(rest, j); if (rest.slice(k, k + 2) !== "=>") return null; k = skipWsComments(rest, k + 2); if (rest[k] === "{") { const body = readBalanced(rest, k + 1, "{", "}"); return { params: ident, body: body.value, name: null }; } return { params: ident, body: rest.slice(k), name: null }; } return asyncPrefix ? null : null; } function collectBindingsFromPattern(text, into) { const tokens = tokenize(text); for (let i = 0; i < tokens.length; i += 1) { const tok = tokens[i]; if (tok.type !== "ident") continue; const prev = tokens[i - 1]; if (prev && prev.type === "punct" && (prev.value === "." || prev.value === "?.")) continue; const next = tokens[i + 1]; if (next && next.type === "punct" && next.value === ":") continue; into.add(tok.value); } } function collectLexicalBindings(tokens, startIndex, into) { // startIndex points at const/let/var. Bind only the pattern before `=`, not // identifiers inside the initializer (`const rows = parseTable(...)`). let i = startIndex + 1; while (i < tokens.length) { const patternStart = i; let depth = 0; while (i < tokens.length) { const t = tokens[i]; if (t.type === "punct" && (t.value === "{" || t.value === "(" || t.value === "[")) depth += 1; else if (t.type === "punct" && (t.value === "}" || t.value === ")" || t.value === "]")) { if (depth === 0) return; depth -= 1; } else if (depth === 0 && t.type === "punct" && (t.value === "=" || t.value === "," || t.value === ";")) break; else if (depth === 0 && t.type === "keyword" && (t.value === "in" || t.value === "of")) break; i += 1; } const patternToks = tokens.slice(patternStart, i); for (let j = 0; j < patternToks.length; j += 1) { const t = patternToks[j]; if (t.type !== "ident") continue; const prev = patternToks[j - 1]; if (prev && prev.type === "punct" && (prev.value === "." || prev.value === "?.")) continue; const next = patternToks[j + 1]; if (next && next.type === "punct" && next.value === ":") continue; into.add(t.value); } if (i >= tokens.length) break; if (tokens[i].type === "punct" && tokens[i].value === "=") { i += 1; let depth = 0; while (i < tokens.length) { const u = tokens[i]; if (u.type === "punct" && (u.value === "{" || u.value === "(" || u.value === "[")) depth += 1; else if (u.type === "punct" && (u.value === "}" || u.value === ")" || u.value === "]")) depth -= 1; else if (depth === 0 && u.type === "punct" && (u.value === "," || u.value === ";")) break; i += 1; } } if (i >= tokens.length) break; if (tokens[i].type === "punct" && tokens[i].value === ",") { i += 1; continue; } break; } } function collectArrowAndFunctionBindings(source, into) { const tokens = tokenize(source); collectArrowAndFunctionBindingsFromTokens(tokens, into); for (const tok of tokens) { if (tok.type === "template") { for (const inner of tok.interpolations || []) collectArrowAndFunctionBindings(inner, into); } } } function collectArrowAndFunctionBindingsFromTokens(tokens, into) { for (let i = 0; i < tokens.length; i += 1) { const tok = tokens[i]; if (tok.type === "keyword" && tok.value === "function") { const next = tokens[i + 1]; if (next && next.type === "ident") into.add(next.value); continue; } if (tok.type === "keyword" && tok.value === "class") { const next = tokens[i + 1]; if (next && next.type === "ident") into.add(next.value); continue; } if (tok.type === "keyword" && (tok.value === "const" || tok.value === "let" || tok.value === "var")) { collectLexicalBindings(tokens, i, into); continue; } if (tok.type === "keyword" && tok.value === "catch") { const next = tokens[i + 1]; if (next && next.type === "punct" && next.value === "(") { for (let j = i + 2; j < tokens.length; j += 1) { if (tokens[j].type === "punct" && tokens[j].value === ")") break; if (tokens[j].type === "ident") into.add(tokens[j].value); } } continue; } if (tok.type === "punct" && tok.value === "=>") { // Walk backward over optional `)` params or a single ident. let k = i - 1; if (k >= 0 && tokens[k].type === "punct" && tokens[k].value === ")") { let depth = 1; k -= 1; const paramToks = []; while (k >= 0 && depth > 0) { if (tokens[k].type === "punct" && tokens[k].value === ")") depth += 1; else if (tokens[k].type === "punct" && tokens[k].value === "(") { depth -= 1; if (depth === 0) break; } paramToks.push(tokens[k]); k -= 1; } for (const t of paramToks) { if (t.type === "ident") into.add(t.value); } } else if (k >= 0 && tokens[k].type === "ident") { into.add(tokens[k].value); } } } } function parseEvaluateFunction(argText) { const split = splitArrowFunction(argText); if (!split) return null; const locals = new Set(); collectBindingsFromPattern(split.params, locals); collectArrowAndFunctionBindings(split.body, locals); collectArrowAndFunctionBindings(split.params, locals); return { params: split.params, body: split.body, locals }; } function prevNonDot(tokens, index) { const prev = tokens[index - 1]; return prev && prev.type === "punct" && (prev.value === "." || prev.value === "?."); } function nextIsColon(tokens, index) { const next = tokens[index + 1]; return next && next.type === "punct" && next.value === ":"; } function eachTokenStream(source, visit) { const tokens = tokenize(source); visit(tokens); for (const tok of tokens) { if (tok.type === "template") { for (const inner of tok.interpolations || []) eachTokenStream(inner, visit); } } } function optionalGlobalTokens(tokens) { const optional = new Set(); const referenceAt = (i) => { const token = tokens[i]; if (!token || token.type !== "ident") return null; if ((token.value === "window" || token.value === "globalThis") && tokens[i + 1]?.value === ".") { const prop = tokens[i + 2]; return prop?.type === "ident" ? { name: prop.value, index: i + 2, next: i + 3 } : null; } return { name: token.value, index: i, next: i + 1 }; }; for (let i = 0; i < tokens.length; i += 1) { if (tokens[i].value !== "typeof") continue; const ref = referenceAt(i + 1); if (!ref || [".", "["].includes(tokens[ref.next]?.value)) continue; optional.add(ref.index); const comparator = tokens[ref.next]?.value; const value = tokens[ref.next + 1]?.value; const operator = tokens[ref.next + 2]?.value; const safe = ["\"undefined\"", "'undefined'"].includes(value) && ((comparator === "===" && operator === "||") || (comparator === "!==" && operator === "&&")); if (!safe) continue; // Only the short-circuit RHS is guarded. Stop at its enclosing delimiter // or a logical alternative that would make the reference unconditional. let depth = 0; for (let j = ref.next + 3; j < tokens.length; j += 1) { const token = tokens[j]; if (depth === 0 && [")", "]", "}", ";", ",", "?", operator === "&&" ? "||" : "&&"].includes(token.value)) break; const used = referenceAt(j); if (used?.name === ref.name && !prevNonDot(tokens, j)) optional.add(used.index); if (["(", "[", "{"].includes(token.value)) depth += 1; else if ([")", "]", "}"].includes(token.value)) depth -= 1; } } return optional; } function collectFreeIdents(body, locals, requiredOnly = false) { const found = new Map(); // name -> count eachTokenStream(body, (tokens) => { const optional = requiredOnly ? optionalGlobalTokens(tokens) : new Set(); for (let i = 0; i < tokens.length; i += 1) { const tok = tokens[i]; if (tok.type !== "ident" || optional.has(i)) continue; if (prevNonDot(tokens, i)) continue; if (nextIsColon(tokens, i)) continue; if (KEYWORDS.has(tok.value) || BUILTINS.has(tok.value)) continue; if (locals.has(tok.value)) continue; if (tok.value.startsWith("__")) continue; found.set(tok.value, (found.get(tok.value) || 0) + 1); } // window.Foo / globalThis.Foo (not __test hooks, not builtins) for (let i = 0; i < tokens.length - 2; i += 1) { const obj = tokens[i]; const dot = tokens[i + 1]; const prop = tokens[i + 2]; if (obj.type !== "ident" || (obj.value !== "window" && obj.value !== "globalThis")) continue; if (dot.type !== "punct" || (dot.value !== "." && dot.value !== "?.")) continue; if (prop.type !== "ident" || optional.has(i + 2)) continue; if (prop.value.startsWith("__")) continue; if (BUILTINS.has(prop.value) || KEYWORDS.has(prop.value)) continue; if (locals.has(prop.value)) continue; found.set(prop.value, (found.get(prop.value) || 0) + 1); } }); return found; } function classifySAssign(tokens, fieldIndex) { // tokens[fieldIndex] is the ident after `S.` let k = fieldIndex + 1; const pathParts = [tokens[fieldIndex].value]; while (k + 1 < tokens.length && tokens[k].type === "punct" && (tokens[k].value === "." || tokens[k].value === "?.")) { if (tokens[k + 1].type !== "ident") break; pathParts.push(tokens[k + 1].value); k += 2; } const path = pathParts.join("."); const next = tokens[k]; let kind = "read"; if (next && next.type === "punct") { if (next.value === "=" || next.value.endsWith("=") && next.value !== "==" && next.value !== "===" && next.value !== "!=" && next.value !== "!==" && next.value !== "<=" && next.value !== ">=") { kind = pathParts.length === 1 ? "write" : "nested-write"; } else if (next.value === "++" || next.value === "--") { kind = pathParts.length === 1 ? "write" : "nested-write"; } } const prev = tokens[fieldIndex - 2]; // S const beforeS = tokens[fieldIndex - 3]; if (beforeS && beforeS.type === "keyword" && beforeS.value === "delete") { kind = pathParts.length === 1 ? "write" : "nested-write"; } if (beforeS && beforeS.type === "punct" && (beforeS.value === "++" || beforeS.value === "--")) { kind = pathParts.length === 1 ? "write" : "nested-write"; } if (kind === "read" && next && next.type === "punct" && next.value === "(" && pathParts.length > 1) { const method = pathParts[pathParts.length - 1]; if (MUTATING_METHODS.has(method)) kind = "nested-write"; } void prev; return { field: pathParts[0], path, kind }; } function collectSAccesses(body) { const hits = []; eachTokenStream(body, (tokens) => { for (let i = 0; i < tokens.length - 2; i += 1) { const obj = tokens[i]; const dot = tokens[i + 1]; const prop = tokens[i + 2]; if (obj.type !== "ident" || obj.value !== "S") continue; if (dot.type !== "punct" || (dot.value !== "." && dot.value !== "?.")) continue; if (prop.type !== "ident") continue; hits.push({ ...classifySAssign(tokens, i + 2), index: obj.index }); } }); return hits; } function looksLikeSelector(raw) { const value = String(raw || "").trim(); if (!value) return false; if (value.startsWith("/") && !value.startsWith("/#") && !value.startsWith("/.")) return false; return /[#.\[]|:not\(|:has\(|:is\(/.test(value) || /^[a-z][\w-]*([.#[]|:)/i.test(value); } function unquote(literal) { if (!literal) return ""; if (literal[0] === "`") { return literal.slice(1, -1).replace(/\$\{[\s\S]*?\}/g, "*"); } if ((literal[0] === "'" || literal[0] === '"') && literal[literal.length - 1] === literal[0]) { return literal.slice(1, -1); } return literal; } function normalizeSelector(raw) { return unquote(raw) .replace(/\$\{[\s\S]*?\}/g, "*") .replace(/\s+/g, " ") .trim(); } function isTagOnlySelector(sel) { return /^[a-z][\w-]*$/i.test(sel); } const SELECTOR_METHODS = new Set([ "locator", "waitForSelector", "querySelector", "querySelectorAll", "closest", "matches", "getElementById", ]); const MAYBE_SELECTOR_METHODS = new Set(["click", "fill", "textContent", "type"]); function collectSimpleConstStrings(source) { const map = new Map(); const re = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(`[\s\S]*?`|'[^']*'|"[^"]*")\s*;/g; let m; while ((m = re.exec(source))) { map.set(m[1], normalizeSelector(m[2])); } const arrRe = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*\[([\s\S]*?)\]/g; while ((m = arrRe.exec(source))) { const items = []; const itemRe = /(`[\s\S]*?`|'[^']*'|"[^"]*")/g; let it; while ((it = itemRe.exec(m[2]))) items.push(normalizeSelector(it[1])); if (items.length) map.set(m[1], items); } return map; } function collectSelectorsFromSource(source) { const hits = []; // { selector, index } const consts = collectSimpleConstStrings(source); const callRe = /(?:^|[\s.])([A-Za-z_$][\w$]*)\s*\(/g; let m; while ((m = callRe.exec(source))) { const method = m[1]; const bareOk = method === "requireOne"; if (!SELECTOR_METHODS.has(method) && !MAYBE_SELECTOR_METHODS.has(method) && !bareOk) continue; const arg = parseFirstCallArg(source, m.index + m[0].length - 1); if (!arg) continue; let values = []; if (arg.kind === "string" || arg.kind === "template") { values = [normalizeSelector(arg.text)]; } else if (arg.kind === "expr") { const ident = arg.text.trim(); if (IDENT_RE.test(ident) && consts.has(ident)) { const mapped = consts.get(ident); values = Array.isArray(mapped) ? mapped : [mapped]; } } for (const sel of values) { if (!sel) continue; if (method === "getElementById") { hits.push({ selector: `#${sel.replace(/^#/, "")}`, index: arg.start }); continue; } if (MAYBE_SELECTOR_METHODS.has(method) && !looksLikeSelector(sel)) continue; if (isTagOnlySelector(sel)) continue; if (!looksLikeSelector(sel) && !sel.startsWith("#") && !sel.startsWith(".") && !sel.startsWith("[")) continue; hits.push({ selector: sel, index: arg.start }); } } // for (const selector of [ "..." ]) used with locator(selector) const forOf = /for\s*\(\s*(?:const|let|var)\s+selector\s+of\s*\[([\s\S]*?)\]\s*\)/g; while ((m = forOf.exec(source))) { const itemRe = /(`[\s\S]*?`|'[^']*'|"[^"]*")/g; let it; while ((it = itemRe.exec(m[1]))) { const sel = normalizeSelector(it[1]); if (sel && !isTagOnlySelector(sel)) hits.push({ selector: sel, index: m.index + it.index }); } } return hits; } function rel(filePath) { return path.relative(ROOT, filePath).split(path.sep).join("/"); } function emptyInventory() { return { files: [], globals: new Map(), // required main-window names scopedGlobals: new Map(), // optional/legacy/frame uses remain visible in the inventory scopedSFields: new Map(), sFields: new Map(), // field -> { read, write, nestedWrite, files, paths: Set, private } sStarHits: 0, selectors: new Map(), // sel -> { count, files: Set } }; } function addGlobal(inv, name, file, line, n) { let row = inv.globals.get(name); if (!row) { row = { count: 0, files: new Set(), lines: [] }; inv.globals.set(name, row); } row.count += n; row.files.add(file); row.lines.push({ file, line }); } function addSField(inv, hit, file, line) { let row = inv.sFields.get(hit.field); if (!row) { row = { read: 0, write: 0, nestedWrite: 0, files: new Set(), paths: new Set(), private: hit.field.startsWith("_"), }; inv.sFields.set(hit.field, row); } row.files.add(file); if (hit.kind === "write") { row.write += 1; row.paths.add(hit.path); } else if (hit.kind === "nested-write") { row.nestedWrite += 1; row.paths.add(hit.path); } else { row.read += 1; } if (hit.field.startsWith("_")) inv.sStarHits += 1; void line; } function addSelector(inv, sel, file) { let row = inv.selectors.get(sel); if (!row) { row = { count: 0, files: new Set() }; inv.selectors.set(sel, row); } row.count += 1; row.files.add(file); } function withoutGeneratedBlock(source) { const begin = source.indexOf(SMOKE_BEGIN); const end = source.indexOf(SMOKE_END); if (begin === -1 || end === -1 || end < begin) return source; return source.slice(0, begin) + source.slice(end + SMOKE_END.length); } // These names identify existing harness scopes, not globals to suppress. // A call moved outside its guard or onto page.evaluate becomes required. const EVALUATE_CONTEXTS = { "browser_p1_controls.mjs": { legacyGuards: ["legacyUploadShell"] }, "browser_sandbox_preview.mjs": { frameReceivers: ["editor", "ketcherFrame", "exactFrame", "inertFrame"] }, }; function evaluationScope(source, index, receiver, contexts) { if ((contexts.frameReceivers || []).includes(receiver)) return `iframe:${receiver}`; for (const guard of contexts.legacyGuards || []) { const pattern = new RegExp(`\\bif\\s*\\(\\s*${guard}\\s*\\)\\s*\\{`, "g"); let match; while ((match = pattern.exec(source))) { const open = match.index + match[0].length - 1; const block = readBalanced(source, open + 1, "{", "}"); if (index > open && index < block.next) return `legacy:${guard}`; } } return "main"; } function addScopedGlobal(inv, scope, name, file, n) { const key = `${scope}:${name}`; let row = inv.scopedGlobals.get(key); if (!row) { row = { scope, name, count: 0, files: new Set() }; inv.scopedGlobals.set(key, row); } row.count += n; row.files.add(file); } function scanFile(filePath, inv, contexts = EVALUATE_CONTEXTS[path.basename(filePath)] || {}) { const source = withoutGeneratedBlock(fs.readFileSync(filePath, "utf8")); const file = path.basename(filePath); inv.files.push(file); CALL_RE.lastIndex = 0; let m; while ((m = CALL_RE.exec(source))) { const arg = parseFirstCallArg(source, m.index + m[0].length - 1); if (!arg || arg.kind !== "expr") continue; const fn = parseEvaluateFunction(arg.text); if (!fn) continue; const line = lineAt(source, arg.start); const idents = collectFreeIdents(fn.body, fn.locals); const receiver = m[0].slice(0, m[0].indexOf(".")); const scope = evaluationScope(source, m.index, receiver, contexts); const required = scope === "main" ? collectFreeIdents(fn.body, fn.locals, true) : new Map(); for (const [name, count] of required) addGlobal(inv, name, file, line, count); for (const [name, count] of idents) { const scopedCount = count - (required.get(name) || 0); if (scopedCount) addScopedGlobal(inv, scope === "main" ? "optional probe" : scope, name, file, scopedCount); } for (const hit of collectSAccesses(fn.body)) { if (scope === "main") { addSField(inv, hit, file, lineAt(source, arg.start + hit.index)); } else { const key = `${scope}:${hit.field}:${hit.kind}`; let row = inv.scopedSFields.get(key); if (!row) { row = { scope, field: hit.field, kind: hit.kind, count: 0, files: new Set(), paths: new Set() }; inv.scopedSFields.set(key, row); } row.count += 1; row.files.add(file); row.paths.add(hit.path); } } } for (const selHit of collectSelectorsFromSource(source)) { addSelector(inv, selHit.selector, file); } } function scanAll(files) { const inv = emptyInventory(); for (const filePath of files) scanFile(filePath, inv); return inv; } function sortedNames(map) { return [...map.keys()].sort(); } function fileList(set) { return [...set].sort().join(", "); } function renderMarkdown(inv) { const globals = sortedNames(inv.globals); const sPrivate = sortedNames(inv.sFields).filter((n) => inv.sFields.get(n).private); const sPublic = sortedNames(inv.sFields).filter((n) => !inv.sFields.get(n).private); const selectors = sortedNames(inv.selectors); const lines = []; const push = (s = "") => lines.push(s); push("# Web UI E2E compatibility contract"); push(""); push("Machine inventory extracted from `tests/browser_*.mjs` by"); push("`scripts/extract_webui_contract.mjs`. Do not edit by hand — regenerate."); push(""); push("F-01 does not transplant `app.js`. It freezes the three surfaces the"); push("rewrite must keep green:"); push(""); push("1. window globals → `frontend/src/compat/window-exports.ts` (F-05)"); push("2. `S` field read/write, including nested writes after which tests call"); push(" render functions synchronously → `window.S` Proxy (F-05); objects such"); push(" as `_timelineView` keep reference identity"); push("3. DOM id/class/attr selectors → class/id freeze (F-21)"); push(""); push("Source files (sorted):"); for (const name of inv.files) push(`- \`tests/${name}\``); push(""); push("## 1. Bare window globals"); push(""); push("Free identifiers inside `page.evaluate` / `waitForFunction` callbacks"); push("on the main workbench page, minus locals, keywords, browser builtins,"); push("and absence probes. `window.__*` test hooks are omitted. Existing"); push("legacy-shell guards and iframe contexts are inventoried separately."); push("Sorted by name."); push(""); push("| Name | Files | Occurrences |"); push("| --- | --- | --- |"); for (const name of globals) { const row = inv.globals.get(name); push(`| \`${name}\` | ${fileList(row.files)} | ${row.count} |`); } push(""); push(`Total names: ${globals.length}`); push(""); push("## 1a. Context-specific and optional globals"); push(""); push("These uses keep their original harness checks. They are not required"); push("on the default workbench window: legacy blocks run only behind their"); push("existing shell guard, iframe calls run in that frame, and absence probes"); push("explicitly handle missing names. An unguarded main-page use is still required."); push(""); push("| Context | Name | Files | Occurrences |"); push("| --- | --- | --- | --- |"); for (const key of sortedNames(inv.scopedGlobals)) { const row = inv.scopedGlobals.get(key); push(`| ${row.scope} | \`${row.name}\` | ${fileList(row.files)} | ${row.count} |`); } push(""); push("## 2. `S` field read/write surface"); push(""); push("Top-level `S.` accesses inside evaluate callbacks. Nested writes"); push("(`S._timelineView.searchQuery = …`, `.collapsedTurns.add(...)`) are"); push("listed under write paths so F-05 can keep object identity."); push(""); push("### 2a. Private `S._*` fields"); push(""); push("| Field | Read | Write | Nested write | Files | Write paths |"); push("| --- | --- | --- | --- | --- | --- |"); for (const name of sPrivate) { const row = inv.sFields.get(name); const paths = [...row.paths].sort().map((p) => `\`${p}\``).join(", ") || "—"; push(`| \`${name}\` | ${row.read} | ${row.write} | ${row.nestedWrite} | ${fileList(row.files)} | ${paths} |`); } push(""); push(`\`S._*\` identifier occurrences: ${inv.sStarHits}`); push(""); push("### 2b. Other `S.*` fields"); push(""); push("| Field | Read | Write | Nested write | Files | Write paths |"); push("| --- | --- | --- | --- | --- | --- |"); for (const name of sPublic) { const row = inv.sFields.get(name); const paths = [...row.paths].sort().map((p) => `\`${p}\``).join(", ") || "—"; push(`| \`${name}\` | ${row.read} | ${row.write} | ${row.nestedWrite} | ${fileList(row.files)} | ${paths} |`); } push(""); push("### 2c. Context-specific `S` accesses"); push(""); push("These fields belong to the existing guarded or framed checks above;"); push("they do not require a signal on the default workbench `S` Proxy."); push(""); push("| Context | Field | Access | Files | Occurrences |"); push("| --- | --- | --- | --- | --- |"); for (const key of sortedNames(inv.scopedSFields)) { const row = inv.scopedSFields.get(key); push(`| ${row.scope} | \`${row.field}\` | ${row.kind} | ${fileList(row.files)} | ${row.count} |`); } push(""); push("## 3. DOM selector contract"); push(""); push("CSS selectors passed to `locator`, `waitForSelector`, `querySelector`,"); push("`querySelectorAll`, `closest`, `matches`, `getElementById`, and Playwright"); push("`click` / `fill` / `textContent` when the argument is a CSS selector."); push("Template interpolations are normalized to `*`. Tag-only selectors"); push("without id, class, or attribute (for example `script`) are omitted."); push("Sorted."); push(""); push("| Selector | Files | Occurrences |"); push("| --- | --- | --- |"); for (const sel of selectors) { const row = inv.selectors.get(sel); const escaped = sel.replace(/\\/g, "\\\\").replace(/\|/g, "\\|"); push(`| \`${escaped}\` | ${fileList(row.files)} | ${row.count} |`); } push(""); push(`Total selectors: ${selectors.length}`); push(""); push("```json"); push(JSON.stringify({ globals, s_private: sPrivate, s_public: sPublic, s_star_occurrences: inv.sStarHits, selectors, }, null, 2)); push("```"); push(""); return lines.join("\n"); } function generateSmokeBlock(names) { const indent = " "; const list = names.map((n) => `${indent} ${JSON.stringify(n)},`).join("\n"); const typeofMap = names.map((n) => `${indent} ${n}: typeof ${n},`).join("\n"); return [ ` ${SMOKE_BEGIN}`, `${indent}// F-01: per-name typeof preamble for the window-export contract.`, `${indent}// app.js is a classic script (\`const S\` is not window.S) and workbench CSP`, `${indent}// has no unsafe-eval, so names are unrolled as \`typeof ident\`.`, `${indent}const CONTRACT_GLOBALS = Object.freeze([`, list, `${indent}]);`, `${indent}if (!CONTRACT_GLOBALS.length) throw new Error("CONTRACT_GLOBALS is empty");`, `${indent}const kinds = await page.evaluate(() => ({`, typeofMap, `${indent}}));`, `${indent}for (const name of CONTRACT_GLOBALS) {`, `${indent} if (!Object.prototype.hasOwnProperty.call(kinds, name) || kinds[name] === "undefined") {`, `${indent} throw new Error(\`missing contract global: \${name}\`);`, `${indent} }`, `${indent}}`, `${indent}for (const name of Object.keys(kinds)) {`, `${indent} if (!CONTRACT_GLOBALS.includes(name)) {`, `${indent} throw new Error(\`typeof map has extra name not in CONTRACT_GLOBALS: \${name}\`);`, `${indent} }`, `${indent}}`, ` ${SMOKE_END}`, ].join("\n"); } function parseSmokeGlobals(smokeSource) { const begin = smokeSource.indexOf(SMOKE_BEGIN); const end = smokeSource.indexOf(SMOKE_END); if (begin === -1 || end === -1 || end < begin) return null; const block = smokeSource.slice(begin, end); const names = []; const re = /"([A-Za-z_$][\w$]*)"/g; let m; while ((m = re.exec(block))) { // Only names inside the freeze array, not in comments. if (m.index < block.indexOf("Object.freeze")) continue; if (m.index > block.indexOf("]);")) continue; names.push(m[1]); } return names; } function applySmokePreamble(smokeSource, names) { const block = generateSmokeBlock(names); const begin = smokeSource.indexOf(SMOKE_BEGIN); const end = smokeSource.indexOf(SMOKE_END); if (begin !== -1 && end !== -1 && end > begin) { let from = begin; while (from > 0 && smokeSource[from - 1] === " ") from -= 1; if (from > 0 && smokeSource[from - 1] === "\n") { // keep a single blank line before the block } let to = end + SMOKE_END.length; if (smokeSource[to] === "\r") to += 1; if (smokeSource[to] === "\n") to += 1; return smokeSource.slice(0, from) + block + "\n" + smokeSource.slice(to); } const needle = ` await page.locator("#dashboard").waitFor({ state: "visible" });\n`; const at = smokeSource.indexOf(needle); if (at === -1) { throw new Error("cannot find dashboard wait in tests/browser_smoke.mjs to insert CONTRACT_GLOBALS"); } const insertAt = at + needle.length; return smokeSource.slice(0, insertAt) + "\n" + block + "\n" + smokeSource.slice(insertAt); } function inventoryFromScan(files = listBrowserHarnesses()) { const inv = scanAll(files); const globals = sortedNames(inv.globals); return { inv, globals, markdown: renderMarkdown(inv) }; } function writeOutputs({ inv, globals, markdown }) { fs.writeFileSync(CONTRACT_MD, markdown, "utf8"); const smoke = fs.readFileSync(SMOKE_PATH, "utf8"); const next = applySmokePreamble(smoke, globals); if (next !== smoke) fs.writeFileSync(SMOKE_PATH, next, "utf8"); return { globals: globals.length, sFields: inv.sFields.size, sStarHits: inv.sStarHits, selectors: inv.selectors.size, }; } function checkOutputs({ globals, markdown }) { const errors = []; const onDisk = fs.existsSync(CONTRACT_MD) ? fs.readFileSync(CONTRACT_MD, "utf8") : ""; if (onDisk !== markdown) { errors.push("tests/webui-contract.md is stale; re-run scripts/extract_webui_contract.mjs"); } const smoke = fs.readFileSync(SMOKE_PATH, "utf8"); const listed = parseSmokeGlobals(smoke); if (!listed) { errors.push("tests/browser_smoke.mjs is missing the CONTRACT_GLOBALS generated block"); } else { const a = listed.join("\n"); const b = globals.join("\n"); if (a !== b) { const listedSet = new Set(listed); const expectedSet = new Set(globals); for (const name of globals) { if (!listedSet.has(name)) errors.push(`CONTRACT_GLOBALS is missing: ${name}`); } for (const name of listed) { if (!expectedSet.has(name)) errors.push(`CONTRACT_GLOBALS has extra name: ${name}`); } if (a !== b && errors.every((e) => !e.startsWith("CONTRACT_GLOBALS"))) { errors.push("CONTRACT_GLOBALS order drifted from the machine inventory"); } } if (!smoke.includes("missing contract global:")) { errors.push("browser_smoke.mjs preamble no longer throws missing contract global: "); } } return errors; } function selfTest() { const failures = []; const note = (ok, label, detail) => { if (!ok) failures.push(detail ? `${label}: ${detail}` : label); }; const fixture = ` const page = {}; page.evaluate(() => { const local = 1; const group = (groupId) => groupId; S._workbenchLoading = S.currentId; S._timelineView.searchQuery = "q"; S._timelineView.collapsedTurns.add("turn-alpha"); const rows = parseTable("a\tb", { filename: "x.tsv" }); renderMd("x"); t("k"); document.querySelector(".timeline-ledger-row"); document.querySelectorAll("script"); typeof openConversation === "function" && openConversation(local); throw new Error(\`\${S._timelineView.entries.map((entry) => actionTimelineEntryKey(entry))}\`); }); page.locator("#messages"); page.locator(".perm-card.resolved"); page.click("#cancel-btn"); requireOne('[data-variable-inspector="python"]'); page.evaluate((fid) => outstandingAdmissions(fid), "f1"); page.evaluate(() => typeof probeOnly); page.evaluate(() => typeof optionalUpload === "undefined" || optionalUpload.pending.size === 0); if (legacyUploadShell) { page.evaluate(() => { legacyUpload.pending.clear(); legacySend(); S._legacyOnly = null; }); } editor.evaluate(() => window.ketcher.getMolfile()); editor.evaluate(() => sharedScope()); page.evaluate(() => sharedScope()); page.evaluate(() => window.realMainCall()); page.evaluate(() => typeof unsafeAlternative !== "undefined" && unsafeAlternative() || unsafeAlternative()); page.evaluate(() => typeof stillRequired === "undefined" || stillRequired.pending.size === 0); page.evaluate(() => stillRequired()); `; const tmp = path.join(TESTS_DIR, ".extract_webui_contract_selftest.mjs"); fs.writeFileSync(tmp, fixture, "utf8"); try { const inv = emptyInventory(); scanFile(tmp, inv, { legacyGuards: ["legacyUploadShell"], frameReceivers: ["editor"] }); const globals = sortedNames(inv.globals); note(!globals.includes("probeOnly"), "typeof-only probe is not a required export"); note(!globals.includes("optionalUpload"), "absence-guarded optional access is not a required export"); note(!globals.includes("legacyUpload"), "legacy-shell guarded body is not required on the main shell"); note(!globals.includes("legacySend"), "legacy-shell guarded calls retain separate scope"); note(!inv.sFields.has("_legacyOnly"), "legacy-only S fields do not require workbench signals"); note([...inv.scopedSFields.values()].some((row) => row.field === "_legacyOnly"), "legacy S fields remain inventoried"); note(!globals.includes("ketcher"), "iframe globals are not required on the main window"); note(globals.includes("realMainCall"), "a real main-page call remains required"); note(globals.includes("sharedScope"), "an iframe use cannot hide a main-page requirement"); note(globals.includes("unsafeAlternative"), "a guard cannot hide an unguarded logical alternative"); note(generateSmokeBlock(globals).includes("realMainCall: typeof realMainCall"), "main-page requirement stays in the failing typeof preamble"); note(globals.includes("stillRequired"), "an optional probe cannot hide a separate required call"); note(globals.includes("renderMd"), "renderMd is a bare global"); note(globals.includes("t"), "t is a bare global"); note(globals.includes("S"), "S is a bare global"); note(globals.includes("openConversation"), "openConversation is a bare global"); note(globals.includes("outstandingAdmissions"), "outstandingAdmissions is a bare global"); note(globals.includes("parseTable"), "parseTable in a const initializer is a global", JSON.stringify(globals)); note(globals.includes("actionTimelineEntryKey"), "template interpolation globals", JSON.stringify(globals)); note(!globals.includes("entry"), "arrow param in template interpolation is local"); note(!globals.includes("rows"), "const rows is not a global"); note(!globals.includes("filename"), "object key filename is not a global"); note(!globals.includes("local"), "const local is not a global", JSON.stringify(globals)); note(!globals.includes("group"), "const group is not a global"); note(!globals.includes("groupId"), "arrow param groupId is not a global"); note(!globals.includes("fid"), "evaluate param fid is not a global", JSON.stringify(globals)); note(!globals.includes("document"), "document is a builtin"); const loading = inv.sFields.get("_workbenchLoading"); note(!!loading && loading.write >= 1, "_workbenchLoading is written"); const currentId = inv.sFields.get("currentId"); note(!!currentId && currentId.read >= 1, "currentId is read"); const view = inv.sFields.get("_timelineView"); note(!!view && view.nestedWrite >= 1, "_timelineView has nested writes"); note(view && [...view.paths].includes("_timelineView.searchQuery"), "searchQuery write path"); note(view && [...view.paths].includes("_timelineView.collapsedTurns.add"), "collapsedTurns.add write path"); const sels = sortedNames(inv.selectors); note(sels.includes("#messages"), "#messages selector"); note(sels.includes(".perm-card.resolved"), ".perm-card.resolved selector"); note(sels.includes(".timeline-ledger-row"), ".timeline-ledger-row from evaluate"); note(sels.includes("#cancel-btn"), "#cancel-btn from click"); note(sels.includes('[data-variable-inspector="python"]'), "requireOne selector", JSON.stringify(sels)); note(!sels.includes("script"), "tag-only script omitted", JSON.stringify(sels)); const preamble = generateSmokeBlock(["renderMd", "S"]); note(preamble.includes("renderMd: typeof renderMd"), "preamble unrolls typeof identifiers"); note(preamble.includes("missing contract global:"), "preamble names the missing global"); // --check reports a deleted CONTRACT_GLOBALS name. const fakeSmoke = applySmokePreamble( ` await page.locator("#dashboard").waitFor({ state: "visible" });\n`, ["renderMd", "S"], ); const listed = parseSmokeGlobals(fakeSmoke); note(JSON.stringify(listed) === JSON.stringify(["renderMd", "S"]), "parseSmokeGlobals round-trip"); const dropped = fakeSmoke.replace(` "renderMd",\n`, ""); const droppedNames = parseSmokeGlobals(dropped); note(droppedNames && !droppedNames.includes("renderMd") && droppedNames.includes("S"), "deleted renderMd from list"); const errors = []; const expected = ["renderMd", "S"]; const listedSet = new Set(droppedNames); for (const name of expected) { if (!listedSet.has(name)) errors.push(`CONTRACT_GLOBALS is missing: ${name}`); } note(errors.includes("CONTRACT_GLOBALS is missing: renderMd"), "deleting a name is reported by that name", JSON.stringify(errors)); let rewritten = ` await page.locator("#dashboard").waitFor({ state: "visible" });\n for (const selector of []\n`; rewritten = applySmokePreamble(rewritten, ["S", "t"]); rewritten = applySmokePreamble(rewritten, ["S", "t"]); const beginLines = rewritten.split("\n").filter((line) => line.includes(SMOKE_BEGIN)); note(beginLines.length === 1 && beginLines[0] === ` ${SMOKE_BEGIN}`, "re-applying the preamble does not accumulate indent", JSON.stringify(beginLines)); } finally { fs.rmSync(tmp, { force: true }); } return failures; } function main(argv) { const args = new Set(argv); if (args.has("--self-test")) { const failures = selfTest(); if (failures.length) { for (const failure of failures) console.error(`self-test: ${failure}`); process.exit(1); } console.log("extract_webui_contract self-test passed"); return; } const { inv, globals, markdown } = inventoryFromScan(); if (args.has("--stdout")) { process.stdout.write(markdown); return; } if (args.has("--json")) { process.stdout.write(JSON.stringify({ globals, s_private: sortedNames(inv.sFields).filter((n) => inv.sFields.get(n).private), s_public: sortedNames(inv.sFields).filter((n) => !inv.sFields.get(n).private), s_star_occurrences: inv.sStarHits, selectors: sortedNames(inv.selectors), }, null, 2) + "\n"); return; } if (args.has("--check")) { const errors = checkOutputs({ globals, markdown }); if (errors.length) { for (const error of errors) console.error(error); process.exit(1); } console.log( `webui contract up to date: ${globals.length} globals, ${inv.sFields.size} S fields, ${inv.sStarHits} S._* hits, ${inv.selectors.size} selectors`, ); return; } const stats = writeOutputs({ inv, globals, markdown }); console.log( `wrote tests/webui-contract.md and CONTRACT_GLOBALS (${stats.globals} globals, ${stats.sFields} S fields, ${stats.sStarHits} S._* hits, ${stats.selectors} selectors)`, ); } main(process.argv.slice(2));