import 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, "'"); 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 = { 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 `

${mark} ${esc(s.name)} ${where}

`; } function picture(s: RunJsonStep, shots: Record): string { const uri = s.screenshot !== undefined ? shots[s.screenshot] : undefined; if (uri !== undefined && PNG_URI.test(uri)) return `
the page after this step
`; return `

no screenshot for this step${s.screenshot !== undefined ? "" : " was taken"}

`; } 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 `

Likely layer: ${esc(d.likely.layer)} (${esc(d.likely.confidence)} confidence). ${esc(d.likely.summary)}

${ faults.length > 0 ? `
    ${faults.map((l) => `
  • ${esc(l.layer)}: ${esc(l.status)}${l.notes.length > 0 ? ` (${l.notes.map(esc).join("; ")})` : ""}
  • `).join("")}
` : "" }${fine.length > 0 ? `

Checked and fine: ${fine.join(", ")}

` : ""}
`; } function candidatesBlock(c: RunJsonCommand): string { if (!Array.isArray(c.candidates) || c.candidates.length === 0) return ""; const rows = (c.candidates as Array>).slice(0, 5).map((x) => { const d = (typeof x.descriptor === "object" && x.descriptor !== null ? x.descriptor : x) as Record; return `<${esc(text(d.tag) || "?")}> ${esc(text(d.text) || text(d.name))}${esc(text(d.xpath))}score ${esc(fixed(x.score))}`; }); return `${rows.join("")}
candidatewherescore
`; } function failedArticle(run: RunJson, testIndex: number, s: RunJsonStep, shots: Record, 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 `
${stepHeading(run, testIndex, s, "x", c?.line)}${picture(s, shots)}
wanted
${esc(wanted)}
found
${esc(found)}
why
${esc(REASONS[reason] ?? reason)}${reason !== "" ? ` (${esc(reason)})` : ""}
${ more.length > 0 ? `
${esc(more.join("\n"))}
` : "" }${c ? candidatesBlock(c) : ""}${c ? diagnosisBlock(c) : ""}${withVerdict ? verdictBox(s) : ""}
`; } function repairedArticle(run: RunJson, testIndex: number, s: RunJsonStep, shots: Record): 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 `
  • locator repaired (${esc(r.changed.join(", ") || "changed")}): ${esc(r.source)}: ${esc(how)}${r.changes.length > 0 ? `
      ${r.changes.map((x) => `
    • ${esc(x)}
    • `).join("")}
    ` : ""}
  • `; }) .join(""); return `
    ${stepHeading(run, testIndex, s, "~")}

    Passed, but the element was found by repair. A person should glance at it, and at the change to the lock file.

      ${items}
    ${picture(s, shots)}
    `; } function commandsTable(s: RunJsonStep): string { const rows = s.commands.map((c) => `${esc(c.file ? `${c.file}:${c.line}` : c.line)}${esc(c.source)}${c.profile !== undefined ? `${esc(c.profile)}` : ""}${esc(c.status)}${esc(c.durationMs)} ms`); const link = s.screenshot !== undefined && SHOT_PATH.test(s.screenshot) ? ` screenshot` : ""; return `
    ${esc(s.name)} ${esc(s.status)}, ${esc(s.durationMs)} ms${link ? "" : ""}${link}${rows.join("")}
    `; } /** 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 `

    `; } export function renderReport(run: RunJson, shots: Record, 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 ? "" : `

    Needs a look

    ${failed.map(({ s, i }) => failedArticle(run, i, s, shots, verdictUi !== undefined)).join("")}${repaired.map(({ s, i }) => repairedArticle(run, i, s, shots)).join("")}
    `; const notes = [ ...(run.environment ? [`

    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.

    `] : []), ...(run.stopped ? [`

    This run was stopped after its time budget of ${esc(run.stopped.budgetMs)} ms, before the script finished.

    `] : []), ].join(""); const lock = run.lock === undefined ? "" : `

    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)}` : ""}

    `; return ` ${verdictUi === undefined ? `` : ` `} aite: ${esc(name)} - ${verdict}

    ${esc(name)} ${verdict}

    ${[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(" · ")}

      ${counts.map(([cls, n, label]) => `
    • ${n} ${label}
    • `).join("")}
    ${lock}${notes}
    ${attention} ${warnings.length > 0 ? `

    Warnings

      ${warnings.map(({ w, where }) => `
    • ${esc(w.message)} (${esc(w.code)}, ${esc(where)})
    • `).join("")}
    ` : ""} ${skipped.length > 0 ? `

    Skipped

    These did not run because an earlier step failed.

      ${skipped.map(({ s }) => `
    • ${esc(s.name)}
    • `).join("")}
    ` : ""} ${passed.length > 0 ? `

    Passed

    ${passed.length} step${passed.length === 1 ? "" : "s"} passed${passed.map(({ s }) => commandsTable(s)).join("")}
    ` : ""}
    ${verdictUi !== undefined && failed.length > 0 ? `` : ""} `; }