import { flattenPages, type CanvasLike, type FlattenResult, type PageLike } from "../pdf/flatten"; import type { AssemblePageData } from "../pdf/render/jobs"; import type { GoldenMap } from "../pdf/testing/goldens"; import type { SelfTestFixture } from "../pdf/testing/selfTestFixtures"; /** * The Diagnostics self-test: renders fixtures through the app's real render client, rasterises and * reads them back with pdf.js, compares with the Node goldens, and times a 300 DPI flatten. Everything * environment-specific is injected, so the same checks run in the webview and under Node. */ export type CheckStatus = "pass" | "fail" | "info"; export interface Check { label: string; status: CheckStatus; detail?: string; ms?: number; } export interface Section { title: string; checks: Check[]; } export interface SelfTestReport { startedAt: string; sections: Section[]; done: boolean; /** No check failed (informational rows never fail). */ passed: boolean; } export interface SelfTestPage extends PageLike { getTextContent(): Promise<{ items: Array<{ str?: string }> }>; } export interface SelfTestDoc { numPages: number; getPage(n: number): Promise; close(): Promise; } export interface SelfTestRender { bytes: Uint8Array; pages: number; issues: Array<{ severity: string; message?: string }>; fingerprint: string; ms: number; } export interface SelfTestDeps { env(): { userAgent: string; isSecureContext: boolean; renderMode: string }; fixtures: SelfTestFixture[]; goldens: GoldenMap; /** Renders in the app's real client (the worker in the app). */ render(fixture: SelfTestFixture): Promise; openPdf(bytes: Uint8Array): Promise; createCanvas(width: number, height: number): CanvasLike; encodeJpeg(canvas: CanvasLike, quality: number): Promise; /** Wraps JPEG pages into an image-only PDF through the real client. */ assemble(pages: AssemblePageData[], title: string): Promise; /** Starts watching frame gaps; the returned function stops it and gives the longest gap in ms. */ startFrameMonitor(): () => number; yieldToUi?: () => Promise; now?: () => number; } /** Longest allowed main-thread stall while flattening (the plan's budget). */ export const MAX_FLATTEN_STALL_MS = 500; /** A rendered invoice page is never blank: at least this share of pixels must carry ink. */ export const MIN_INK_RATIO = 0.003; const squash = (s: string) => s.replace(/\s+/g, ""); const errText = (e: unknown) => (e instanceof Error ? e.message : String(e)); const short = (fp: string) => (fp.length > 16 ? `${fp.slice(0, 8)}…${fp.slice(-6)}` : fp); const mb = (n: number) => `${(n / 1048576).toFixed(2)} MB`; /** Share of non-white pixels of page 1 drawn at 1 px per point. */ async function inkRatioOfFirstPage(doc: SelfTestDoc, deps: SelfTestDeps): Promise { const page = await doc.getPage(1); const viewport = page.getViewport({ scale: 1 }); const w = Math.ceil(viewport.width); const h = Math.ceil(viewport.height); const canvas = deps.createCanvas(w, h); try { const ctx = canvas.getContext("2d") as { fillStyle: unknown; fillRect(x: number, y: number, w: number, h: number): void; getImageData(x: number, y: number, w: number, h: number): { data: ArrayLike }; }; ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, w, h); await page.render({ canvasContext: ctx, canvas, viewport, intent: "print" }).promise; const data = ctx.getImageData(0, 0, w, h).data; let ink = 0; for (let i = 0; i < data.length; i += 4) if (data[i] < 250 || data[i + 1] < 250 || data[i + 2] < 250) ink++; return ink / (w * h); } finally { canvas.width = 0; canvas.height = 0; } } async function pageTexts(doc: SelfTestDoc): Promise { const out: string[] = []; for (let n = 1; n <= doc.numPages; n++) { const content = await (await doc.getPage(n)).getTextContent(); out.push(content.items.map((i) => i.str ?? "").join(" ")); } return out; } export async function runSelfTest(deps: SelfTestDeps, onUpdate?: (report: SelfTestReport) => void): Promise { const now = deps.now ?? (() => performance.now()); const report: SelfTestReport = { startedAt: new Date().toISOString(), sections: [], done: false, passed: true }; const emit = () => { report.passed = report.sections.every((s) => s.checks.every((c) => c.status !== "fail")); onUpdate?.({ ...report, sections: report.sections.map((s) => ({ ...s, checks: [...s.checks] })) }); }; const section = (title: string): Section => { const s: Section = { title, checks: [] }; report.sections.push(s); emit(); return s; }; const add = (s: Section, check: Check) => { s.checks.push(check); emit(); }; /** Runs a check body that returns a Check, turning a throw into a failure. */ const timed = async (s: Section, label: string, body: () => Promise>) => { const t0 = now(); try { const r = await body(); add(s, { label, ms: Math.round(now() - t0), ...r }); } catch (e) { add(s, { label, status: "fail", detail: errText(e), ms: Math.round(now() - t0) }); } }; const env = section("Webview"); const info = deps.env(); add(env, { label: "User agent", status: "info", detail: info.userAgent }); add(env, { label: "Secure context", status: "info", detail: String(info.isSecureContext) }); const rendered = new Map(); for (const fixture of deps.fixtures) { const sec = section(fixture.label); const golden = deps.goldens[fixture.id]; let r: SelfTestRender | null = null; await timed(sec, "Render in the app's client", async () => { r = await deps.render(fixture); rendered.set(fixture.id, r); return { status: "pass", detail: `${r.pages} page${r.pages === 1 ? "" : "s"}, ${(r.bytes.byteLength / 1024).toFixed(0)} KB, ${r.ms} ms in the render client` }; }); if (!r) continue; const res: SelfTestRender = r; if (!golden) { add(sec, { label: "Golden", status: "fail", detail: `goldens.json has no entry for ${fixture.id}; regenerate it with UPDATE_GOLDENS=1` }); } else { add(sec, { label: "Page count", status: res.pages === golden.pages ? "pass" : "fail", detail: res.pages === golden.pages ? `${res.pages}` : `expected ${golden.pages}, got ${res.pages}`, }); } const errors = res.issues.filter((i) => i.severity === "error"); add(sec, { label: "Layout audit", status: errors.length === 0 ? "pass" : "fail", detail: errors.length === 0 ? `no errors, ${res.issues.length} warning${res.issues.length === 1 ? "" : "s"}` : errors.slice(0, 3).map((e) => e.message ?? "error").join("; "), }); let doc: SelfTestDoc | null = null; try { doc = await deps.openPdf(res.bytes); } catch (e) { add(sec, { label: "Open with pdf.js", status: "fail", detail: errText(e) }); } if (doc) { const opened = doc; await timed(sec, "Rasterise page 1", async () => { const ratio = await inkRatioOfFirstPage(opened, deps); return { status: ratio >= MIN_INK_RATIO ? "pass" : "fail", detail: `${(ratio * 100).toFixed(1)}% of pixels carry ink${ratio >= MIN_INK_RATIO ? "" : " (blank page?)"}` }; }); await timed(sec, "Text extraction", async () => { const texts = await pageTexts(opened); const flat = squash(texts.join(" ")); const wanted = golden ? [fixture.invoiceNumber, golden.totalText, golden.pageLabel] : [fixture.invoiceNumber, fixture.totalText, `Page 1 of ${res.pages}`]; const missing = wanted.filter((w) => !flat.includes(squash(w))); return { status: missing.length === 0 ? "pass" : "fail", detail: missing.length === 0 ? `found ${wanted.join(", ")}` : `missing ${missing.map((m) => `"${m}"`).join(", ")}` }; }); await opened.close().catch(() => {}); } if (golden) { const same = res.fingerprint === golden.fingerprint; add(sec, { label: "Layout fingerprint matches the Node golden", status: same ? "pass" : "fail", detail: same ? short(res.fingerprint) : `golden ${golden.fingerprint}\nthis webview ${res.fingerprint}\nThe layout differs from Node: text measuring or the JavaScript engine does not agree with the goldens.`, }); } } // Flatten timing on the longest rendered fixture. const multi = [...rendered.entries()].sort((a, b) => b[1].pages - a[1].pages)[0]; if (multi) { const [id, src] = multi; const fixture = deps.fixtures.find((f) => f.id === id)!; const sec = section(`300 DPI flatten (${fixture.label})`); const held: { flat: FlattenResult | null } = { flat: null }; const stop = deps.startFrameMonitor(); await timed(sec, `Flatten ${src.pages} page${src.pages === 1 ? "" : "s"}`, async () => { const doc = await deps.openPdf(src.bytes); let flat: FlattenResult; try { flat = await flattenPages({ pdf: doc as unknown as Parameters[0]["pdf"], createCanvas: deps.createCanvas, encodeJpeg: deps.encodeJpeg, yieldToUi: deps.yieldToUi, now, }); } finally { await doc.close().catch(() => {}); } held.flat = flat; const total = flat.pages.reduce((n, p) => n + p.jpeg.byteLength, 0); return { status: "pass", detail: flat.pages.map((p, i) => `page ${i + 1}: render ${p.renderMs} ms + encode ${p.encodeMs} ms, ${p.pxWidth}x${p.pxHeight}, ${mb(p.jpeg.byteLength)}`).join("\n") + `\ntotal ${mb(total)}`, }; }); const longest = Math.round(stop()); const done = held.flat; add(sec, { label: "Longest main-thread stall", status: !done ? "info" : longest <= MAX_FLATTEN_STALL_MS ? "pass" : "fail", detail: !done ? "not measured" : `${longest} ms (budget ${MAX_FLATTEN_STALL_MS} ms)`, }); if (done) { await timed(sec, "Assemble the image-only PDF", async () => { const out = await deps.assemble( done.pages.map((p) => ({ jpeg: p.jpeg, widthPt: p.widthPt, heightPt: p.heightPt })), fixture.invoiceNumber, ); const doc = await deps.openPdf(out); try { const texts = await pageTexts(doc); const textItems = texts.filter((t) => t.trim() !== "").length; const ok = doc.numPages === src.pages && textItems === 0; return { status: ok ? "pass" : "fail", detail: `${doc.numPages} page${doc.numPages === 1 ? "" : "s"}, ${textItems === 0 ? "no extractable text" : `${textItems} page(s) still have text`}, ${mb(out.byteLength)}` }; } finally { await doc.close().catch(() => {}); } }); } } // Known only now: the client falls back to the main thread on its first failed start. const renderMode = deps.env().renderMode; add(env, { label: "Render client", status: "info", detail: renderMode === "worker" ? "worker" : "main thread (the worker could not start)" }); report.done = true; emit(); return report; } export function reportToText(report: SelfTestReport): string { const lines = [`Voiced diagnostics ${report.startedAt}`, report.done ? (report.passed ? "RESULT: all checks passed" : "RESULT: FAILED") : "RESULT: running", ""]; for (const s of report.sections) { lines.push(`== ${s.title}`); for (const c of s.checks) { lines.push(`[${c.status.toUpperCase()}] ${c.label}${c.ms !== undefined ? ` (${c.ms} ms)` : ""}`); if (c.detail) for (const l of c.detail.split("\n")) lines.push(` ${l}`); } lines.push(""); } return lines.join("\n"); }