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.
290 lines
12 KiB
TypeScript
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");
|
|
}
|