Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212import type { RunJson, RunJsonCommand, RunJsonStep } from "../runner/runJson";
/** * The static report: one HTML file made from run.json (docs/design/data-formats.md), readable offline. It has no script of its own and a * content security policy that allows none, so nothing in the page under test (which ends up in step names, found text and so on) can run * or load anything. Every piece of text from run.json goes through `esc`; the only markup that is not escaped is the one built here. */
const esc = (value: unknown): string => String(value) .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """) .replace(/'/g, "'");
const PNG_URI = /^data:image\/png;base64,[A-Za-z0-9+/]*={0,2}$/;const SHOT_PATH = /^shots\/[A-Za-z0-9._-]+\.png$/;
const REASONS: Record<string, string> = { mismatch: "The page shows something else than the script wanted.", "not-found": "No element on the page matched the target.", ambiguous: "More than one element matched, so the script cannot tell which is meant.", "not-visible": "The element is on the page but cannot be seen or reached.", "not-enabled": "The element is there but disabled.", timeout: "The page did not get to the wanted state in time.", drifted: "A similar element is on the page, but not similar enough to trust: please confirm it is the one.", unlocked: "This run does not know what the target looked like before (run it once interactively first).", "pre-state": "The test's starting state is wrong (for example a balance too low). This is not a failure of the app.", navigation: "The page navigated while the command was running.", "load-failed": "The browser could not load the page: it showed its own error page (the site is down, cannot be reached from this machine, or the address is wrong).", "tab-closed": "The tab was closed.", "connection-lost": "The connection to the browser was lost.", unsupported: "The script uses something the extension does not support yet.", internal: "aite itself failed here.",};
const text = (v: unknown): string => (typeof v === "string" ? v : v === undefined ? "" : JSON.stringify(v));const failedCommand = (s: RunJsonStep): RunJsonCommand | undefined => s.commands.find((c) => c.status === "failed");const fixed = (n: unknown, digits = 2): string => (typeof n === "number" ? n.toFixed(digits) : "?");
const CSS = `:root{color-scheme:light dark;--bg:#fff;--fg:#1c1c1e;--muted:#6b6b70;--line:#d8d8dc;--bad:#b3261e;--badbg:#fdecea;--ok:#1b6e3a;--okbg:#e8f5ec;--fix:#8a5a00;--fixbg:#fff4dc;--card:#fafafb}@media (prefers-color-scheme:dark){:root{--bg:#161618;--fg:#ececee;--muted:#9a9aa1;--line:#34343a;--bad:#ff8a80;--badbg:#3a1f1d;--ok:#7fd49a;--okbg:#1d3326;--fix:#f2c566;--fixbg:#3a2f14;--card:#1d1d20}}body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,sans-serif}main{max-width:60rem;margin:0 auto;padding:1rem 1rem 3rem}h1{font-size:1.4rem;margin:.2rem 0}h2{font-size:1.1rem;margin:1.6rem 0 .6rem}h3{font-size:1rem;margin:0 0 .5rem}.meta,.where{color:var(--muted);font-size:.85rem}.badge{font-size:.8rem;padding:.1rem .5rem;border-radius:1rem;vertical-align:middle}.badge.failed{background:var(--badbg);color:var(--bad)}.badge.passed{background:var(--okbg);color:var(--ok)}#summary ul{list-style:none;display:flex;flex-wrap:wrap;gap:.5rem;padding:0;margin:1rem 0}#summary li{border:1px solid var(--line);border-radius:.5rem;padding:.3rem .7rem;background:var(--card)}#summary b{font-size:1.2rem;margin-right:.3rem}.c-failed b{color:var(--bad)}.c-passed b{color:var(--ok)}.c-repaired b{color:var(--fix)}article.step{border:1px solid var(--line);border-left-width:.35rem;border-radius:.5rem;padding:.8rem 1rem;margin:.8rem 0;background:var(--card)}article.failed{border-left-color:var(--bad)}article.repaired{border-left-color:var(--fix)}figure{margin:.5rem 0}figure img{max-width:100%;border:1px solid var(--line);border-radius:.3rem}dl{display:grid;grid-template-columns:6rem 1fr;gap:.2rem .8rem;margin:.5rem 0}dt{color:var(--muted)}dd{margin:0;overflow-wrap:anywhere}.wanted,.found,pre{font-family:ui-monospace,monospace;font-size:.88rem}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:var(--bg);border:1px solid var(--line);border-radius:.3rem;padding:.5rem;margin:.4rem 0}.mark{display:inline-block;width:1.2rem;font-weight:700}.failed .mark{color:var(--bad)}.repaired .mark{color:var(--fix)}table{border-collapse:collapse;width:100%;font-size:.88rem}td,th{border-bottom:1px solid var(--line);padding:.2rem .5rem;text-align:left;overflow-wrap:anywhere}details{margin:.4rem 0}summary{cursor:pointer}.verdict{margin-top:.8rem;padding-top:.6rem;border-top:1px dashed var(--line)}.verdict label{display:block;margin:.3rem 0}.verdict input[type=text]{width:100%;max-width:36rem;padding:.3rem}button{font:inherit;padding:.4rem .8rem;border:1px solid var(--line);border-radius:.4rem;background:var(--bg);color:var(--fg);cursor:pointer}button:disabled{opacity:.5}`;
function stepHeading(run: RunJson, testIndex: number, s: RunJsonStep, mark: string, line?: number): string { const t = run.tests[testIndex]!; const profile = s.commands.find((c) => c.status === "failed")?.profile ?? (t.profiles !== undefined ? [...new Set(s.commands.flatMap((c) => (c.profile !== undefined ? [c.profile] : [])))].join(", ") : ""); const where = [`test: ${t.name}`, `${t.file}${line ? `:${line}` : ""}`, ...(t.covers.length > 0 ? [`covers ${t.covers.join(", ")}`] : []), ...(profile !== "" ? [`as ${profile}`] : []), `${s.durationMs} ms`].map(esc).join(" · "); return `<h3 id="step-${esc(s.index)}"><span class="mark">${mark}</span> ${esc(s.name)} <span class="where">${where}</span></h3>`;}
function picture(s: RunJsonStep, shots: Record<string, string>): string { const uri = s.screenshot !== undefined ? shots[s.screenshot] : undefined; if (uri !== undefined && PNG_URI.test(uri)) return `<figure><img alt="the page after this step" src="${uri}"></figure>`; return `<p class="where">no screenshot for this step${s.screenshot !== undefined ? "" : " was taken"}</p>`;}
function diagnosisBlock(c: RunJsonCommand): string { const d = c.diagnosis; if (d === undefined) return ""; const fine = d.layers.filter((l) => l.status === "ok").map((l) => esc(l.layer)); const faults = d.layers.filter((l) => l.status === "failed" || l.status === "suspect"); return `<div class="diagnosis"><p><b>Likely layer: ${esc(d.likely.layer)}</b> (${esc(d.likely.confidence)} confidence). ${esc(d.likely.summary)}</p>${ faults.length > 0 ? `<ul>${faults.map((l) => `<li>${esc(l.layer)}: ${esc(l.status)}${l.notes.length > 0 ? ` (${l.notes.map(esc).join("; ")})` : ""}</li>`).join("")}</ul>` : "" }${fine.length > 0 ? `<p class="where">Checked and fine: ${fine.join(", ")}</p>` : ""}</div>`;}
function candidatesBlock(c: RunJsonCommand): string { if (!Array.isArray(c.candidates) || c.candidates.length === 0) return ""; const rows = (c.candidates as Array<Record<string, unknown>>).slice(0, 5).map((x) => { const d = (typeof x.descriptor === "object" && x.descriptor !== null ? x.descriptor : x) as Record<string, unknown>; return `<tr><td><${esc(text(d.tag) || "?")}> ${esc(text(d.text) || text(d.name))}</td><td>${esc(text(d.xpath))}</td><td>score ${esc(fixed(x.score))}</td></tr>`; }); return `<table class="candidates"><tr><th>candidate</th><th>where</th><th>score</th></tr>${rows.join("")}</table>`;}
function failedArticle(run: RunJson, testIndex: number, s: RunJsonStep, shots: Record<string, string>, withVerdict: boolean): string { const c = failedCommand(s); const wanted = c?.source ?? s.name; const foundFromSaw = c !== undefined && c.found === undefined && c.saw !== undefined && c.saw.length > 0; const found = c === undefined ? "" : c.found !== undefined ? text(c.found) : (c.saw?.[0] ?? ""); const more = c?.saw === undefined ? [] : foundFromSaw ? c.saw.slice(1) : c.saw; const reason = c?.reason ?? ""; return `<article class="step failed">${stepHeading(run, testIndex, s, "x", c?.line)}${picture(s, shots)}<dl><dt>wanted</dt><dd class="wanted">${esc(wanted)}</dd><dt>found</dt><dd class="found">${esc(found)}</dd><dt>why</dt><dd class="reason">${esc(REASONS[reason] ?? reason)}${reason !== "" ? ` <span class="where">(${esc(reason)})</span>` : ""}</dd></dl>${ more.length > 0 ? `<pre class="saw">${esc(more.join("\n"))}</pre>` : "" }${c ? candidatesBlock(c) : ""}${c ? diagnosisBlock(c) : ""}${withVerdict ? verdictBox(s) : ""}</article>`;}
function repairedArticle(run: RunJson, testIndex: number, s: RunJsonStep, shots: Record<string, string>): string { const repairs = s.commands.flatMap((c) => c.repairs ?? []); const items = repairs .map((r) => { const how = r.via === "descriptor" ? "found by its remembered description" : `score ${fixed(r.score)}, next best ${fixed(r.runnerUp)}`; return `<li><b>locator repaired</b> (${esc(r.changed.join(", ") || "changed")}): <code>${esc(r.source)}</code>: ${esc(how)}${r.changes.length > 0 ? `<ul>${r.changes.map((x) => `<li>${esc(x)}</li>`).join("")}</ul>` : ""}</li>`; }) .join(""); return `<article class="step repaired">${stepHeading(run, testIndex, s, "~")}<p class="where">Passed, but the element was found by repair. A person should glance at it, and at the change to the lock file.</p><ul>${items}</ul>${picture(s, shots)}</article>`;}
function commandsTable(s: RunJsonStep): string { const rows = s.commands.map((c) => `<tr><td>${esc(c.file ? `${c.file}:${c.line}` : c.line)}</td><td><code>${esc(c.source)}</code></td>${c.profile !== undefined ? `<td>${esc(c.profile)}</td>` : ""}<td>${esc(c.status)}</td><td>${esc(c.durationMs)} ms</td></tr>`); const link = s.screenshot !== undefined && SHOT_PATH.test(s.screenshot) ? ` <a href="${esc(s.screenshot)}">screenshot</a>` : ""; return `<details><summary>${esc(s.name)} <span class="where">${esc(s.status)}, ${esc(s.durationMs)} ms${link ? "" : ""}</span>${link}</summary><table>${rows.join("")}</table></details>`;}
/** The verdict buttons only exist on the page served by `aite verdict`; the file report never has them (it has no script). */export interface VerdictUi { /** CSP nonce for the one script of the page; made fresh for every response by the server. */ nonce: string; /** The per-session token the page sends back with every verdict. */ token: string;}
const NONCE = /^[A-Za-z0-9_-]{16,64}$/;const TOKEN = /^[A-Za-z0-9_-]{32,128}$/;
/** The page's own script: it reads the token from the meta tag, posts the verdict and shows the answer as text. It contains nothing from the run. */const VERDICT_SCRIPT = `const token = document.querySelector('meta[name="aite-token"]').content;document.addEventListener("click", async (event) => { const button = event.target.closest("button[data-kind]"); if (!button) return; const box = button.closest(".verdict"); const out = box.querySelector(".result"); const buttons = box.querySelectorAll("button"); buttons.forEach((b) => (b.disabled = true)); out.textContent = "working..."; try { const res = await fetch("/verdict", { method: "POST", headers: { "Content-Type": "application/json", "X-Aite-Token": token }, body: JSON.stringify({ step: Number(box.dataset.step), kind: button.dataset.kind, note: box.querySelector(".note").value, attach: box.querySelector(".attach").checked }), }); const body = await res.json(); out.textContent = body.message || (res.ok ? "done" : "refused (" + res.status + ")"); } catch (err) { out.textContent = "could not reach aite: " + err.message; } buttons.forEach((b) => (b.disabled = false));});`;
function verdictBox(s: RunJsonStep): string { return `<div class="verdict" data-step="${esc(s.index)}"><label>Note <input type="text" class="note" maxlength="1000" placeholder="what you saw (optional)"></label><label><input type="checkbox" class="attach" checked> attach the screenshot to the issue (it may show addresses or handles)</label><p><button type="button" data-kind="app-broken">App is broken</button> <button type="button" data-kind="script-out-of-date">Script is out of date</button> <button type="button" data-kind="flaky">Flaky, retry</button></p><p class="result where" role="status"></p></div>`;}
export function renderReport(run: RunJson, shots: Record<string, string>, verdictUi?: VerdictUi): string { if (verdictUi !== undefined && (!NONCE.test(verdictUi.nonce) || !TOKEN.test(verdictUi.token))) throw new Error("the verdict page needs a nonce and a token of letters, digits, - and _"); const t = run.tests[0]; const steps = run.tests.flatMap((test, i) => test.steps.map((s) => ({ s, i }))); const by = (...statuses: RunJsonStep["status"][]) => steps.filter(({ s }) => statuses.includes(s.status)); const failed = by("failed"); const repaired = by("repaired"); const passed = by("passed", "passed-after-retry"); const skipped = by("skipped"); const warnings = run.tests.flatMap((test) => [...test.warnings.map((w) => ({ w, where: test.name })), ...test.steps.flatMap((s) => s.warnings.map((w) => ({ w, where: s.name })))]); const verdict = run.tests.every((test) => test.status === "passed") ? "PASSED" : "FAILED"; const name = t?.name ?? "run"; const counts: Array<[string, number, string]> = [["c-failed", failed.length, "failed"], ["c-passed", passed.length, "passed"], ["c-repaired", repaired.length, "repaired"], ["c-skipped", skipped.length, "skipped"], ["c-warnings", warnings.length, "warnings"], ["c-retries", run.summary.retries, "retries"]];
const attention = failed.length + repaired.length === 0 ? "" : `<section id="attention"><h2>Needs a look</h2>${failed.map(({ s, i }) => failedArticle(run, i, s, shots, verdictUi !== undefined)).join("")}${repaired.map(({ s, i }) => repairedArticle(run, i, s, shots)).join("")}</section>`; const notes = [ ...(run.environment ? [`<p class="where env">Environment changed since the last unattended run: Chrome ${esc(run.environment.chromeChanged.from)} before, ${esc(run.environment.chromeChanged.to)} now. A failure here may come from that, not from the app.</p>`] : []), ...(run.stopped ? [`<p class="where env">This run was stopped after its time budget of ${esc(run.stopped.budgetMs)} ms, before the script finished.</p>`] : []), ].join(""); const lock = run.lock === undefined ? "" : `<p class="lock where">Lock file ${esc(run.lock.path)}: ${esc(run.lock.status)} (${esc(run.lock.captured)} captured, ${esc(run.lock.reused)} reused, ${esc(run.lock.repaired)} repaired${run.lock.ignored > 0 ? `, ${esc(run.lock.ignored)} ignored` : ""})${run.lock.message !== undefined ? `: ${esc(run.lock.message)}` : ""}</p>`;
return `<!doctype html><html lang="en"><head><meta charset="utf-8">${verdictUi === undefined ? `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'">` : `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'nonce-${verdictUi.nonce}'; connect-src 'self'; form-action 'none'; base-uri 'none'"><meta name="aite-token" content="${verdictUi.token}">`}<meta name="viewport" content="width=device-width, initial-scale=1"><title>aite: ${esc(name)} - ${verdict}</title><style>${CSS}</style></head><body><main><header><h1>${esc(name)} <span class="badge ${verdict === "PASSED" ? "passed" : "failed"}">${verdict}</span></h1><p class="meta">${[t?.file ?? "", ...(t && t.covers.length > 0 ? [`covers ${t.covers.join(", ")}`] : []), `run ${run.runId}`, `Chrome ${run.chrome}`, `extension ${run.extension}`, `profile ${run.profile}`, run.target ?? "", `${run.durationMs} ms`].filter((x) => x !== "").map(esc).join(" · ")}</p></header><section id="summary"><ul>${counts.map(([cls, n, label]) => `<li class="${cls}"><b>${n}</b> ${label}</li>`).join("")}</ul>${lock}${notes}</section>${attention}${warnings.length > 0 ? `<section id="warnings"><h2>Warnings</h2><ul>${warnings.map(({ w, where }) => `<li>${esc(w.message)} <span class="where">(${esc(w.code)}, ${esc(where)})</span></li>`).join("")}</ul></section>` : ""}${skipped.length > 0 ? `<section id="skipped"><h2>Skipped</h2><p class="where">These did not run because an earlier step failed.</p><ul>${skipped.map(({ s }) => `<li>${esc(s.name)}</li>`).join("")}</ul></section>` : ""}${passed.length > 0 ? `<section id="passed"><h2>Passed</h2><details><summary>${passed.length} step${passed.length === 1 ? "" : "s"} passed</summary>${passed.map(({ s }) => commandsTable(s)).join("")}</details></section>` : ""}</main>${verdictUi !== undefined && failed.length > 0 ? `<script nonce="${verdictUi.nonce}">${VERDICT_SCRIPT}</script>` : ""}</body></html>`;}