Spaces:
Sleeping
Sleeping
| /** | |
| * End-to-end browser audit for the deployed img2threejs Space. | |
| * | |
| * This intentionally uses only Node.js built-ins and Chromium's raw Chrome | |
| * DevTools Protocol. It does not start a local server and does not use | |
| * Playwright, Puppeteer, Selenium, or sleep-based polling. | |
| * | |
| * Examples: | |
| * node scripts/live_browser_audit.mjs \ | |
| * --url https://mike0021-img2threejs.hf.space \ | |
| * --image tests/fixtures/mug_photo.png \ | |
| * --output-dir /tmp/img2threejs-browser-audit | |
| * | |
| * node scripts/live_browser_audit.mjs \ | |
| * --url https://mike0021-img2threejs.hf.space \ | |
| * --skip-submit --gallery-id 0123456789abcdef0123456789abcdef \ | |
| * --output-dir /tmp/img2threejs-browser-audit | |
| * | |
| * The Hugging Face parent page is derived from /health.space_id. Pass | |
| * --hub-url only when auditing a deployment that does not expose SPACE_ID. | |
| */ | |
| import { spawn } from 'node:child_process'; | |
| import { createHash } from 'node:crypto'; | |
| import { | |
| access, | |
| constants as fsConstants, | |
| mkdir, | |
| mkdtemp, | |
| readFile, | |
| rm, | |
| stat, | |
| writeFile, | |
| } from 'node:fs/promises'; | |
| import { tmpdir } from 'node:os'; | |
| import { basename, join, resolve } from 'node:path'; | |
| import process from 'node:process'; | |
| const DEFAULT_GENERATION_TIMEOUT_MS = 15 * 60 * 1000; | |
| const DEFAULT_VIEWER_TIMEOUT_MS = 75 * 1000; | |
| const DEFAULT_ACTION_TIMEOUT_MS = 30 * 1000; | |
| const GALLERY_ID_PATTERN = /^[a-f0-9]{32}$/i; | |
| const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); | |
| function usage() { | |
| return ` | |
| Usage: | |
| node scripts/live_browser_audit.mjs --url URL --image IMAGE --output-dir DIR [options] | |
| node scripts/live_browser_audit.mjs --url URL --skip-submit --gallery-id ID --output-dir DIR [options] | |
| Required: | |
| --url URL Direct deployed app URL (for example, *.hf.space) | |
| --output-dir DIR Parent directory for screenshots and downloaded files | |
| --image FILE Image submitted through #file-input (unless --skip-submit) | |
| Options: | |
| --hint TEXT Optional object hint entered before submission | |
| --skip-submit Audit an existing shared gallery item | |
| --gallery-id ID Existing 32-hex gallery ID (required with --skip-submit) | |
| --hub-url URL huggingface.co/spaces/... parent page (normally auto-detected) | |
| --chromium FILE Chromium executable (auto-detected by default) | |
| --timeout-ms N Generation timeout (default ${DEFAULT_GENERATION_TIMEOUT_MS}) | |
| --viewer-timeout-ms N Viewer-ready timeout (default ${DEFAULT_VIEWER_TIMEOUT_MS}) | |
| --help Show this help | |
| `.trim(); | |
| } | |
| function parseArgs(argv) { | |
| const options = { | |
| url: null, | |
| image: null, | |
| outputDir: null, | |
| hint: '', | |
| skipSubmit: false, | |
| galleryId: null, | |
| hubUrl: null, | |
| chromium: null, | |
| timeoutMs: DEFAULT_GENERATION_TIMEOUT_MS, | |
| viewerTimeoutMs: DEFAULT_VIEWER_TIMEOUT_MS, | |
| }; | |
| const valueOptions = new Map([ | |
| ['--url', 'url'], | |
| ['--image', 'image'], | |
| ['--output-dir', 'outputDir'], | |
| ['--hint', 'hint'], | |
| ['--gallery-id', 'galleryId'], | |
| ['--hub-url', 'hubUrl'], | |
| ['--chromium', 'chromium'], | |
| ['--timeout-ms', 'timeoutMs'], | |
| ['--viewer-timeout-ms', 'viewerTimeoutMs'], | |
| ]); | |
| for (let index = 0; index < argv.length; index += 1) { | |
| const raw = argv[index]; | |
| if (raw === '--help' || raw === '-h') { | |
| options.help = true; | |
| continue; | |
| } | |
| if (raw === '--skip-submit') { | |
| options.skipSubmit = true; | |
| continue; | |
| } | |
| const equals = raw.indexOf('='); | |
| const flag = equals === -1 ? raw : raw.slice(0, equals); | |
| if (!valueOptions.has(flag)) throw new Error(`Unknown argument: ${raw}`); | |
| const inlineValue = equals === -1 ? null : raw.slice(equals + 1); | |
| const value = inlineValue ?? argv[++index]; | |
| if (value === undefined || value === '') throw new Error(`${flag} requires a value.`); | |
| options[valueOptions.get(flag)] = value; | |
| } | |
| if (options.help) return options; | |
| if (!options.url) throw new Error('--url is required.'); | |
| if (!options.outputDir) throw new Error('--output-dir is required.'); | |
| if (options.skipSubmit && !options.galleryId) { | |
| throw new Error('--gallery-id is required with --skip-submit.'); | |
| } | |
| if (!options.skipSubmit && !options.image) { | |
| throw new Error('--image is required unless --skip-submit is used.'); | |
| } | |
| if (options.galleryId && !GALLERY_ID_PATTERN.test(options.galleryId)) { | |
| throw new Error('--gallery-id must be a 32-character hexadecimal ID.'); | |
| } | |
| for (const key of ['timeoutMs', 'viewerTimeoutMs']) { | |
| const value = Number(options[key]); | |
| if (!Number.isFinite(value) || value < 1000) { | |
| throw new Error(`--${key === 'timeoutMs' ? 'timeout-ms' : 'viewer-timeout-ms'} must be at least 1000.`); | |
| } | |
| options[key] = Math.floor(value); | |
| } | |
| return options; | |
| } | |
| function assert(condition, message) { | |
| if (!condition) throw new Error(`Assertion failed: ${message}`); | |
| } | |
| function delayTimeout(ms, callback) { | |
| const timer = setTimeout(callback, ms); | |
| timer.unref?.(); | |
| return timer; | |
| } | |
| async function findChromium(explicitPath) { | |
| const candidates = [ | |
| explicitPath, | |
| process.env.CHROMIUM_BIN, | |
| '/snap/bin/chromium', | |
| '/usr/bin/chromium', | |
| '/usr/bin/chromium-browser', | |
| '/usr/bin/google-chrome', | |
| '/usr/bin/google-chrome-stable', | |
| ].filter(Boolean); | |
| for (const candidate of candidates) { | |
| try { | |
| await access(candidate, fsConstants.X_OK); | |
| return candidate; | |
| } catch { | |
| // Try the next known location. | |
| } | |
| } | |
| throw new Error( | |
| `Could not find Chromium. Pass --chromium FILE. Checked: ${candidates.join(', ')}`, | |
| ); | |
| } | |
| function directAppRoot(rawUrl) { | |
| const parsed = new URL(rawUrl); | |
| assert(parsed.protocol === 'https:', 'the live audit URL must use HTTPS'); | |
| assert( | |
| !['localhost', '127.0.0.1', '::1'].includes(parsed.hostname), | |
| 'the audit must target a deployed app, not a local server', | |
| ); | |
| parsed.pathname = '/'; | |
| parsed.search = ''; | |
| parsed.hash = ''; | |
| return parsed.href; | |
| } | |
| async function resolveHubParentUrl(explicitUrl, appRoot) { | |
| if (explicitUrl) { | |
| const parsed = new URL(explicitUrl); | |
| assert(parsed.protocol === 'https:', '--hub-url must use HTTPS'); | |
| assert(parsed.hostname === 'huggingface.co', '--hub-url must use huggingface.co'); | |
| assert(/^\/spaces\/[^/]+\/[^/]+\/?$/.test(parsed.pathname), '--hub-url must identify a Space parent page'); | |
| parsed.search = ''; | |
| parsed.hash = ''; | |
| return parsed.href.replace(/\/$/, ''); | |
| } | |
| const response = await fetch(new URL('/health', appRoot), { | |
| headers: { Accept: 'application/json' }, | |
| signal: AbortSignal.timeout(30_000), | |
| }); | |
| assert(response.ok, `could not discover Space ID from /health (HTTP ${response.status})`); | |
| const health = await response.json(); | |
| const spaceId = String(health.space_id || ''); | |
| assert( | |
| /^[^/\s]+\/[^/\s]+$/.test(spaceId), | |
| 'the deployed /health response has no SPACE_ID; pass --hub-url explicitly', | |
| ); | |
| return `https://huggingface.co/spaces/${spaceId}`; | |
| } | |
| class CdpConnection { | |
| constructor(socket, commandTimeoutMs = DEFAULT_ACTION_TIMEOUT_MS) { | |
| this.socket = socket; | |
| this.commandTimeoutMs = commandTimeoutMs; | |
| this.nextId = 1; | |
| this.pending = new Map(); | |
| this.eventHandlers = new Set(); | |
| this.closed = false; | |
| socket.addEventListener('message', (event) => { | |
| void this.#handleMessage(event.data); | |
| }); | |
| socket.addEventListener('close', () => { | |
| this.closed = true; | |
| for (const pending of this.pending.values()) { | |
| clearTimeout(pending.timer); | |
| pending.reject(new Error('The Chromium DevTools connection closed.')); | |
| } | |
| this.pending.clear(); | |
| }); | |
| socket.addEventListener('error', () => { | |
| // The corresponding close or command timeout produces the actionable error. | |
| }); | |
| } | |
| async #handleMessage(raw) { | |
| let text; | |
| if (typeof raw === 'string') text = raw; | |
| else if (raw instanceof ArrayBuffer) text = Buffer.from(raw).toString('utf8'); | |
| else if (typeof raw?.text === 'function') text = await raw.text(); | |
| else text = Buffer.from(raw).toString('utf8'); | |
| const message = JSON.parse(text); | |
| if (message.id !== undefined) { | |
| const pending = this.pending.get(message.id); | |
| if (!pending) return; | |
| this.pending.delete(message.id); | |
| clearTimeout(pending.timer); | |
| if (message.error) { | |
| pending.reject( | |
| new Error(`${pending.method}: ${message.error.message} (${message.error.code})`), | |
| ); | |
| } else { | |
| pending.resolve(message.result || {}); | |
| } | |
| return; | |
| } | |
| for (const handler of [...this.eventHandlers]) { | |
| try { | |
| handler(message); | |
| } catch { | |
| // Event consumers own their asynchronous errors. | |
| } | |
| } | |
| } | |
| send(method, params = {}, sessionId = undefined, timeoutMs = this.commandTimeoutMs) { | |
| if (this.closed) return Promise.reject(new Error('The DevTools connection is closed.')); | |
| const id = this.nextId++; | |
| const payload = { id, method, params }; | |
| if (sessionId) payload.sessionId = sessionId; | |
| return new Promise((resolvePromise, rejectPromise) => { | |
| const timer = delayTimeout(timeoutMs, () => { | |
| this.pending.delete(id); | |
| rejectPromise(new Error(`${method} timed out after ${timeoutMs} ms.`)); | |
| }); | |
| this.pending.set(id, { | |
| method, | |
| resolve: resolvePromise, | |
| reject: rejectPromise, | |
| timer, | |
| }); | |
| this.socket.send(JSON.stringify(payload)); | |
| }); | |
| } | |
| onEvent(handler) { | |
| this.eventHandlers.add(handler); | |
| return () => this.eventHandlers.delete(handler); | |
| } | |
| waitForEvent(method, predicate = () => true, options = {}) { | |
| const { sessionId, timeoutMs = this.commandTimeoutMs } = options; | |
| return new Promise((resolvePromise, rejectPromise) => { | |
| let remove = () => {}; | |
| const timer = delayTimeout(timeoutMs, () => { | |
| remove(); | |
| rejectPromise(new Error(`CDP event ${method} timed out after ${timeoutMs} ms.`)); | |
| }); | |
| remove = this.onEvent((event) => { | |
| if (event.method !== method) return; | |
| if (sessionId !== undefined && event.sessionId !== sessionId) return; | |
| if (!predicate(event.params || {})) return; | |
| clearTimeout(timer); | |
| remove(); | |
| resolvePromise(event.params || {}); | |
| }); | |
| }); | |
| } | |
| close() { | |
| if (!this.closed) this.socket.close(); | |
| } | |
| } | |
| class TargetMonitor { | |
| constructor(cdp, rootSessionId, rootTargetId, issueSink) { | |
| this.cdp = cdp; | |
| this.rootSessionId = rootSessionId; | |
| this.issueSink = issueSink; | |
| this.records = new Map(); | |
| this.waiters = new Set(); | |
| this.removeListener = null; | |
| this.records.set(rootSessionId, { | |
| sessionId: rootSessionId, | |
| targetId: rootTargetId, | |
| targetInfo: { targetId: rootTargetId, type: 'page', url: 'about:blank' }, | |
| attached: true, | |
| ready: Promise.resolve(), | |
| }); | |
| } | |
| async start() { | |
| this.removeListener = this.cdp.onEvent((event) => this.#handle(event)); | |
| await this.#enableAutoAttach(this.rootSessionId); | |
| } | |
| async #enableAutoAttach(sessionId) { | |
| await this.cdp.send('Target.setAutoAttach', { | |
| autoAttach: true, | |
| // Pausing every target can deadlock a sandboxed viewer nested inside the | |
| // Hugging Face Space iframe before it can emit its shell-ready message. | |
| // Error/log domains still attach early without freezing target scripts. | |
| waitForDebuggerOnStart: false, | |
| flatten: true, | |
| filter: [ | |
| { type: 'iframe', exclude: false }, | |
| { exclude: true }, | |
| ], | |
| }, sessionId); | |
| } | |
| #recordIssue(event, kind, text) { | |
| const record = this.records.get(event.sessionId); | |
| const url = record?.targetInfo?.url || 'unknown target'; | |
| this.issueSink.push(`${kind} [${url}]: ${text}`); | |
| } | |
| #handle(event) { | |
| if (event.method === 'Target.attachedToTarget') { | |
| const params = event.params || {}; | |
| const sessionId = params.sessionId; | |
| if (!sessionId) return; | |
| const record = { | |
| sessionId, | |
| targetId: params.targetInfo?.targetId, | |
| targetInfo: params.targetInfo || {}, | |
| parentSessionId: event.sessionId || null, | |
| waitingForDebugger: Boolean(params.waitingForDebugger), | |
| mainFrameId: null, | |
| attached: true, | |
| ready: null, | |
| }; | |
| record.ready = (async () => { | |
| await Promise.all([ | |
| this.cdp.send('Runtime.enable', {}, sessionId), | |
| this.cdp.send('Log.enable', {}, sessionId), | |
| this.cdp.send('Page.enable', {}, sessionId), | |
| this.#enableAutoAttach(sessionId), | |
| ]); | |
| if (record.waitingForDebugger) { | |
| await this.cdp.send('Runtime.runIfWaitingForDebugger', {}, sessionId); | |
| } | |
| })().catch((error) => { | |
| this.issueSink.push( | |
| `CDP auto-attach failed [${record.targetInfo.url || record.targetId}]: ${error.message}`, | |
| ); | |
| }); | |
| this.records.set(sessionId, record); | |
| this.#notifyWaiters(); | |
| return; | |
| } | |
| if (event.method === 'Target.detachedFromTarget') { | |
| const record = this.records.get(event.params?.sessionId); | |
| if (record) record.attached = false; | |
| this.#notifyWaiters(); | |
| return; | |
| } | |
| if (event.method === 'Target.targetInfoChanged') { | |
| const targetInfo = event.params?.targetInfo; | |
| if (!targetInfo) return; | |
| for (const record of this.records.values()) { | |
| if (record.targetId === targetInfo.targetId) record.targetInfo = targetInfo; | |
| } | |
| this.#notifyWaiters(); | |
| return; | |
| } | |
| if (event.method === 'Page.frameNavigated') { | |
| const record = this.records.get(event.sessionId); | |
| const frame = event.params?.frame; | |
| if ( | |
| record | |
| && frame?.url | |
| && ( | |
| !record.mainFrameId | |
| || frame.id === record.mainFrameId | |
| || frame.id === record.targetId | |
| ) | |
| ) { | |
| record.mainFrameId ||= frame.id; | |
| record.targetInfo = { ...record.targetInfo, url: frame.url }; | |
| this.#notifyWaiters(); | |
| } | |
| return; | |
| } | |
| if (!this.records.has(event.sessionId)) return; | |
| if (event.method === 'Runtime.exceptionThrown') { | |
| const details = event.params?.exceptionDetails || {}; | |
| this.#recordIssue( | |
| event, | |
| 'Uncaught exception', | |
| details.exception?.description || details.text || 'unknown', | |
| ); | |
| } else if (event.method === 'Runtime.consoleAPICalled' && event.params?.type === 'error') { | |
| const text = (event.params.args || []) | |
| .map((arg) => arg.value ?? arg.description ?? arg.type) | |
| .join(' '); | |
| this.#recordIssue(event, 'console.error', text); | |
| } else if (event.method === 'Log.entryAdded' && event.params?.entry?.level === 'error') { | |
| this.#recordIssue(event, 'page log error', event.params.entry.text); | |
| } | |
| } | |
| #notifyWaiters() { | |
| for (const waiter of [...this.waiters]) { | |
| const match = [...this.records.values()].find( | |
| (record) => record.attached && waiter.predicate(record), | |
| ); | |
| if (!match) continue; | |
| clearTimeout(waiter.timer); | |
| this.waiters.delete(waiter); | |
| Promise.resolve(match.ready).then( | |
| () => waiter.resolve(match), | |
| waiter.reject, | |
| ); | |
| } | |
| } | |
| waitForTarget(predicate, timeoutMs = 60_000) { | |
| const existing = [...this.records.values()].find( | |
| (record) => record.attached && predicate(record), | |
| ); | |
| if (existing) return Promise.resolve(existing.ready).then(() => existing); | |
| return new Promise((resolvePromise, rejectPromise) => { | |
| const waiter = { | |
| predicate, | |
| resolve: resolvePromise, | |
| reject: rejectPromise, | |
| timer: null, | |
| }; | |
| waiter.timer = delayTimeout(timeoutMs, () => { | |
| this.waiters.delete(waiter); | |
| rejectPromise(new Error(`No matching iframe/OOPIF target appeared within ${timeoutMs} ms.`)); | |
| }); | |
| this.waiters.add(waiter); | |
| }); | |
| } | |
| attachedTargets() { | |
| return [...this.records.values()] | |
| .filter((record) => record.attached) | |
| .map((record) => ({ | |
| sessionId: record.sessionId, | |
| targetId: record.targetId, | |
| type: record.targetInfo?.type || '', | |
| url: record.targetInfo?.url || '', | |
| parentSessionId: record.parentSessionId || null, | |
| })); | |
| } | |
| close() { | |
| this.removeListener?.(); | |
| for (const waiter of this.waiters) { | |
| clearTimeout(waiter.timer); | |
| waiter.reject(new Error('Target monitor closed.')); | |
| } | |
| this.waiters.clear(); | |
| } | |
| } | |
| async function launchChromium(binary, userDataDir) { | |
| const argumentsList = [ | |
| '--headless=new', | |
| '--no-sandbox', | |
| '--disable-dev-shm-usage', | |
| '--disable-background-networking', | |
| '--disable-component-update', | |
| '--disable-default-apps', | |
| '--disable-features=Translate', | |
| '--no-first-run', | |
| '--remote-debugging-port=0', | |
| '--remote-allow-origins=*', | |
| `--user-data-dir=${userDataDir}`, | |
| '--window-size=1440,1000', | |
| '--use-gl=angle', | |
| '--use-angle=swiftshader', | |
| '--enable-webgl', | |
| '--enable-unsafe-swiftshader', | |
| '--ignore-gpu-blocklist', | |
| 'about:blank', | |
| ]; | |
| const child = spawn(binary, argumentsList, { | |
| stdio: ['ignore', 'ignore', 'pipe'], | |
| }); | |
| const stderrTail = []; | |
| const webSocketUrl = await new Promise((resolvePromise, rejectPromise) => { | |
| let rollingText = ''; | |
| const timer = delayTimeout(30_000, () => { | |
| rejectPromise( | |
| new Error(`Chromium did not expose a DevTools endpoint.\n${stderrTail.join('')}`), | |
| ); | |
| }); | |
| const onExit = (code, signal) => { | |
| clearTimeout(timer); | |
| rejectPromise( | |
| new Error( | |
| `Chromium exited before DevTools was ready (code=${code}, signal=${signal}).\n` | |
| + stderrTail.join(''), | |
| ), | |
| ); | |
| }; | |
| child.once('exit', onExit); | |
| child.stderr.on('data', (chunk) => { | |
| const text = chunk.toString('utf8'); | |
| stderrTail.push(text); | |
| if (stderrTail.length > 40) stderrTail.shift(); | |
| rollingText = `${rollingText}${text}`.slice(-8192); | |
| const match = rollingText.match(/DevTools listening on (ws:\/\/[^\s]+)/); | |
| if (!match) return; | |
| clearTimeout(timer); | |
| child.off('exit', onExit); | |
| resolvePromise(match[1]); | |
| }); | |
| }); | |
| const socket = new WebSocket(webSocketUrl); | |
| await new Promise((resolvePromise, rejectPromise) => { | |
| const timer = delayTimeout(15_000, () => rejectPromise(new Error('DevTools WebSocket timed out.'))); | |
| socket.addEventListener('open', () => { | |
| clearTimeout(timer); | |
| resolvePromise(); | |
| }, { once: true }); | |
| socket.addEventListener('error', () => { | |
| clearTimeout(timer); | |
| rejectPromise(new Error('DevTools WebSocket could not be opened.')); | |
| }, { once: true }); | |
| }); | |
| return { child, cdp: new CdpConnection(socket), stderrTail }; | |
| } | |
| async function stopChromium(child) { | |
| if (!child || child.exitCode !== null || child.signalCode !== null) return; | |
| const exited = new Promise((resolvePromise) => child.once('exit', resolvePromise)); | |
| child.kill('SIGTERM'); | |
| const forceTimer = delayTimeout(5000, () => child.kill('SIGKILL')); | |
| await exited; | |
| clearTimeout(forceTimer); | |
| } | |
| class BrowserPage { | |
| constructor(cdp, sessionId) { | |
| this.cdp = cdp; | |
| this.sessionId = sessionId; | |
| } | |
| send(method, params = {}, timeoutMs = DEFAULT_ACTION_TIMEOUT_MS) { | |
| return this.cdp.send(method, params, this.sessionId, timeoutMs); | |
| } | |
| async evaluate(expression, options = {}) { | |
| const { | |
| awaitPromise = false, | |
| timeoutMs = DEFAULT_ACTION_TIMEOUT_MS, | |
| userGesture = false, | |
| } = options; | |
| const response = await this.send('Runtime.evaluate', { | |
| expression, | |
| awaitPromise, | |
| returnByValue: true, | |
| userGesture, | |
| }, timeoutMs); | |
| if (response.exceptionDetails) { | |
| const exception = response.exceptionDetails.exception; | |
| const description = exception?.description | |
| || exception?.value | |
| || response.exceptionDetails.text | |
| || 'JavaScript evaluation failed'; | |
| throw new Error(String(description)); | |
| } | |
| return response.result?.value; | |
| } | |
| waitForCondition(predicateSource, description, timeoutMs = DEFAULT_ACTION_TIMEOUT_MS) { | |
| const expression = `new Promise((resolve, reject) => { | |
| const description = ${JSON.stringify(description)}; | |
| let observer = null; | |
| let timer = null; | |
| const cleanup = () => { | |
| if (observer) observer.disconnect(); | |
| if (timer) clearTimeout(timer); | |
| window.removeEventListener('popstate', check); | |
| window.removeEventListener('hashchange', check); | |
| }; | |
| const check = () => { | |
| try { | |
| const value = (${predicateSource})(); | |
| if (value) { | |
| cleanup(); | |
| resolve(value); | |
| } | |
| } catch (error) { | |
| cleanup(); | |
| reject(error); | |
| } | |
| }; | |
| observer = new MutationObserver(check); | |
| observer.observe(document.documentElement, { | |
| subtree: true, | |
| childList: true, | |
| characterData: true, | |
| attributes: true | |
| }); | |
| window.addEventListener('popstate', check); | |
| window.addEventListener('hashchange', check); | |
| timer = setTimeout(() => { | |
| cleanup(); | |
| reject(new Error("Timed out waiting for " + description)); | |
| }, ${timeoutMs}); | |
| check(); | |
| })`; | |
| return this.evaluate(expression, { | |
| awaitPromise: true, | |
| timeoutMs: timeoutMs + 5000, | |
| }); | |
| } | |
| clickAndWait(selector, predicateSource, description, timeoutMs = DEFAULT_ACTION_TIMEOUT_MS) { | |
| const expression = `new Promise((resolve, reject) => { | |
| const selector = ${JSON.stringify(selector)}; | |
| const description = ${JSON.stringify(description)}; | |
| const target = document.querySelector(selector); | |
| if (!target) { | |
| reject(new Error("Missing click target " + selector)); | |
| return; | |
| } | |
| let observer = null; | |
| let timer = null; | |
| const cleanup = () => { | |
| if (observer) observer.disconnect(); | |
| if (timer) clearTimeout(timer); | |
| window.removeEventListener('popstate', check); | |
| window.removeEventListener('hashchange', check); | |
| }; | |
| const check = () => { | |
| try { | |
| const value = (${predicateSource})(); | |
| if (value) { | |
| cleanup(); | |
| resolve(value); | |
| } | |
| } catch (error) { | |
| cleanup(); | |
| reject(error); | |
| } | |
| }; | |
| observer = new MutationObserver(check); | |
| observer.observe(document.documentElement, { | |
| subtree: true, | |
| childList: true, | |
| characterData: true, | |
| attributes: true | |
| }); | |
| window.addEventListener('popstate', check); | |
| window.addEventListener('hashchange', check); | |
| timer = setTimeout(() => { | |
| cleanup(); | |
| reject(new Error("Timed out waiting for " + description)); | |
| }, ${timeoutMs}); | |
| target.click(); | |
| check(); | |
| })`; | |
| return this.evaluate(expression, { | |
| awaitPromise: true, | |
| timeoutMs: timeoutMs + 5000, | |
| userGesture: true, | |
| }); | |
| } | |
| async navigate(url, timeoutMs = DEFAULT_ACTION_TIMEOUT_MS) { | |
| const loaded = this.cdp.waitForEvent('Page.loadEventFired', () => true, { | |
| sessionId: this.sessionId, | |
| timeoutMs, | |
| }); | |
| const result = await this.send('Page.navigate', { url }, timeoutMs); | |
| if (result.errorText) throw new Error(`Navigation failed: ${result.errorText}`); | |
| await loaded; | |
| } | |
| async settleFrames() { | |
| await this.evaluate( | |
| 'new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))', | |
| { awaitPromise: true }, | |
| ); | |
| } | |
| async setDesktopViewport() { | |
| await this.send('Emulation.setDeviceMetricsOverride', { | |
| width: 1440, | |
| height: 1000, | |
| screenWidth: 1440, | |
| screenHeight: 1000, | |
| deviceScaleFactor: 1, | |
| mobile: false, | |
| }); | |
| await this.send('Emulation.setTouchEmulationEnabled', { enabled: false }); | |
| await this.settleFrames(); | |
| } | |
| async setMobileViewport() { | |
| await this.send('Emulation.setDeviceMetricsOverride', { | |
| width: 390, | |
| height: 844, | |
| screenWidth: 390, | |
| screenHeight: 844, | |
| deviceScaleFactor: 2, | |
| mobile: true, | |
| }); | |
| await this.send('Emulation.setTouchEmulationEnabled', { | |
| enabled: true, | |
| maxTouchPoints: 5, | |
| }); | |
| await this.settleFrames(); | |
| } | |
| async screenshot(filePath, selector = null) { | |
| if (selector) { | |
| await this.evaluate( | |
| `document.querySelector(${JSON.stringify(selector)})?.scrollIntoView({block: "center", inline: "center"})`, | |
| ); | |
| await this.settleFrames(); | |
| } | |
| const result = await this.send('Page.captureScreenshot', { | |
| format: 'png', | |
| fromSurface: true, | |
| captureBeyondViewport: false, | |
| }, 60_000); | |
| const bytes = Buffer.from(result.data, 'base64'); | |
| assert(bytes.subarray(0, 8).equals(PNG_SIGNATURE), `invalid PNG screenshot: ${filePath}`); | |
| await writeFile(filePath, bytes); | |
| return { path: filePath, bytes: bytes.length, sha256: sha256(bytes) }; | |
| } | |
| async clippedScreenshot(selector) { | |
| const rect = await this.evaluate(`(() => { | |
| const element = document.querySelector(${JSON.stringify(selector)}); | |
| if (!element) throw new Error("Missing screenshot target"); | |
| element.scrollIntoView({block: "center", inline: "center"}); | |
| const bounds = element.getBoundingClientRect(); | |
| return { | |
| x: bounds.left + window.scrollX, | |
| y: bounds.top + window.scrollY, | |
| width: bounds.width, | |
| height: bounds.height | |
| }; | |
| })()`); | |
| await this.settleFrames(); | |
| assert(rect.width > 10 && rect.height > 10, `${selector} has no visible screenshot area`); | |
| const result = await this.send('Page.captureScreenshot', { | |
| format: 'png', | |
| fromSurface: true, | |
| captureBeyondViewport: true, | |
| clip: { | |
| x: Math.max(0, rect.x), | |
| y: Math.max(0, rect.y), | |
| width: rect.width, | |
| height: rect.height, | |
| scale: 1, | |
| }, | |
| }, 60_000); | |
| return Buffer.from(result.data, 'base64'); | |
| } | |
| async elementCenter(selector) { | |
| return this.evaluate(`(() => { | |
| const element = document.querySelector(${JSON.stringify(selector)}); | |
| if (!element) throw new Error("Missing interaction target"); | |
| element.scrollIntoView({block: "center", inline: "center"}); | |
| const bounds = element.getBoundingClientRect(); | |
| if (bounds.width < 10 || bounds.height < 10) throw new Error("Interaction target is not visible"); | |
| return { | |
| x: bounds.left + bounds.width / 2, | |
| y: bounds.top + bounds.height / 2, | |
| width: bounds.width, | |
| height: bounds.height | |
| }; | |
| })()`); | |
| } | |
| } | |
| class DownloadMonitor { | |
| constructor(cdp, downloadDirectory, timeoutMs = 60_000) { | |
| this.cdp = cdp; | |
| this.downloadDirectory = downloadDirectory; | |
| this.timeoutMs = timeoutMs; | |
| this.waiting = []; | |
| this.records = new Map(); | |
| this.removeListener = cdp.onEvent((event) => this.#handle(event)); | |
| } | |
| #handle(event) { | |
| if (event.method === 'Browser.downloadWillBegin') { | |
| const waiter = this.waiting.shift(); | |
| if (!waiter) return; | |
| const params = event.params || {}; | |
| const record = { | |
| label: waiter.label, | |
| guid: params.guid, | |
| suggestedFilename: params.suggestedFilename, | |
| url: params.url, | |
| done: null, | |
| completionTimer: null, | |
| resolveDone: null, | |
| rejectDone: null, | |
| }; | |
| record.done = new Promise((resolvePromise, rejectPromise) => { | |
| record.resolveDone = resolvePromise; | |
| record.rejectDone = rejectPromise; | |
| }); | |
| record.completionTimer = delayTimeout(this.timeoutMs, () => { | |
| record.rejectDone(new Error(`${record.label} did not finish downloading.`)); | |
| }); | |
| this.records.set(record.guid, record); | |
| clearTimeout(waiter.timer); | |
| waiter.resolve(record); | |
| return; | |
| } | |
| if (event.method !== 'Browser.downloadProgress') return; | |
| const params = event.params || {}; | |
| const record = this.records.get(params.guid); | |
| if (!record) return; | |
| if (params.state === 'completed') { | |
| clearTimeout(record.completionTimer); | |
| record.receivedBytes = params.receivedBytes; | |
| record.totalBytes = params.totalBytes; | |
| record.filePath = params.filePath || join(this.downloadDirectory, record.guid); | |
| record.resolveDone(record); | |
| } else if (params.state === 'canceled') { | |
| clearTimeout(record.completionTimer); | |
| record.rejectDone(new Error(`${record.label} download was canceled.`)); | |
| } | |
| } | |
| expect(label) { | |
| return new Promise((resolvePromise, rejectPromise) => { | |
| const waiter = { | |
| label, | |
| resolve: resolvePromise, | |
| reject: rejectPromise, | |
| timer: null, | |
| }; | |
| waiter.timer = delayTimeout(this.timeoutMs, () => { | |
| const position = this.waiting.indexOf(waiter); | |
| if (position !== -1) this.waiting.splice(position, 1); | |
| rejectPromise(new Error(`${label} did not start downloading.`)); | |
| }); | |
| this.waiting.push(waiter); | |
| }); | |
| } | |
| close() { | |
| this.removeListener(); | |
| for (const waiter of this.waiting) { | |
| clearTimeout(waiter.timer); | |
| waiter.reject(new Error('Download monitor closed.')); | |
| } | |
| this.waiting.length = 0; | |
| for (const record of this.records.values()) clearTimeout(record.completionTimer); | |
| } | |
| } | |
| function sha256(bytes) { | |
| return createHash('sha256').update(bytes).digest('hex'); | |
| } | |
| function pageConditionVisible(selector) { | |
| return `() => { | |
| const element = document.querySelector(${JSON.stringify(selector)}); | |
| return element && !element.hidden && element.getClientRects().length > 0; | |
| }`; | |
| } | |
| async function installAuditRecorder(page, onUpdate) { | |
| const bindingName = '__i2tAuditEmit'; | |
| await page.send('Runtime.addBinding', { name: bindingName }); | |
| const removeListener = page.cdp.onEvent((event) => { | |
| if ( | |
| event.sessionId !== page.sessionId | |
| || event.method !== 'Runtime.bindingCalled' | |
| || event.params?.name !== bindingName | |
| ) return; | |
| try { | |
| onUpdate(JSON.parse(event.params.payload)); | |
| } catch { | |
| // The in-page recorder is diagnostic; malformed diagnostics never alter the app. | |
| } | |
| }); | |
| await page.evaluate(`(() => { | |
| window.__i2tAuditJobResponse = null; | |
| window.__i2tAuditProgress = []; | |
| const originalFetch = window.fetch.bind(window); | |
| window.fetch = async (...args) => { | |
| const response = await originalFetch(...args); | |
| try { | |
| const request = args[0]; | |
| const init = args[1] || {}; | |
| const url = new URL(typeof request === "string" ? request : request.url, location.href); | |
| const method = String(init.method || (typeof request !== "string" && request.method) || "GET").toUpperCase(); | |
| if (method === "POST" && url.pathname === "/api/jobs") { | |
| response.clone().json().then((value) => { | |
| window.__i2tAuditJobResponse = value; | |
| if (value?.job_id) { | |
| window.${bindingName}(JSON.stringify({ | |
| type: "job-accepted", | |
| jobId: value.job_id | |
| })); | |
| } | |
| }).catch(() => {}); | |
| } | |
| } catch { | |
| // The app still receives the untouched response. | |
| } | |
| return response; | |
| }; | |
| let previousKey = ""; | |
| const snapshot = () => { | |
| const panel = document.querySelector("#panel-progress"); | |
| if (!panel || panel.hidden) return; | |
| const stages = [...document.querySelectorAll("#stages .stage")].map((stage) => ({ | |
| stage: stage.dataset.stage, | |
| state: [...stage.classList].find((name) => name.startsWith("stage-") && name !== "stage") || "", | |
| note: stage.querySelector(".stage-note")?.textContent?.trim() || "" | |
| })); | |
| const logLines = (document.querySelector("#log")?.textContent || "").trim().split("\\n").filter(Boolean); | |
| const value = { | |
| at: Date.now(), | |
| elapsed: document.querySelector("#progress-elapsed")?.textContent?.trim() || "", | |
| activity: document.querySelector("#progress-activity")?.textContent?.trim() || "", | |
| activityTime: document.querySelector("#progress-activity-time")?.textContent?.trim() || "", | |
| attempt: document.querySelector("#progress-attempt")?.textContent?.trim() || "", | |
| connection: document.querySelector("#progress-connection")?.textContent?.trim() || "", | |
| stages, | |
| lastLogLine: logLines.at(-1) || "", | |
| logLineCount: logLines.length | |
| }; | |
| const key = JSON.stringify({ | |
| activity: value.activity, | |
| attempt: value.attempt, | |
| connection: value.connection, | |
| stages: value.stages, | |
| lastLogLine: value.lastLogLine | |
| }); | |
| if (key !== previousKey) { | |
| previousKey = key; | |
| window.__i2tAuditProgress.push(value); | |
| if (value.lastLogLine) { | |
| const active = [...value.stages].reverse().find((stage) => | |
| stage.state === "stage-active" || stage.state === "stage-done" | |
| ); | |
| window.${bindingName}(JSON.stringify({ | |
| type: "progress", | |
| elapsed: value.elapsed, | |
| stage: active?.stage || "pipeline", | |
| activity: value.activity, | |
| attempt: value.attempt, | |
| lastLogLine: value.lastLogLine | |
| })); | |
| } | |
| } | |
| }; | |
| window.__i2tAuditProgressObserver?.disconnect(); | |
| window.__i2tAuditProgressObserver = new MutationObserver(snapshot); | |
| window.__i2tAuditProgressObserver.observe(document.body, { | |
| subtree: true, | |
| childList: true, | |
| characterData: true, | |
| attributes: true | |
| }); | |
| snapshot(); | |
| })()`); | |
| return removeListener; | |
| } | |
| async function auditExternalLinks(page) { | |
| const result = await page.evaluate(`(() => { | |
| const links = [...document.querySelectorAll("a[href]")].map((link) => { | |
| const url = new URL(link.href, location.href); | |
| return { | |
| text: link.textContent.trim().replace(/\\s+/g, " "), | |
| href: url.href, | |
| external: url.origin !== location.origin, | |
| target: link.getAttribute("target") || "", | |
| rel: (link.getAttribute("rel") || "").split(/\\s+/).filter(Boolean) | |
| }; | |
| }); | |
| const external = links.filter((link) => link.external); | |
| return { | |
| count: external.length, | |
| links: external, | |
| violations: external.filter((link) => | |
| link.target !== "_blank" | |
| || !link.rel.includes("noopener") | |
| || !link.rel.includes("noreferrer") | |
| ) | |
| }; | |
| })()`); | |
| assert(result.count > 0, 'the page should expose at least one external project link'); | |
| assert( | |
| result.violations.length === 0, | |
| `external-link safety violations: ${JSON.stringify(result.violations)}`, | |
| ); | |
| return result; | |
| } | |
| async function selectFile(page, absoluteImagePath) { | |
| const { root } = await page.send('DOM.getDocument', { depth: 2, pierce: true }); | |
| const { nodeId } = await page.send('DOM.querySelector', { | |
| nodeId: root.nodeId, | |
| selector: '#file-input', | |
| }); | |
| assert(nodeId, 'missing #file-input'); | |
| await page.send('DOM.setFileInputFiles', { | |
| nodeId, | |
| files: [absoluteImagePath], | |
| }); | |
| await page.waitForCondition( | |
| `() => { | |
| const preview = document.querySelector("#preview-row"); | |
| const run = document.querySelector("#run-btn"); | |
| return preview && !preview.hidden && run && !run.disabled | |
| ? {name: document.querySelector("#preview-name")?.textContent || ""} | |
| : false; | |
| }`, | |
| 'the selected image preview', | |
| ); | |
| } | |
| async function inspectProgress(page) { | |
| const progress = await page.evaluate('window.__i2tAuditProgress || []'); | |
| assert(progress.length >= 3, `expected several server-backed progress updates, got ${progress.length}`); | |
| assert( | |
| progress.every((entry) => /^\d+:\d{2}(?::\d{2})?$/.test(entry.elapsed)), | |
| 'each recorded progress update must show a clock-style elapsed time', | |
| ); | |
| const logLines = Math.max(...progress.map((entry) => Number(entry.logLineCount) || 0)); | |
| assert(logLines > 0, 'the live server event log should contain at least one event'); | |
| const activities = [...new Set(progress.map((entry) => entry.activity).filter(Boolean))]; | |
| assert(activities.length >= 3, 'progress should contain multiple distinct server activities'); | |
| const allText = JSON.stringify(progress); | |
| assert(!/\b\d{1,3}\s*%/.test(allText), 'progress UI must not invent percentage completion'); | |
| const fakeProgress = await page.evaluate( | |
| 'document.querySelectorAll("progress, meter, [role=progressbar], [aria-valuenow]").length', | |
| ); | |
| assert(fakeProgress === 0, 'progress UI should not expose a fabricated numeric progress bar'); | |
| return { | |
| updates: progress.length, | |
| activities, | |
| finalElapsed: progress.at(-1)?.elapsed || '', | |
| maxLogLines: logLines, | |
| observedStages: [...new Set( | |
| progress.flatMap((entry) => entry.stages || []) | |
| .filter((stage) => stage.state === 'stage-active' || stage.state === 'stage-done') | |
| .map((stage) => stage.stage), | |
| )], | |
| }; | |
| } | |
| async function waitForViewer(page, prefix, timeoutMs) { | |
| const root = prefix ? `#${prefix}-viewer-wrap` : '#viewer-frame-wrap'; | |
| const wireframe = prefix ? `#${prefix}-viewer-wireframe` : '#viewer-wireframe'; | |
| const shadows = prefix ? `#${prefix}-viewer-shadows` : '#viewer-shadows'; | |
| const reset = prefix ? `#${prefix}-viewer-reset-camera` : '#viewer-reset-camera'; | |
| const overlay = prefix ? `#${prefix}-viewer-overlay` : '#viewer-overlay'; | |
| const stats = prefix ? `#${prefix}-viewer-stats` : '#viewer-stats'; | |
| return page.waitForCondition( | |
| `() => { | |
| const root = document.querySelector(${JSON.stringify(root)}); | |
| const overlay = document.querySelector(${JSON.stringify(overlay)}); | |
| const wireframe = document.querySelector(${JSON.stringify(wireframe)}); | |
| const shadows = document.querySelector(${JSON.stringify(shadows)}); | |
| const reset = document.querySelector(${JSON.stringify(reset)}); | |
| const stats = document.querySelector(${JSON.stringify(stats)}); | |
| return root?.getAttribute("aria-busy") === "false" | |
| && overlay?.hidden | |
| && wireframe && !wireframe.disabled | |
| && shadows && !shadows.disabled | |
| && reset && !reset.disabled | |
| ? { | |
| stats: stats?.textContent?.trim() || "", | |
| wireframe: wireframe.getAttribute("aria-pressed"), | |
| shadows: shadows.getAttribute("aria-pressed") | |
| } | |
| : false; | |
| }`, | |
| `${prefix || 'result'} viewer readiness`, | |
| timeoutMs, | |
| ); | |
| } | |
| async function toggleViewerControl(page, buttonSelector, statusSelector, label) { | |
| const before = await page.evaluate( | |
| `document.querySelector(${JSON.stringify(buttonSelector)})?.getAttribute("aria-pressed")`, | |
| ); | |
| assert(before === 'true' || before === 'false', `${label} must expose aria-pressed`); | |
| const expected = before === 'true' ? 'false' : 'true'; | |
| const result = await page.clickAndWait( | |
| buttonSelector, | |
| `() => { | |
| const button = document.querySelector(${JSON.stringify(buttonSelector)}); | |
| const status = document.querySelector(${JSON.stringify(statusSelector)}); | |
| return button?.getAttribute("aria-pressed") === ${JSON.stringify(expected)} | |
| && !button.disabled | |
| && status?.textContent?.trim() | |
| ? {pressed: button.getAttribute("aria-pressed"), status: status.textContent.trim()} | |
| : false; | |
| }`, | |
| `${label} acknowledgement`, | |
| ); | |
| assert(!/failed|error|not ready|timed out/i.test(result.status), `${label} failed: ${result.status}`); | |
| return result; | |
| } | |
| async function resetViewer(page, buttonSelector, statusSelector, label) { | |
| const result = await page.clickAndWait( | |
| buttonSelector, | |
| `() => { | |
| const button = document.querySelector(${JSON.stringify(buttonSelector)}); | |
| const status = document.querySelector(${JSON.stringify(statusSelector)})?.textContent?.trim() || ""; | |
| return !button?.disabled && status === "View reset" ? {status} : false; | |
| }`, | |
| `${label} camera reset acknowledgement`, | |
| ); | |
| return result.status; | |
| } | |
| async function drag(page, point, button, buttons, deltaX, deltaY) { | |
| await page.send('Input.dispatchMouseEvent', { | |
| type: 'mousePressed', | |
| x: point.x, | |
| y: point.y, | |
| button, | |
| buttons, | |
| clickCount: 1, | |
| pointerType: 'mouse', | |
| }); | |
| for (let step = 1; step <= 6; step += 1) { | |
| await page.send('Input.dispatchMouseEvent', { | |
| type: 'mouseMoved', | |
| x: point.x + deltaX * (step / 6), | |
| y: point.y + deltaY * (step / 6), | |
| button: 'none', | |
| buttons, | |
| pointerType: 'mouse', | |
| }); | |
| } | |
| await page.send('Input.dispatchMouseEvent', { | |
| type: 'mouseReleased', | |
| x: point.x + deltaX, | |
| y: point.y + deltaY, | |
| button, | |
| buttons: 0, | |
| clickCount: 1, | |
| pointerType: 'mouse', | |
| }); | |
| await page.settleFrames(); | |
| } | |
| async function exerciseViewerGestures(page, frameWrapSelector) { | |
| const hashes = []; | |
| const captureHash = async () => { | |
| const bytes = await page.clippedScreenshot(frameWrapSelector); | |
| const digest = sha256(bytes); | |
| hashes.push(digest); | |
| return digest; | |
| }; | |
| await captureHash(); | |
| let point = await page.elementCenter(frameWrapSelector); | |
| await drag(page, point, 'left', 1, Math.min(110, point.width * 0.2), Math.min(55, point.height * 0.15)); | |
| await captureHash(); | |
| point = await page.elementCenter(frameWrapSelector); | |
| await page.send('Input.dispatchMouseEvent', { | |
| type: 'mouseWheel', | |
| x: point.x, | |
| y: point.y, | |
| deltaX: 0, | |
| deltaY: -260, | |
| pointerType: 'mouse', | |
| }); | |
| await page.settleFrames(); | |
| await captureHash(); | |
| point = await page.elementCenter(frameWrapSelector); | |
| await drag(page, point, 'right', 2, Math.min(70, point.width * 0.14), -Math.min(45, point.height * 0.12)); | |
| await captureHash(); | |
| const changed = { | |
| orbit: hashes[1] !== hashes[0], | |
| zoom: hashes[2] !== hashes[1], | |
| pan: hashes[3] !== hashes[2], | |
| }; | |
| for (const [gesture, visible] of Object.entries(changed)) { | |
| assert(visible, `${gesture} input did not produce its own visible viewer change`); | |
| } | |
| return { ...changed, visibleTransitions: 3, hashes }; | |
| } | |
| function validateArtifactBytes(name, bytes) { | |
| assert(Buffer.isBuffer(bytes) && bytes.length > 0, `${name} is empty`); | |
| const text = [ | |
| 'factory.ts', | |
| 'spec.json', | |
| 'model.bundle.js', | |
| 'standalone.html', | |
| ].includes(name) ? bytes.toString('utf8') : null; | |
| if (name === 'reference.png' || name === 'screenshot.png') { | |
| assert(bytes.subarray(0, 8).equals(PNG_SIGNATURE), `${name} is not a PNG`); | |
| } else if (name === 'factory.ts') { | |
| assert( | |
| /THREE|three|create[A-Za-z0-9_]*Model/.test(text), | |
| 'factory.ts does not look like generated TypeScript', | |
| ); | |
| } else if (name === 'spec.json') { | |
| const parsed = JSON.parse(text); | |
| assert(parsed && typeof parsed === 'object', 'spec.json must contain a JSON object'); | |
| } else if (name === 'model.bundle.js') { | |
| assert(bytes.length > 100 && /THREE|createScene|createModel/.test(text), 'model.bundle.js looks invalid'); | |
| } else if (name === 'standalone.html') { | |
| assert(/<!doctype html/i.test(text) && /<script/i.test(text), 'standalone.html looks invalid'); | |
| } else { | |
| throw new Error(`No artifact validator exists for ${name}.`); | |
| } | |
| } | |
| async function inspectDownloadedFile(record, label, artifactName) { | |
| const filePath = record.filePath; | |
| const fileStat = await stat(filePath); | |
| assert(fileStat.isFile() && fileStat.size > 0, `${label} download is empty`); | |
| const bytes = await readFile(filePath); | |
| validateArtifactBytes(artifactName, bytes); | |
| return { | |
| report: { | |
| label, | |
| artifactName, | |
| suggestedFilename: record.suggestedFilename, | |
| path: filePath, | |
| bytes: fileStat.size, | |
| sha256: sha256(bytes), | |
| }, | |
| bytes, | |
| }; | |
| } | |
| async function downloadResultArtifacts(page, monitor) { | |
| const actions = [ | |
| ['factory', 'factory.ts', '#dl-ts'], | |
| ['spec', 'spec.json', '#dl-spec'], | |
| ['bundle', 'model.bundle.js', '#dl-bundle'], | |
| ['standalone', 'standalone.html', '#dl-standalone'], | |
| ['screenshot', 'screenshot.png', '#dl-shot'], | |
| ]; | |
| const readiness = await page.evaluate(`(() => { | |
| const selectors = ${JSON.stringify(actions.map(([, , selector]) => selector))}; | |
| return selectors.map((selector) => { | |
| const element = document.querySelector(selector); | |
| return { | |
| selector, | |
| href: element?.href || "", | |
| disabled: Boolean(element?.disabled), | |
| ariaDisabled: element?.getAttribute("aria-disabled") || "" | |
| }; | |
| }); | |
| })()`); | |
| for (const action of readiness) { | |
| assert(action.selector === '#dl-shot' ? !action.disabled : Boolean(action.href), `${action.selector} is not ready`); | |
| assert(action.ariaDisabled !== 'true', `${action.selector} is marked disabled`); | |
| } | |
| const downloaded = []; | |
| const bytesByArtifact = {}; | |
| for (const [label, artifactName, selector] of actions) { | |
| const start = monitor.expect(label); | |
| await page.evaluate( | |
| `document.querySelector(${JSON.stringify(selector)}).click()`, | |
| { userGesture: true }, | |
| ); | |
| const record = await start; | |
| await record.done; | |
| const inspected = await inspectDownloadedFile(record, label, artifactName); | |
| downloaded.push(inspected.report); | |
| bytesByArtifact[artifactName] = inspected.bytes; | |
| } | |
| const shotStatus = await page.evaluate('document.querySelector("#shot-status")?.textContent?.trim()'); | |
| assert(shotStatus === 'Screenshot saved', `screenshot UI did not confirm success: ${shotStatus}`); | |
| return { report: downloaded, bytesByArtifact }; | |
| } | |
| async function fetchAndValidateGalleryArtifacts( | |
| item, | |
| appRoot, | |
| { expectedResultBytes = {}, immutableBaseline = {} } = {}, | |
| ) { | |
| const names = [ | |
| 'reference.png', | |
| 'factory.ts', | |
| 'spec.json', | |
| 'model.bundle.js', | |
| 'standalone.html', | |
| ]; | |
| const appOrigin = new URL(appRoot).origin; | |
| const bytesByArtifact = {}; | |
| const artifactReport = []; | |
| for (const name of names) { | |
| const supplied = item.artifacts?.[name]; | |
| assert(supplied, `gallery item is missing ${name}`); | |
| const url = new URL(supplied, appRoot); | |
| assert(url.origin === appOrigin, `${name} gallery URL left the deployed Space origin`); | |
| assert( | |
| url.pathname === `/api/gallery/${item.id}/artifacts/${encodeURIComponent(name)}`, | |
| `${name} does not use its immutable gallery artifact route`, | |
| ); | |
| const response = await fetch(url, { | |
| headers: { Accept: '*/*' }, | |
| signal: AbortSignal.timeout(60_000), | |
| }); | |
| assert(response.ok, `${name} gallery fetch failed with HTTP ${response.status}`); | |
| const bytes = Buffer.from(await response.arrayBuffer()); | |
| validateArtifactBytes(name, bytes); | |
| bytesByArtifact[name] = bytes; | |
| const expected = expectedResultBytes[name]; | |
| if (expected) { | |
| assert( | |
| bytes.equals(expected), | |
| `${name} gallery bytes differ from the result-screen download`, | |
| ); | |
| } | |
| const baseline = immutableBaseline[name]; | |
| if (baseline) { | |
| assert( | |
| bytes.equals(baseline), | |
| `${name} changed between gallery fetches`, | |
| ); | |
| } | |
| artifactReport.push({ | |
| name, | |
| url: url.href, | |
| contentType: response.headers.get('content-type') || '', | |
| bytes: bytes.length, | |
| sha256: sha256(bytes), | |
| matchesResultDownload: expected ? true : null, | |
| matchesImmutableBaseline: baseline ? true : null, | |
| }); | |
| } | |
| return { report: artifactReport, bytesByArtifact }; | |
| } | |
| async function auditGalleryDetail( | |
| page, | |
| galleryId, | |
| viewerTimeoutMs, | |
| { downloadMonitor = null, exerciseControls = true, exerciseScreenshot = false } = {}, | |
| ) { | |
| assert(GALLERY_ID_PATTERN.test(galleryId), `invalid gallery ID in route: ${galleryId}`); | |
| const item = await page.evaluate(`fetch( | |
| "/api/gallery/${galleryId}" | |
| ).then(async (response) => { | |
| const payload = await response.json(); | |
| if (!response.ok) throw new Error(payload.detail || "Gallery API failed"); | |
| return payload; | |
| })`, { awaitPromise: true }); | |
| assert(item.id === galleryId, 'gallery API returned a different item ID'); | |
| for (const name of ['reference.png', 'factory.ts', 'spec.json', 'model.bundle.js', 'standalone.html']) { | |
| assert(item.artifacts?.[name], `gallery item is missing ${name}`); | |
| } | |
| const detail = await page.waitForCondition( | |
| `() => { | |
| const dialog = document.querySelector("#gallery-dialog"); | |
| const content = document.querySelector("#gallery-detail-content"); | |
| const error = document.querySelector("#gallery-detail-error"); | |
| if (error && !error.hidden) throw new Error( | |
| document.querySelector("#gallery-detail-error-message")?.textContent || "Gallery detail failed" | |
| ); | |
| return dialog?.open && content && !content.hidden | |
| ? { | |
| title: document.querySelector("#gallery-detail-title")?.textContent?.trim() || "", | |
| meta: document.querySelector("#gallery-detail-meta")?.textContent?.trim() || "" | |
| } | |
| : false; | |
| }`, | |
| 'gallery detail content', | |
| viewerTimeoutMs, | |
| ); | |
| assert(detail.title, 'gallery detail title is empty'); | |
| const viewer = await waitForViewer(page, 'gallery', viewerTimeoutMs); | |
| assert(viewer.stats && !/unavailable/i.test(viewer.stats), `gallery viewer stats are invalid: ${viewer.stats}`); | |
| const links = await page.evaluate(`(() => { | |
| const ids = [ | |
| "gallery-dl-reference", | |
| "gallery-dl-ts", | |
| "gallery-dl-spec", | |
| "gallery-dl-bundle", | |
| "gallery-dl-standalone" | |
| ]; | |
| return ids.map((id) => { | |
| const element = document.getElementById(id); | |
| return {id, href: element?.href || "", ariaDisabled: element?.getAttribute("aria-disabled") || ""}; | |
| }); | |
| })()`); | |
| assert(links.every((link) => link.href && link.ariaDisabled !== 'true'), 'gallery artifact links are not all enabled'); | |
| let controls = { verifiedEnabled: true }; | |
| if (exerciseControls) { | |
| controls = { | |
| wireframe: await toggleViewerControl( | |
| page, | |
| '#gallery-viewer-wireframe', | |
| '#gallery-viewer-control-status', | |
| 'gallery wireframe', | |
| ), | |
| shadows: await toggleViewerControl( | |
| page, | |
| '#gallery-viewer-shadows', | |
| '#gallery-viewer-control-status', | |
| 'gallery shadows', | |
| ), | |
| reset: await resetViewer( | |
| page, | |
| '#gallery-viewer-reset-camera', | |
| '#gallery-viewer-control-status', | |
| 'gallery', | |
| ), | |
| }; | |
| } | |
| let screenshot = null; | |
| if (exerciseScreenshot) { | |
| assert(downloadMonitor, 'gallery screenshot exercise requires a download monitor'); | |
| const start = downloadMonitor.expect('gallery screenshot'); | |
| await page.evaluate('document.querySelector("#gallery-dl-shot").click()', { | |
| userGesture: true, | |
| }); | |
| const record = await start; | |
| await record.done; | |
| const inspected = await inspectDownloadedFile(record, 'gallery screenshot', 'screenshot.png'); | |
| const status = await page.evaluate( | |
| 'document.querySelector("#gallery-shot-status")?.textContent?.trim()', | |
| ); | |
| assert(status === 'Screenshot saved', `gallery screenshot UI did not confirm success: ${status}`); | |
| screenshot = inspected.report; | |
| } | |
| return { | |
| report: { | |
| id: galleryId, | |
| title: detail.title, | |
| meta: detail.meta, | |
| viewer, | |
| controls, | |
| screenshot, | |
| artifactLinks: links.length, | |
| }, | |
| item, | |
| }; | |
| } | |
| async function closeGalleryDialog(page) { | |
| return page.clickAndWait( | |
| '#gallery-detail-close', | |
| `() => { | |
| const dialog = document.querySelector("#gallery-dialog"); | |
| return !dialog?.open && location.pathname === "/gallery" | |
| ? {path: location.pathname} | |
| : false; | |
| }`, | |
| 'gallery dialog close and gallery route restoration', | |
| ); | |
| } | |
| async function waitForGalleryIndex(page, galleryId, timeoutMs = DEFAULT_ACTION_TIMEOUT_MS) { | |
| const expectedPath = `/gallery/${galleryId}`; | |
| return page.waitForCondition( | |
| `() => { | |
| const panel = document.querySelector("#panel-gallery"); | |
| const error = document.querySelector("#gallery-error"); | |
| if (error && !error.hidden) { | |
| throw new Error( | |
| document.querySelector("#gallery-error-message")?.textContent | |
| || "Gallery index failed" | |
| ); | |
| } | |
| const card = [...document.querySelectorAll("#gallery-grid .gallery-card-button")] | |
| .find((candidate) => candidate.getAttribute("href") === ${JSON.stringify(expectedPath)}); | |
| const loading = document.querySelector("#gallery-loading"); | |
| return panel && !panel.hidden && card && loading?.hidden | |
| ? { | |
| path: location.pathname, | |
| cardCount: document.querySelectorAll("#gallery-grid .gallery-card").length, | |
| status: document.querySelector("#gallery-status")?.textContent?.trim() || "", | |
| href: card.getAttribute("href") | |
| } | |
| : false; | |
| }`, | |
| `gallery index card ${galleryId}`, | |
| timeoutMs, | |
| ); | |
| } | |
| async function resetGeneratedResult(page) { | |
| await page.clickAndWait( | |
| '#nav-create', | |
| pageConditionVisible('#panel-result'), | |
| 'return to the generated result', | |
| ); | |
| const reset = await page.clickAndWait( | |
| '#again-btn', | |
| `() => { | |
| const upload = document.querySelector("#panel-upload"); | |
| if (!upload || upload.hidden) return false; | |
| const downloads = ["dl-ts", "dl-spec", "dl-bundle", "dl-standalone"] | |
| .map((id) => document.getElementById(id)); | |
| return { | |
| path: location.pathname, | |
| shareChecked: document.querySelector("#share-toggle")?.checked, | |
| previewHidden: document.querySelector("#preview-row")?.hidden, | |
| fileEmpty: !document.querySelector("#file-input")?.value, | |
| viewerSource: document.querySelector("#viewer-frame")?.getAttribute("src") || "", | |
| downloadsCleared: downloads.every((link) => | |
| !link?.getAttribute("href") && link?.getAttribute("aria-disabled") === "true" | |
| ), | |
| screenshotDisabled: document.querySelector("#dl-shot")?.disabled, | |
| warningsHidden: document.querySelector("#warnings-box")?.hidden | |
| }; | |
| }`, | |
| 'Create another model reset', | |
| ); | |
| assert(reset.path === '/', `reset should return to /, got ${reset.path}`); | |
| assert(reset.shareChecked === true, 'reset did not restore default-on sharing'); | |
| assert(reset.previewHidden === true && reset.fileEmpty === true, 'reset retained the prior upload'); | |
| assert(reset.viewerSource === 'about:blank', `reset retained viewer source ${reset.viewerSource}`); | |
| assert(reset.downloadsCleared && reset.screenshotDisabled, 'reset retained result downloads'); | |
| assert(reset.warningsHidden, 'reset retained prior validation warnings'); | |
| return reset; | |
| } | |
| async function auditHubParentEmbed({ | |
| parentPage, | |
| targetMonitor, | |
| hubUrl, | |
| appRoot, | |
| galleryId, | |
| viewerTimeoutMs, | |
| screenshotPath, | |
| diagnostics = [], | |
| }) { | |
| const appOrigin = new URL(appRoot).origin; | |
| const phase = (name, details = {}) => { | |
| const entry = { phase: name, at: new Date().toISOString(), ...details }; | |
| diagnostics.push(entry); | |
| console.error(`[audit][hub] ${name}${details.url ? `: ${details.url}` : ''}`); | |
| return entry; | |
| }; | |
| phase('parent-navigation-start', { url: hubUrl }); | |
| await parentPage.setDesktopViewport(); | |
| console.error(`[audit] Navigating to Hugging Face parent page ${hubUrl}`); | |
| await parentPage.navigate(hubUrl, 90_000); | |
| phase('parent-document-loaded', { url: hubUrl }); | |
| const iframeInfo = await parentPage.waitForCondition( | |
| `() => { | |
| const expectedOrigin = ${JSON.stringify(appOrigin)}; | |
| const iframe = [...document.querySelectorAll("iframe[src]")].find((candidate) => { | |
| try { | |
| const url = new URL(candidate.src, location.href); | |
| return url.origin === expectedOrigin; | |
| } catch { | |
| return false; | |
| } | |
| }); | |
| if (!iframe) return false; | |
| iframe.scrollIntoView({block: "center", inline: "center"}); | |
| const bounds = iframe.getBoundingClientRect(); | |
| return bounds.width > 100 && bounds.height > 100 | |
| ? { | |
| src: iframe.src, | |
| title: iframe.title || "", | |
| sandbox: iframe.getAttribute("sandbox") || "", | |
| allow: iframe.getAttribute("allow") || "", | |
| width: Math.round(bounds.width), | |
| height: Math.round(bounds.height) | |
| } | |
| : false; | |
| }`, | |
| 'the actual Hugging Face Space iframe', | |
| 90_000, | |
| ); | |
| phase('space-iframe-visible', { | |
| url: iframeInfo.src, | |
| width: iframeInfo.width, | |
| height: iframeInfo.height, | |
| sandbox: iframeInfo.sandbox, | |
| }); | |
| const appTarget = await targetMonitor.waitForTarget((record) => { | |
| if (record.targetInfo?.type !== 'iframe') return false; | |
| try { | |
| const url = new URL(record.targetInfo.url); | |
| return url.origin === appOrigin && url.pathname !== '/static/viewer.html'; | |
| } catch { | |
| return false; | |
| } | |
| }, 90_000); | |
| phase('root-oopif-acquired', { | |
| url: appTarget.targetInfo.url, | |
| targetId: appTarget.targetId, | |
| sessionId: appTarget.sessionId, | |
| }); | |
| const embeddedPage = new BrowserPage(parentPage.cdp, appTarget.sessionId); | |
| const embeddedInitial = await embeddedPage.waitForCondition( | |
| `() => { | |
| const toggle = document.querySelector("#share-toggle"); | |
| const upload = document.querySelector("#panel-upload"); | |
| const badge = document.querySelector("#llm-badge"); | |
| return toggle && upload && !upload.hidden | |
| && badge?.textContent?.trim() === "Model ready" | |
| ? { | |
| shareChecked: toggle.checked, | |
| title: document.title, | |
| path: location.pathname, | |
| badge: badge.textContent.trim() | |
| } | |
| : false; | |
| }`, | |
| 'the img2threejs app inside the Hugging Face iframe', | |
| 60_000, | |
| ); | |
| assert(embeddedInitial.shareChecked === true, 'embedded app lost its default-on share choice'); | |
| phase('embedded-root-ready', { | |
| path: embeddedInitial.path, | |
| sessionId: appTarget.sessionId, | |
| }); | |
| phase('embedded-gallery-navigation-start'); | |
| const embeddedGalleryRoute = await embeddedPage.clickAndWait( | |
| '#nav-gallery', | |
| `() => { | |
| const panel = document.querySelector("#panel-gallery"); | |
| return location.pathname === "/gallery" && panel && !panel.hidden | |
| ? {path: location.pathname} | |
| : false; | |
| }`, | |
| 'the embedded community gallery route', | |
| 60_000, | |
| ); | |
| const embeddedIndex = await waitForGalleryIndex( | |
| embeddedPage, | |
| galleryId, | |
| viewerTimeoutMs, | |
| ); | |
| phase('embedded-gallery-index-ready', { | |
| path: embeddedGalleryRoute.path, | |
| cards: embeddedIndex.cardCount, | |
| status: embeddedIndex.status, | |
| }); | |
| phase('embedded-gallery-card-open-start', { url: new URL(embeddedIndex.href, appRoot).href }); | |
| const deepRoute = await embeddedPage.clickAndWait( | |
| `#gallery-grid .gallery-card-button[href=${JSON.stringify(`/gallery/${galleryId}`)}]`, | |
| `() => { | |
| const dialog = document.querySelector("#gallery-dialog"); | |
| return location.pathname === ${JSON.stringify(`/gallery/${galleryId}`)} | |
| && dialog?.open | |
| ? {path: location.pathname, title: document.title} | |
| : false; | |
| }`, | |
| 'the embedded gallery card deep route', | |
| 60_000, | |
| ); | |
| phase('embedded-deep-route-ready', { | |
| path: deepRoute.path, | |
| sessionId: appTarget.sessionId, | |
| }); | |
| const embeddedGallery = await auditGalleryDetail( | |
| embeddedPage, | |
| galleryId, | |
| viewerTimeoutMs, | |
| { exerciseControls: true, exerciseScreenshot: false }, | |
| ); | |
| phase('nested-gallery-viewer-ready', { | |
| sessionId: appTarget.sessionId, | |
| stats: embeddedGallery.report.viewer.stats, | |
| }); | |
| await parentPage.settleFrames(); | |
| const screenshot = await parentPage.screenshot(screenshotPath); | |
| phase('parent-embed-evidence-captured', { path: screenshot.path }); | |
| const attachedTargets = targetMonitor.attachedTargets(); | |
| const appFrames = attachedTargets.filter((target) => { | |
| try { | |
| return new URL(target.url).origin === appOrigin; | |
| } catch { | |
| return false; | |
| } | |
| }); | |
| assert(appFrames.length >= 1, 'the Hugging Face parent did not expose an attached Space iframe target'); | |
| return { | |
| hubUrl, | |
| parentIframe: iframeInfo, | |
| embeddedInitial, | |
| embeddedIndex, | |
| embeddedGallery: embeddedGallery.report, | |
| nestedViewerReady: true, | |
| attachedSpaceTargets: appFrames, | |
| screenshot, | |
| phases: diagnostics, | |
| }; | |
| } | |
| async function main() { | |
| let options; | |
| try { | |
| options = parseArgs(process.argv.slice(2)); | |
| } catch (error) { | |
| console.error(error.message); | |
| console.error(usage()); | |
| process.exitCode = 2; | |
| return; | |
| } | |
| if (options.help) { | |
| console.log(usage()); | |
| return; | |
| } | |
| const appRoot = directAppRoot(options.url); | |
| const hubUrl = await resolveHubParentUrl(options.hubUrl, appRoot); | |
| const imagePath = options.image ? resolve(options.image) : null; | |
| if (imagePath) { | |
| const imageStat = await stat(imagePath); | |
| assert(imageStat.isFile() && imageStat.size > 0, `image does not exist or is empty: ${imagePath}`); | |
| } | |
| const chromiumBinary = await findChromium(options.chromium); | |
| const outputRoot = resolve(options.outputDir); | |
| await mkdir(outputRoot, { recursive: true }); | |
| const runStamp = new Date().toISOString().replace(/[:.]/g, '-'); | |
| const runDirectory = join(outputRoot, `audit-${runStamp}`); | |
| const downloadDirectory = join(runDirectory, 'downloads'); | |
| await mkdir(downloadDirectory, { recursive: true }); | |
| const userDataDirectory = await mkdtemp(join(tmpdir(), 'img2threejs-cdp-')); | |
| let child = null; | |
| let cdp = null; | |
| let page = null; | |
| let targetId = null; | |
| let downloadMonitor = null; | |
| let targetMonitor = null; | |
| let removeProgressReporter = null; | |
| const pageIssues = []; | |
| const report = { | |
| url: appRoot, | |
| hubUrl, | |
| mode: options.skipSubmit ? 'existing-gallery' : 'submit-and-share', | |
| image: imagePath ? basename(imagePath) : null, | |
| jobId: null, | |
| galleryId: options.galleryId || null, | |
| runDirectory, | |
| screenshots: [], | |
| checks: {}, | |
| }; | |
| let resultBytesByArtifact = {}; | |
| let galleryBaselineBytes = {}; | |
| try { | |
| console.error(`[audit] Launching ${chromiumBinary}`); | |
| const launched = await launchChromium(chromiumBinary, userDataDirectory); | |
| child = launched.child; | |
| cdp = launched.cdp; | |
| const created = await cdp.send('Target.createTarget', { url: 'about:blank' }); | |
| targetId = created.targetId; | |
| const attached = await cdp.send('Target.attachToTarget', { | |
| targetId, | |
| flatten: true, | |
| }); | |
| page = new BrowserPage(cdp, attached.sessionId); | |
| await Promise.all([ | |
| page.send('Page.enable'), | |
| page.send('Runtime.enable'), | |
| page.send('DOM.enable'), | |
| page.send('Network.enable'), | |
| page.send('Log.enable'), | |
| ]); | |
| targetMonitor = new TargetMonitor(cdp, page.sessionId, targetId, pageIssues); | |
| await targetMonitor.start(); | |
| await cdp.send('Browser.setDownloadBehavior', { | |
| behavior: 'allowAndName', | |
| downloadPath: downloadDirectory, | |
| eventsEnabled: true, | |
| }); | |
| downloadMonitor = new DownloadMonitor(cdp, downloadDirectory); | |
| await page.setDesktopViewport(); | |
| console.error(`[audit] Navigating to ${appRoot}`); | |
| await page.navigate(appRoot, 60_000); | |
| await page.waitForCondition( | |
| `() => { | |
| const toggle = document.querySelector("#share-toggle"); | |
| const badge = document.querySelector("#llm-badge"); | |
| return toggle && badge && !/checking/i.test(badge.textContent) | |
| ? {checked: toggle.checked, badge: badge.textContent.trim()} | |
| : false; | |
| }`, | |
| 'application initialization', | |
| 45_000, | |
| ); | |
| const initial = await page.evaluate(`(() => ({ | |
| checked: document.querySelector("#share-toggle")?.checked, | |
| badge: document.querySelector("#llm-badge")?.textContent?.trim() || "", | |
| disclosure: document.querySelector(".share-control")?.textContent?.trim().replace(/\\s+/g, " ") || "" | |
| }))()`); | |
| assert(initial.checked === true, 'the public-gallery share toggle must be checked by default'); | |
| assert(/on by default/i.test(initial.disclosure), 'the default-sharing disclosure must be visible'); | |
| if (!options.skipSubmit) { | |
| assert(initial.badge === 'Model ready', `generation model is not ready: ${initial.badge}`); | |
| } | |
| report.checks.initial = initial; | |
| report.checks.externalLinks = await auditExternalLinks(page); | |
| report.screenshots.push( | |
| await page.screenshot(join(runDirectory, 'initial-desktop.png'), '#panel-upload'), | |
| ); | |
| await page.setMobileViewport(); | |
| report.screenshots.push( | |
| await page.screenshot(join(runDirectory, 'upload-mobile.png'), '#panel-upload'), | |
| ); | |
| await page.setDesktopViewport(); | |
| if (!options.skipSubmit) { | |
| let streamedProgressKey = ''; | |
| removeProgressReporter = await installAuditRecorder(page, (update) => { | |
| if (update.type === 'job-accepted' && update.jobId) { | |
| report.jobId = update.jobId; | |
| console.error(`[audit] Job accepted: ${update.jobId}`); | |
| return; | |
| } | |
| if (update.type === 'progress') { | |
| const progressKey = [ | |
| update.stage, | |
| update.activity, | |
| update.attempt, | |
| update.lastLogLine, | |
| ].join('\u0000'); | |
| if (progressKey === streamedProgressKey) return; | |
| streamedProgressKey = progressKey; | |
| const attempt = update.attempt && update.attempt !== '—' | |
| ? ` · attempt ${update.attempt}` | |
| : ''; | |
| console.error( | |
| `[progress ${update.elapsed || '--:--'}] ${update.stage}: ${update.activity}${attempt}`, | |
| ); | |
| } | |
| }); | |
| await selectFile(page, imagePath); | |
| assert( | |
| await page.evaluate('document.querySelector("#share-toggle")?.checked === true'), | |
| 'the share toggle changed before submission', | |
| ); | |
| if (options.hint) { | |
| await page.evaluate(`(() => { | |
| const input = document.querySelector("#hint"); | |
| input.value = ${JSON.stringify(options.hint)}; | |
| input.dispatchEvent(new Event("input", {bubbles: true})); | |
| })()`); | |
| } | |
| console.error('[audit] Submitting image; waiting on DOM/SSE events (no polling)'); | |
| const terminal = await page.clickAndWait( | |
| '#run-btn', | |
| `() => { | |
| const result = document.querySelector("#panel-result"); | |
| const error = document.querySelector("#panel-error"); | |
| if (result && !result.hidden) return {kind: "result"}; | |
| if (error && !error.hidden) { | |
| return { | |
| kind: "error", | |
| title: document.querySelector("#error-title")?.textContent?.trim() || "", | |
| message: document.querySelector("#error-message")?.textContent?.trim() || "", | |
| detail: document.querySelector("#error-detail")?.textContent?.trim() || "" | |
| }; | |
| } | |
| return false; | |
| }`, | |
| 'generation result or honest error', | |
| options.timeoutMs, | |
| ); | |
| report.jobId ||= await page.evaluate('window.__i2tAuditJobResponse?.job_id || null'); | |
| assert(terminal.kind === 'result', `${terminal.title}: ${terminal.message}\n${terminal.detail}`); | |
| assert(report.jobId && GALLERY_ID_PATTERN.test(report.jobId), `invalid or missing job ID: ${report.jobId}`); | |
| report.checks.progress = await inspectProgress(page); | |
| const sharing = await page.evaluate(`(() => ({ | |
| status: document.querySelector("#result-share-status")?.textContent?.trim() || "", | |
| galleryButtonVisible: !document.querySelector("#gallery-result-link")?.hidden, | |
| title: document.querySelector("#result-title")?.textContent?.trim() || "", | |
| subtitle: document.querySelector("#result-sub")?.textContent?.trim() || "" | |
| }))()`); | |
| assert(sharing.status === 'Shared publicly', `result was not shared: ${sharing.status}`); | |
| assert(sharing.galleryButtonVisible, 'shared result does not expose its gallery-detail action'); | |
| report.checks.result = sharing; | |
| const resultViewer = await waitForViewer(page, '', options.viewerTimeoutMs); | |
| assert(resultViewer.stats && !/unavailable/i.test(resultViewer.stats), 'result viewer did not report valid stats'); | |
| report.checks.resultViewer = { | |
| ready: resultViewer, | |
| wireframe: await toggleViewerControl( | |
| page, | |
| '#viewer-wireframe', | |
| '#viewer-control-status', | |
| 'result wireframe', | |
| ), | |
| shadows: await toggleViewerControl( | |
| page, | |
| '#viewer-shadows', | |
| '#viewer-control-status', | |
| 'result shadows', | |
| ), | |
| }; | |
| report.checks.resultViewer.gestures = await exerciseViewerGestures(page, '#viewer-frame-wrap'); | |
| report.checks.resultViewer.reset = await resetViewer( | |
| page, | |
| '#viewer-reset-camera', | |
| '#viewer-control-status', | |
| 'result', | |
| ); | |
| report.screenshots.push( | |
| await page.screenshot(join(runDirectory, 'result-desktop.png'), '#panel-result'), | |
| ); | |
| await page.setMobileViewport(); | |
| report.screenshots.push( | |
| await page.screenshot(join(runDirectory, 'result-mobile.png'), '#panel-result'), | |
| ); | |
| await page.setDesktopViewport(); | |
| console.error('[audit] Exercising all result downloads'); | |
| const resultDownloads = await downloadResultArtifacts(page, downloadMonitor); | |
| report.checks.downloads = resultDownloads.report; | |
| resultBytesByArtifact = resultDownloads.bytesByArtifact; | |
| console.error('[audit] Opening the newly shared gallery item'); | |
| const opened = await page.clickAndWait( | |
| '#gallery-result-link', | |
| `() => { | |
| const match = location.pathname.match(/^\\/gallery\\/([a-f0-9]{32})$/i); | |
| return match ? {id: match[1]} : false; | |
| }`, | |
| 'shared gallery route', | |
| ); | |
| report.galleryId = opened.id; | |
| } else { | |
| console.error(`[audit] Opening existing gallery item ${report.galleryId}`); | |
| await page.navigate(new URL(`/gallery/${report.galleryId}`, appRoot).href, 60_000); | |
| } | |
| const galleryAudit = await auditGalleryDetail( | |
| page, | |
| report.galleryId, | |
| options.viewerTimeoutMs, | |
| { | |
| downloadMonitor, | |
| exerciseControls: true, | |
| exerciseScreenshot: true, | |
| }, | |
| ); | |
| report.checks.gallery = galleryAudit.report; | |
| const galleryArtifacts = await fetchAndValidateGalleryArtifacts( | |
| galleryAudit.item, | |
| appRoot, | |
| { expectedResultBytes: resultBytesByArtifact }, | |
| ); | |
| galleryBaselineBytes = galleryArtifacts.bytesByArtifact; | |
| report.checks.galleryArtifacts = galleryArtifacts.report; | |
| report.checks.externalLinksAfterGallery = await auditExternalLinks(page); | |
| report.screenshots.push( | |
| await page.screenshot(join(runDirectory, 'gallery-desktop.png'), '#gallery-dialog'), | |
| ); | |
| await page.setMobileViewport(); | |
| report.screenshots.push( | |
| await page.screenshot(join(runDirectory, 'gallery-mobile.png'), '#gallery-dialog'), | |
| ); | |
| await page.setDesktopViewport(); | |
| report.checks.galleryDialogClose = await closeGalleryDialog(page); | |
| report.checks.galleryIndex = await waitForGalleryIndex( | |
| page, | |
| report.galleryId, | |
| options.viewerTimeoutMs, | |
| ); | |
| report.screenshots.push( | |
| await page.screenshot(join(runDirectory, 'gallery-index-desktop.png'), '#panel-gallery'), | |
| ); | |
| await page.setMobileViewport(); | |
| report.screenshots.push( | |
| await page.screenshot(join(runDirectory, 'gallery-index-mobile.png'), '#panel-gallery'), | |
| ); | |
| await page.setDesktopViewport(); | |
| if (!options.skipSubmit) { | |
| report.checks.createAnotherReset = await resetGeneratedResult(page); | |
| } | |
| const coldGalleryUrl = new URL(`/gallery/${report.galleryId}`, appRoot).href; | |
| console.error(`[audit] Cold-loading shared deep link ${coldGalleryUrl}`); | |
| await page.navigate(coldGalleryUrl, 60_000); | |
| const coldGalleryAudit = await auditGalleryDetail( | |
| page, | |
| report.galleryId, | |
| options.viewerTimeoutMs, | |
| { exerciseControls: false, exerciseScreenshot: false }, | |
| ); | |
| report.checks.coldGalleryDeepLink = { | |
| url: coldGalleryUrl, | |
| path: await page.evaluate('location.pathname'), | |
| ...coldGalleryAudit.report, | |
| }; | |
| assert( | |
| report.checks.coldGalleryDeepLink.path === `/gallery/${report.galleryId}`, | |
| 'cold gallery navigation did not preserve the deep-link path', | |
| ); | |
| const coldGalleryArtifacts = await fetchAndValidateGalleryArtifacts( | |
| coldGalleryAudit.item, | |
| appRoot, | |
| { | |
| expectedResultBytes: resultBytesByArtifact, | |
| immutableBaseline: galleryBaselineBytes, | |
| }, | |
| ); | |
| report.checks.coldGalleryArtifacts = coldGalleryArtifacts.report; | |
| report.screenshots.push( | |
| await page.screenshot(join(runDirectory, 'gallery-cold-deeplink-desktop.png'), '#gallery-dialog'), | |
| ); | |
| const hubEmbedPhases = []; | |
| report.checks.huggingFaceParentEmbedPhases = hubEmbedPhases; | |
| const hubEmbed = await auditHubParentEmbed({ | |
| parentPage: page, | |
| targetMonitor, | |
| hubUrl, | |
| appRoot, | |
| galleryId: report.galleryId, | |
| viewerTimeoutMs: options.viewerTimeoutMs, | |
| screenshotPath: join(runDirectory, 'huggingface-parent-embed-desktop.png'), | |
| diagnostics: hubEmbedPhases, | |
| }); | |
| report.checks.huggingFaceParentEmbed = hubEmbed; | |
| report.screenshots.push(hubEmbed.screenshot); | |
| report.checks.autoAttachedTargets = targetMonitor.attachedTargets(); | |
| const uniqueIssues = [...new Set(pageIssues)]; | |
| assert(uniqueIssues.length === 0, `page/console errors:\n${uniqueIssues.join('\n')}`); | |
| report.checks.pageErrors = 0; | |
| report.ok = true; | |
| await writeFile( | |
| join(runDirectory, 'report.json'), | |
| `${JSON.stringify(report, null, 2)}\n`, | |
| 'utf8', | |
| ); | |
| console.log(JSON.stringify(report, null, 2)); | |
| console.error( | |
| `[audit] PASS job=${report.jobId || 'skipped'} gallery=${report.galleryId} artifacts=${runDirectory}`, | |
| ); | |
| } catch (error) { | |
| report.ok = false; | |
| report.error = error.stack || error.message || String(error); | |
| report.pageIssues = [...new Set(pageIssues)]; | |
| if (page) { | |
| try { | |
| report.screenshots.push( | |
| await page.screenshot(join(runDirectory, 'failure.png')), | |
| ); | |
| } catch { | |
| // Preserve the original failure. | |
| } | |
| } | |
| try { | |
| await writeFile( | |
| join(runDirectory, 'report.json'), | |
| `${JSON.stringify(report, null, 2)}\n`, | |
| 'utf8', | |
| ); | |
| } catch { | |
| // Preserve the original failure. | |
| } | |
| console.error(`[audit] FAIL\n${report.error}`); | |
| if (report.pageIssues.length) { | |
| console.error(`[audit] Page issues:\n${report.pageIssues.join('\n')}`); | |
| } | |
| console.error(`[audit] Failure artifacts: ${runDirectory}`); | |
| process.exitCode = 1; | |
| } finally { | |
| removeProgressReporter?.(); | |
| downloadMonitor?.close(); | |
| targetMonitor?.close(); | |
| if (cdp && targetId) { | |
| try { | |
| await cdp.send('Target.closeTarget', { targetId }, undefined, 5000); | |
| } catch { | |
| // Chromium shutdown below is authoritative. | |
| } | |
| } | |
| cdp?.close(); | |
| await stopChromium(child); | |
| await rm(userDataDirectory, { recursive: true, force: true }); | |
| } | |
| } | |
| await main(); | |