Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";import { basename, dirname, join, resolve } from "node:path";import { pathToFileURL } from "node:url";import { Bridge } from "../bridge/bridge";import { writeBridgeConfig } from "../bridge/bridgeConfigFile";import type { Settled } from "../bridge/dispatcher";import { loadConfig, type AiteConfig } from "../shared/config";import { buildExtension } from "../tools/buildExtension";import { allowDownloads } from "./downloadSetup";import { realDownloadFiles } from "./downloadFiles";import { formatDiagnosis } from "./diagnosis/format";import { diagnose, markRelated } from "./diagnosis/rules";import { PageObserver } from "./diagnosis/observer";import { probeTargetFor, probeTargetsFrom, runProbes, type ProbeDeps } from "./diagnosis/probes";import { realProbeDeps } from "./diagnosis/probesReal";import type { Diagnosis } from "./diagnosis/types";import { formatReport, runScript, type CheckCall, type FailureToDiagnose, type RunResult } from "./engine";import { startFixtureServer, type FixtureServer } from "./fixtureServer";import { launchChrome, type ChromeProcess, type LaunchOptions } from "./launcher";import { screenshotRequest } from "./screenshot";import { runCheck } from "./checks";import { checkAllowed } from "./checkAllowlist";import { isolateCheck, type IsolateCheck } from "./checkIsolation";import { expandIncludes } from "./include";import { formatParseError, parseScript } from "./parser";import { withTrustedInput } from "./trusted";import { buildRunJson } from "./runJson";import { closeLock, describeLockReport, openLock } from "./lockRun";import { pngDataUri } from "../report/generate";import { renderReport } from "../report/render";import { profileInUse } from "./profile";
export interface BridgeLike { runId: string; /** Who connected: the browser, the extension version and the profile it reported. */ readonly peer?: { chrome: string; extVersion: string; profile: string } | undefined; endpoint: string; token: string; waitForConnection(ms: number): Promise<void>; request(body: { cmd: string; [key: string]: unknown }, options?: { timeoutMs?: number }): Promise<Settled>; close(): Promise<void>;}
export interface RunDeps { readFile(path: string): string; loadConfig(path: string): AiteConfig; startFixtures(root: string, port: number): Promise<FixtureServer>; buildExtension(options: { outdir: string; allow: string[] }): void; startBridge(): Promise<BridgeLike>; writeBridgeConfig(dir: string, bridge: { endpoint: string; token: string }, profile: string): string; launch(options: LaunchOptions): Promise<ChromeProcess>; /** Another Chrome that has the profile open, if there is one. */ profileInUse(userDataDir: string): { pid: number; host: string } | undefined; removeFile(path: string): void; makeDir(path: string): void; writeFile(path: string, data: Buffer): void; now(): number; sleep(ms: number): Promise<void>; /** Calls `fire` after `ms` unless the returned function is called first. */ startTimer?(ms: number, fire: () => void): () => void; fileExists(path: string): boolean; /** What the failure probes may use of this machine (name lookups, connections, the route table). */ probeDeps(): ProbeDeps; /** Loads a TypeScript check module (scripts/checks/NAME.ts). */ importModule(path: string): Promise<unknown>; /** P7-5: undefined when `call NAME` may run, else why not. The real one reads scripts/checks/allowlist.json; a test may leave it out. */ checkAllowed?(checksDir: string, name: string): string | undefined; /** P7-5.d: runs a check in its own process with a stripped environment. A test that supplies its own `importModule` leaves this out. */ isolateCheck?: IsolateCheck | undefined;}
export interface RunOptions { scriptPath: string; configPath: string; /** Overrides the script's `needs session` and the only-profile default. */ profile?: string; /** Serve this folder on the start page's port. Default: the script's own folder, if it is called fixtures. */ serveDir?: string; headless: boolean; noSandbox: boolean; display?: string; connectTimeoutMs?: number; /** Do not read or write the script's lock file. For runs of scripts that live in a repository the run must not change. */ noLock?: true; /** An unattended run: never write the lock file, refuse a target it does not know, and report every repair on every run. */ freeze?: true; /** The Chrome version the previous unattended run of this script used; a different one is flagged "environment changed" in run.json and the report. */ previousChrome?: string; /** The most the whole run may take. When it is over the run is stopped and reported as failed with `stopped` in run.json. */ budgetMs?: number;}
/** 0 passed, 1 the test failed, 2 it could not be run (script, config or usage problem), 3 the environment failed (Chrome, build). */export interface RunOutcome { exitCode: 0 | 1 | 2 | 3; output: string;}
export const realRunDeps = (): RunDeps => ({ readFile: (p) => readFileSync(p, "utf8"), loadConfig: (p) => loadConfig(p), startFixtures: (root, port) => startFixtureServer({ root, port }), buildExtension: (o) => void buildExtension(o), startBridge: () => Bridge.start({}), writeBridgeConfig, launch: launchChrome, profileInUse, removeFile: (p) => rmSync(p, { force: true }), makeDir: (p) => void mkdirSync(p, { recursive: true, mode: 0o700 }), // a profile holds signed-in sessions: private to the user writeFile: (p, data) => writeFileSync(p, data), now: () => Date.now(), sleep: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)), startTimer: (ms, fire) => { const t = setTimeout(fire, ms); return () => clearTimeout(t); }, fileExists: (path) => existsSync(path), probeDeps: () => realProbeDeps(), importModule: (path) => import(pathToFileURL(path).href), checkAllowed: checkAllowed, isolateCheck: isolateCheck,});
const DEFAULT_CONNECT_TIMEOUT_MS = 30_000;
/** What to show of Chrome's stderr: the lines that look like a reason (FATAL, sandbox, error), else the last few lines. */function explainStderr(stderr: string): string { const lines = stderr.split("\n").filter((l) => l.trim() !== ""); const reasons = lines.filter((l) => /fatal|sandbox|error/i.test(l)); return (reasons.length > 0 ? reasons.slice(0, 5) : lines.slice(-5)).map((l) => ` ${l.trim().slice(0, 200)}`).join("\n");}const messageOf = (e: unknown) => (e instanceof Error ? e.message : String(e));
/** A screenshot of the tab a request names, over Chrome's debugging channel; without one (no pipe) the answer says so, as it always has. */function screenshotter(bridge: Pick<BridgeLike, "request">, cdp: ChromeProcess["cdp"]) { if (cdp === undefined) return async () => ({ id: 0, ok: false, reason: "internal", detail: { message: "Chrome's debugging channel is not available" } }) as never; return screenshotRequest((b, o) => bridge.request(b, o), cdp);}
export async function runScriptFile(options: RunOptions, deps: RunDeps = realRunDeps()): Promise<RunOutcome> { const file = options.scriptPath; const refuse = (output: string): RunOutcome => ({ exitCode: 2, output });
let text: string; try { text = deps.readFile(file); } catch (err) { return refuse(`cannot read ${file}: ${messageOf(err)}`); } const read = parseScript(text); if (!read.ok) return refuse([...read.errors.map((e) => formatParseError(file, e)), `${read.errors.length} error${read.errors.length === 1 ? "" : "s"} in ${file}`].join("\n")); // The folder the config is in is the project: included scripts must be inside it, and checks are in its scripts/checks folder. const projectRoot = dirname(resolve(options.configPath)); const expanded = expandIncludes(read.script, { file: resolve(file), display: file, root: projectRoot, readFile: deps.readFile }); if (!expanded.ok) return refuse([...expanded.errors.map((e) => `${e.file}:${e.line}: ${e.message}`), `${expanded.errors.length} error${expanded.errors.length === 1 ? "" : "s"} in ${file}`].join("\n")); const script = expanded.script;
let config: AiteConfig; try { config = deps.loadConfig(options.configPath); } catch (err) { return refuse(messageOf(err)); }
const known = Object.keys(config.profiles); let profileName: string; if (options.profile !== undefined) { if (!known.includes(options.profile)) return refuse(`profile "${options.profile}" is not in aite.config.json (known: ${known.join(", ")})`); profileName = options.profile; } else if (script.needsSession !== undefined) { if (!known.includes(script.needsSession)) return refuse(`${file}:${script.needsLine}: "needs session ${script.needsSession}" but no such profile is configured`); profileName = script.needsSession; } else if (known.length === 1) { profileName = known[0] as string; } else { return refuse(`which profile? choose one with --profile NAME or add "needs session NAME" to the script (known: ${known.join(", ")})`); } const profile = config.profiles[profileName]!; // Every other profile the script uses must be configured, in a folder of its own. const extras = script.alsoNeeds ?? []; const defaultKey = script.needsSession ?? "(default)"; for (const n of extras) { if (!known.includes(n.name)) return refuse(`${file}:${n.line}: "needs session ${n.name}" but no such profile is configured`); } const folders = [profile.userDataDir, ...extras.map((n) => config.profiles[n.name]!.userDataDir)]; if (new Set(folders.map((f) => resolve(f))).size !== folders.length) return refuse(`${file}: the profiles this script uses (${[profileName, ...extras.map((n) => n.name)].join(", ")}) must each have a folder of their own in aite.config.json`); // A profile that is a real signed-in session (it names its account) is never used by a script that does not first check who is signed in. // For each such profile, the first command that profile does (leaving out goto, which only opens its page) must be: call session-check. const flat = script.steps.flatMap((st) => st.commands); const seatOf = (c: (typeof flat)[number]) => c.on ?? defaultKey; for (const [key, name] of [[defaultKey, profileName] as const, ...extras.map((n) => [n.name, n.name] as const)]) { const account = config.profiles[name]!.account; if (account === undefined) continue; const first = flat.find((c) => seatOf(c) === key && c.verb !== "goto"); if (first === undefined && key !== defaultKey) continue; // a profile with nothing to do if (first === undefined || first.verb !== "call" || first.name !== "session-check") { const where = first !== undefined ? `${first.file ?? file}:${first.line}` : `${file}:${script.needsLine ?? 1}`; return refuse(`${where}: this script runs on profile "${name}", which is signed in as ${account}, so the first command on that profile must be: call session-check (it stops the run when the session is signed out or is another account)`); } } const busy = deps.profileInUse(profile.userDataDir); if (busy) return { exitCode: 3, output: `the Chrome profile ${profile.userDataDir} is already open in another Chrome (process ${busy.pid} on ${busy.host}). Close that Chrome, or use another profile.` }; for (const n of extras) { const other = deps.profileInUse(config.profiles[n.name]!.userDataDir); if (other) return { exitCode: 3, output: `the Chrome profile ${config.profiles[n.name]!.userDataDir} (${n.name}) is already open in another Chrome (process ${other.pid} on ${other.host}). Close that Chrome, or use another profile.` }; }
if (script.start !== undefined) { const origin = new URL(script.start).origin; if (!config.allow.includes(origin)) return refuse(`${file}:${script.startLine}: the start page ${origin} is not in the allow list in aite.config.json (allowed: ${config.allow.join(", ")})`); }
// Test pages: --serve DIR, or the script's own folder when that folder is called fixtures. Served on the start page's port. const explicit = options.serveDir !== undefined; const dir = explicit ? resolve(options.serveDir as string) : basename(dirname(resolve(file))) === "fixtures" ? dirname(resolve(file)) : undefined; let servePort: number | undefined; if (dir !== undefined) { const start = script.start !== undefined ? new URL(script.start) : undefined; if (start && start.hostname === "127.0.0.1" && start.port !== "") servePort = Number(start.port); else if (explicit) return refuse("--serve needs a start page on 127.0.0.1 with a port, for example start http://127.0.0.1:4780/hello.html"); }
const closers: Array<() => Promise<void> | void> = []; const tidy = async () => { for (const close of closers.reverse()) { try { await close(); } catch { /* keep tidying */ } } };
try { if (dir !== undefined && servePort !== undefined) { try { const fixtures = await deps.startFixtures(dir, servePort); closers.push(() => fixtures.close()); } catch (err) { return refuse(messageOf(err)); } }
// The extension lives in a folder of its own inside the profile, the same every run: Chrome then sees one extension with a // stable id and keeps its storage, instead of a new unpacked extension (and a leftover entry in the profile) each time. deps.makeDir(profile.userDataDir); const extensionDir = join(profile.userDataDir, "aite-extension"); try { deps.buildExtension({ outdir: extensionDir, allow: config.allow }); } catch (err) { return { exitCode: 3, output: `cannot build the extension: ${messageOf(err)}` }; }
const bridge = await deps.startBridge(); closers.push(() => bridge.close()); const tokenFile = deps.writeBridgeConfig(extensionDir, bridge, profileName); closers.push(() => deps.removeFile(tokenFile)); // a stale token must not be left in the profile let chrome: ChromeProcess; try { chrome = await deps.launch({ chrome: profile.chrome, userDataDir: profile.userDataDir, extensionDir, headless: options.headless, noSandbox: options.noSandbox, ...(options.display !== undefined ? { display: options.display } : {}) }); } catch (err) { return { exitCode: 3, output: messageOf(err) }; } closers.push(() => chrome.close());
// Watch the browser from the start: its requests, console and exceptions are the evidence when a command fails. Chrome is asked to wait for us // before a new page runs, so the very first page load is not missed. Without the debugging channel there is nothing to watch, and the report says so. let observer: PageObserver | undefined; if (chrome.cdp?.onEvent) { const candidate = new PageObserver(chrome.cdp, { now: deps.now }, { bodies: config.diagnosis.bodies }); try { await candidate.start(); observer = candidate; closers.push(() => candidate.stop()); } catch { candidate.stop(); } }
const waitMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS; const connection = bridge.waitForConnection(waitMs).then(() => "connected" as const, () => "timeout" as const); const outcome = await Promise.race([connection, chrome.exited]); if (outcome === "timeout") { const printed = explainStderr(chrome.stderrTail()); return { exitCode: 3, output: `the extension did not connect within ${waitMs} ms. Chrome may not have started or loaded it: try --headless, add --no-sandbox when running as root or in a container, and check the Chrome path in aite.config.json (${profile.chrome}).${printed ? `\nChrome printed:\n${printed}` : ""}` }; } if (outcome !== "connected") { const how = outcome.code !== null ? `Chrome exited with code ${outcome.code}` : `Chrome was stopped by ${outcome.signal}`; const said = explainStderr(chrome.stderrTail()); return { exitCode: 3, output: `${how} before the extension connected.${said ? `\nChrome printed:\n${said}` : ""}\nTry --no-sandbox (needed in containers, and where unprivileged user namespaces are off) and check the Chrome path in aite.config.json (${profile.chrome}).` }; }
// If Chrome goes away mid-run there is nobody left to answer: say so now instead of waiting out a command's timeout. // Pictures go to results/<run id>/shots, next to the config file (resultsDir is relative to it). const runDir = join(resolve(dirname(options.configPath)), config.resultsDir, bridge.runId); const pictures = new Map<string, Buffer>(); const saveScreenshot = async (relativePath: string, base64Png: string) => { const full = join(runDir, relativePath); deps.makeDir(dirname(full)); const bytes = Buffer.from(base64Png, "base64"); pictures.set(relativePath, bytes); deps.writeFile(full, bytes); }; // A screenshot is taken by the runner over Chrome's debugging channel, not by the extension (which would need <all_urls> to do it). // The extension is only asked which url the run's tab is showing. const cdp = chrome.cdp; const screenshotOf = screenshotter(bridge, cdp); const bridgeRequest = (body: { cmd: string; [key: string]: unknown }, opts?: { timeoutMs?: number }) => bridge.request(body, opts); // With the debugging pipe, clicks, typing, key presses and drags are real input (D11); without it they are plain events in the page. const downloadDir = join(runDir, "downloads"); if (cdp) { // the run's downloads go to a folder of their own in the results, so `verify download` looks at this run's files and nothing else try { deps.makeDir(downloadDir); await allowDownloads(cdp, downloadDir); } catch { /* a click that downloads still works; `verify download` will then say no file arrived */ } } const trusted = cdp ? withTrustedInput({ request: bridgeRequest, cdp, clock: { now: deps.now, sleep: deps.sleep }, downloadDir, files: realDownloadFiles }) : undefined; if (trusted) closers.push(() => trusted.stop()); type Req = (body: { cmd: string; [key: string]: unknown }, opts?: { timeoutMs?: number }) => Promise<Settled>; const defaultRequest: Req = (body, opts) => (body.cmd === "screenshot" ? screenshotOf(body) : trusted ? trusted.request(body, opts) : bridgeRequest(body, opts)); /** The browsers of this run, by the name the script uses for the profile: the default one, and one more for each other profile the script declares. */ const seats = new Map<string, { request: Req; bridge: BridgeLike }>([[defaultKey, { request: defaultRequest, bridge }]]); for (const n of extras) { const xprofile = config.profiles[n.name]!; deps.makeDir(xprofile.userDataDir); const xdir = join(xprofile.userDataDir, "aite-extension"); try { deps.buildExtension({ outdir: xdir, allow: config.allow }); } catch (err) { return { exitCode: 3, output: `cannot build the extension for profile ${n.name}: ${messageOf(err)}` }; } const xbridge = await deps.startBridge(); closers.push(() => xbridge.close()); const xtoken = deps.writeBridgeConfig(xdir, xbridge, n.name); closers.push(() => deps.removeFile(xtoken)); let xchrome: ChromeProcess; try { xchrome = await deps.launch({ chrome: xprofile.chrome, userDataDir: xprofile.userDataDir, extensionDir: xdir, headless: options.headless, noSandbox: options.noSandbox, ...(options.display !== undefined ? { display: options.display } : {}) }); } catch (err) { return { exitCode: 3, output: `profile ${n.name}: ${messageOf(err)}` }; } closers.push(() => xchrome.close()); const joined = await Promise.race([xbridge.waitForConnection(waitMs).then(() => "connected" as const, () => "timeout" as const), xchrome.exited]); if (joined !== "connected") { const how = joined === "timeout" ? `the extension did not connect within ${waitMs} ms` : joined.code !== null ? `Chrome exited with code ${joined.code} before the extension connected` : `Chrome was stopped by ${joined.signal} before the extension connected`; return { exitCode: 3, output: `profile ${n.name}: ${how}.${explainStderr(xchrome.stderrTail()) ? `\nChrome printed:\n${explainStderr(xchrome.stderrTail())}` : ""}` }; } const xdownloads = join(runDir, `downloads-${n.name}`); if (xchrome.cdp) { try { deps.makeDir(xdownloads); await allowDownloads(xchrome.cdp, xdownloads); } catch { /* as for the first profile: a download still works, verify download will say no file arrived */ } } const xbridgeRequest: Req = (b, o) => xbridge.request(b, o); const xtrusted = xchrome.cdp ? withTrustedInput({ request: xbridgeRequest, cdp: xchrome.cdp, clock: { now: deps.now, sleep: deps.sleep }, downloadDir: xdownloads, files: realDownloadFiles }) : undefined; if (xtrusted) closers.push(() => xtrusted.stop()); const xshot = screenshotter(xbridge, xchrome.cdp); seats.set(n.name, { request: (b, o) => (b.cmd === "screenshot" ? xshot(b) : xtrusted ? xtrusted.request(b, o) : xbridgeRequest(b, o)), bridge: xbridge }); } /** A request goes to the browser of the profile it names (the engine names one only in a script that uses several). */ const request: Req = (body, opts) => { const { profile: asked, ...rest } = body as { profile?: unknown; cmd: string; [key: string]: unknown }; const seat = asked === undefined ? seats.get(defaultKey) : typeof asked === "string" ? seats.get(asked) : undefined; if (seat === undefined) return Promise.resolve({ id: 0, ok: false, reason: "internal", detail: { message: `this run has no browser for profile ${String(asked)}` } } as never); return seat.request(rest, opts); }; const checksDir = join(projectRoot, "scripts", "checks"); const check = (call: CheckCall) => { const key = call.profile ?? defaultKey; const asProfile = key === defaultKey ? profileName : key; const seat = seats.get(key) ?? seats.get(defaultKey)!; return runCheck({ name: call.name, args: call.args, vars: call.vars, timeoutMs: call.timeoutMs, checksDir, profile: asProfile, account: config.profiles[asProfile]?.account, load: deps.importModule, exists: deps.fileExists, ...(deps.checkAllowed ? { allow: deps.checkAllowed } : {}), ...(deps.isolateCheck ? { isolate: deps.isolateCheck, cwd: projectRoot } : {}), read: async (target, readOptions) => { // a read waits up to half the command's time, so a check whose element never comes can report that before it is stopped for taking too long const wait = Math.max(100, Math.floor(call.timeoutMs / 2)); const a = (await request({ cmd: "read", target, ...(readOptions?.blocks === true ? { blocks: true } : {}), ...(call.profile !== undefined ? { profile: call.profile } : {}), ...(call.tab !== undefined ? { tab: call.tab } : {}), timeoutMs: wait }, { timeoutMs: wait + 5000 })) as unknown as { ok?: boolean; text?: string; reason?: string; detail?: unknown }; if (a.ok !== true || typeof a.text !== "string") throw new Error(`${a.reason ?? "internal"}: ${JSON.stringify(a.detail ?? {})}`); return a.text; }, url: async () => { const a = (await seat.bridge.request({ cmd: "tabs" }, { timeoutMs: 5000 })) as unknown as { tabs?: Array<{ id: number; url: string }> }; const found = a.tabs?.find((t) => t.id === call.tab); if (!found) throw new Error("the run's tab is not open"); return found.url; }, }); }; const diagnosisConfig = config.diagnosis; const diagnoseFailure = async (f: FailureToDiagnose): Promise<Diagnosis> => { // Only the first profile's browser is watched: a failure in another profile's browser is explained without evidence from this one's network. const watched = f.profile === undefined || f.profile === defaultKey; const window = observer && watched ? observer.window(f.startedAt, f.endedAt, { includeNavigation: true }) : { from: f.startedAt, to: f.endedAt, network: [], console: [], sessions: 0, detached: false }; if (observer && watched) await observer.settled(); const failure = { verb: f.verb, reason: f.reason, line: f.line, ...(f.file !== undefined ? { file: f.file } : {}), detail: f.detail, startedAt: f.startedAt, endedAt: f.endedAt }; const noiseHosts = diagnosisConfig.noiseHosts; const unseenPage = !window.network.some((e) => e.document); let probes; if (diagnosisConfig.probes) { const related = markRelated(window.network, failure, { noiseHosts, loadStart: window.from }); const targets = probeTargetsFrom({ ...window, network: related }); if (unseenPage) { const first = script.start !== undefined ? probeTargetFor(script.start) : undefined; if (first && !targets.some((t) => t.host === first.host && t.port === first.port)) targets.unshift(first); } probes = await runProbes(targets, deps.probeDeps(), { budgetMs: diagnosisConfig.budgetMs, referenceServer: diagnosisConfig.referenceDns, maxHosts: 3 }); } return diagnose({ failure, window, observed: observer !== undefined && watched, ...(observer !== undefined && !watched ? { unobservedBecause: `this failure is in the browser of profile ${f.profile}, which the run does not watch (only the first profile's browser is observed)` } : {}), noiseHosts, ...(unseenPage ? { unseenPage } : {}), ...(script.start !== undefined ? { startUrl: script.start } : {}), ...(probes ? { probes } : { probesSkipped: "diagnosis probes are switched off in the configuration" }) }); }; const startedMs = deps.now(); const lock = options.noLock ? undefined : openLock(options.scriptPath, deps, options.freeze === true); const running = runScript(script, { request, now: deps.now, sleep: deps.sleep, runCheck: check, diagnose: diagnoseFailure, ...(lock?.store ? { lock: lock.store } : {}), ...(options.freeze ? { freeze: true as const } : {}), ...(cdp ? { saveScreenshot } : {}) }).then((result) => ({ result })); let stopBudget: (() => void) | undefined; const overBudget = options.budgetMs === undefined || deps.startTimer === undefined ? undefined : new Promise<{ overBudget: true }>((resolve) => { stopBudget = deps.startTimer!(options.budgetMs!, () => resolve({ overBudget: true })); }); const first0 = await Promise.race([running, chrome.exited.then((exit) => ({ exit })), ...(overBudget ? [overBudget] : [])]); stopBudget?.(); if ("overBudget" in first0) running.catch(() => undefined); // the browser is closed below; the pending request fails and nobody is waiting for it const first = "overBudget" in first0 ? { result: { name: script.name, ok: false, status: "failed" as const, steps: [], durationMs: deps.now() - startedMs, retries: 0, warnings: [], warningCount: 0, repairs: 0 } satisfies RunResult, stopped: { reason: "budget" as const, budgetMs: options.budgetMs! } } : first0; if ("exit" in first) { running.catch(() => undefined); // closing the bridge will fail the pending request; nobody is waiting for that const how = first.exit.code !== null ? `Chrome exited with code ${first.exit.code}` : `Chrome was stopped by ${first.exit.signal}`; let why = ""; try { const probes = diagnosisConfig.probes ? await runProbes([], deps.probeDeps(), { budgetMs: Math.min(diagnosisConfig.budgetMs, 3000), referenceServer: null, maxHosts: 0 }) : undefined; const endedMs = deps.now(); const d = diagnose({ failure: { verb: "run", reason: "chrome-exited", line: 0, detail: {}, startedAt: startedMs, endedAt: endedMs }, window: observer ? observer.window(startedMs, endedMs) : { from: startedMs, to: endedMs, network: [], console: [], sessions: 0, detached: false }, observed: observer !== undefined, chromeDied: how, ...(probes ? { probes } : { probesSkipped: "diagnosis probes are switched off in the configuration" }), }); why = `\n${formatDiagnosis(d).join("\n")}`; } catch { /* the machine could not be looked at; the exit itself is still reported */ } return { exitCode: 3, output: `${how} during the run, so the run was stopped.${why}` }; } const lockReport = lock ? closeLock(lock, deps) : undefined; const peer = bridge.peer; const json = buildRunJson({ runId: bridge.runId, startedAt: new Date(startedMs), finishedAt: new Date(deps.now()), chrome: peer?.chrome ?? "unknown", extension: peer?.extVersion ?? "unknown", profile: profileName, target: script.start, file: options.scriptPath, covers: script.covers, result: first.result, ...(lockReport ? { lock: lockReport } : {}), ...(options.previousChrome !== undefined ? { previousChrome: options.previousChrome } : {}), ...("stopped" in first ? { stopped: first.stopped } : {}), }); let saved = ""; try { deps.makeDir(runDir); deps.writeFile(join(runDir, "run.json"), Buffer.from(JSON.stringify(json, null, 2) + "\n")); } catch (err) { saved = `\nwarning: could not write run.json: ${messageOf(err)}`; } try { // The pictures of the steps that need a look go into the report itself; the rest stay in shots/ and are linked. const shots: Record<string, string> = {}; for (const step of json.tests.flatMap((t) => t.steps)) { const bytes = step.screenshot !== undefined && (step.status === "failed" || step.status === "repaired") ? pictures.get(step.screenshot) : undefined; const uri = bytes !== undefined ? pngDataUri(bytes) : undefined; if (step.screenshot !== undefined && uri !== undefined) shots[step.screenshot] = uri; } deps.writeFile(join(runDir, "report.html"), Buffer.from(renderReport(json, shots))); saved += `\nreport: ${join(runDir, "report.html")}`; } catch (err) { saved += `\nwarning: could not write report.html: ${messageOf(err)}`; } const lockLine = options.freeze && first.result.repairs > 0 ? `lock file not changed (--freeze): ${first.result.repairs} repair${first.result.repairs === 1 ? "" : "s"} for a person to review; run without --freeze and commit the lock file to accept` : lockReport ? describeLockReport(lockReport) : undefined; return { exitCode: first.result.ok ? 0 : 1, output: `${formatReport(first.result, file)}${saved}${lockLine !== undefined ? `\n${lockLine}` : ""}\nresults: ${runDir}` }; } finally { await tidy(); }}