Files
Voiced/src/lib/selfTest.ts
T
xavierk b6d999b3e8 Add searchable and flattened export, history re-export and diagnostics self-test
issueAndArchive keeps an issued invoice even when rendering or archiving
fails (Issued - not archived, with a Render & archive action). The export
menu offers a searchable PDF or a 300 DPI image-only PDF whose metadata is
the invoice number alone; flatten failures offer Retry or searchable export
and never silently lower the resolution. History re-exports the original
as issued by default and warns when a re-render's layout fingerprint
differs. The render client gains a FIFO assemble lane that previews cannot
supersede. A Diagnostics panel runs fixtures in the real worker and
compares against goldens generated from Node.
2026-10-04 06:11:23 +05:30

290 lines
12 KiB
TypeScript

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<SelfTestPage>;
close(): Promise<void>;
}
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<SelfTestRender>;
openPdf(bytes: Uint8Array): Promise<SelfTestDoc>;
createCanvas(width: number, height: number): CanvasLike;
encodeJpeg(canvas: CanvasLike, quality: number): Promise<Uint8Array>;
/** Wraps JPEG pages into an image-only PDF through the real client. */
assemble(pages: AssemblePageData[], title: string): Promise<Uint8Array>;
/** Starts watching frame gaps; the returned function stops it and gives the longest gap in ms. */
startFrameMonitor(): () => number;
yieldToUi?: () => Promise<void>;
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<number> {
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<number> };
};
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<string[]> {
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<SelfTestReport> {
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<Omit<Check, "label">>) => {
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<string, SelfTestRender>();
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<typeof flattenPages>[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");
}