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.
This commit is contained in:
2026-10-04 06:11:23 +05:30
parent dff03ea210
commit b6d999b3e8
30 changed files with 2727 additions and 154 deletions
+151 -1
View File
@@ -9,7 +9,7 @@ import {
WorkerUnavailable,
type WorkerLike,
} from "./client";
import type { RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs";
import type { AssembleJob, AssembleResult, RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs";
class FakeWorker implements WorkerLike {
onmessage: WorkerLike["onmessage"] = null;
@@ -31,8 +31,15 @@ class FakeWorker implements WorkerLike {
reply(id: number) {
this.emit({ type: "result", result: result(id) });
}
replyAssembled(id: number) {
this.emit({ type: "assembled", result: assembled(id) });
}
}
const assembled = (id: number): AssembleResult => ({ id, bytes: new Uint8Array([id]), ms: 3 });
const assembleJob = (id: number): AssembleJob => ({ id, title: "T", pages: [{ jpeg: new Uint8Array([1]), widthPt: 10, heightPt: 10 }] });
const kinds = (w: FakeWorker) => w.sent.map((m) => `${m.type}:${m.job.id}`);
const result = (id: number): RenderResult => ({
id,
bytes: new Uint8Array([id]),
@@ -190,6 +197,149 @@ describe("RenderClient", () => {
});
});
describe("assemble lane", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("queues behind a render in flight without superseding it", async () => {
const { client, workers } = setup();
const r1 = client.render(job(1));
workers[0].ready();
const a = client.assemble(assembleJob(2));
expect(kinds(workers[0])).toEqual(["render:1"]);
workers[0].reply(1);
expect((await r1).id).toBe(1);
expect(kinds(workers[0])).toEqual(["render:1", "assemble-images:2"]);
workers[0].replyAssembled(2);
expect((await a).id).toBe(2);
});
it("runs ahead of a render that was queued before it", async () => {
const { client, workers } = setup();
const r1 = client.render(job(1));
workers[0].ready();
const r3 = client.render(job(3)); // supersedes r1, waits for the worker
const a = client.assemble(assembleJob(2));
expect(await settled(r1)).toBeInstanceOf(Superseded);
workers[0].reply(1);
expect(kinds(workers[0])).toEqual(["render:1", "assemble-images:2"]);
workers[0].replyAssembled(2);
expect((await a).id).toBe(2);
expect(kinds(workers[0])).toEqual(["render:1", "assemble-images:2", "render:3"]);
workers[0].reply(3);
expect((await r3).id).toBe(3);
});
it("is not superseded by renders that arrive while it runs, and the newest render still wins afterwards", async () => {
const { client, workers } = setup();
const a = client.assemble(assembleJob(1));
workers[0].ready();
const r2 = client.render(job(2));
const r3 = client.render(job(3));
expect(await settled(r2)).toBeInstanceOf(Superseded);
workers[0].replyAssembled(1);
expect((await a).id).toBe(1);
expect(kinds(workers[0])).toEqual(["assemble-images:1", "render:3"]);
workers[0].reply(3);
expect((await r3).id).toBe(3);
});
it("runs several assembles in order and a render never rejects them", async () => {
const { client, workers } = setup();
const r0 = client.render(job(0));
workers[0].ready();
workers[0].reply(0);
await r0;
const a = client.assemble(assembleJob(1));
const b = client.assemble(assembleJob(2));
void client.render(job(3)).catch(() => {});
void client.render(job(4)).catch(() => {});
workers[0].replyAssembled(1);
expect((await a).id).toBe(1);
expect(kinds(workers[0]).slice(-1)).toEqual(["assemble-images:2"]);
workers[0].replyAssembled(2);
expect((await b).id).toBe(2);
});
it("surfaces an assemble error from the worker and keeps serving", async () => {
const { client, workers } = setup();
const a = client.assemble(assembleJob(1));
workers[0].ready();
workers[0].emit({ type: "error", id: 1, message: "bad jpeg" });
const err = await settled(a);
expect(err).toBeInstanceOf(RenderFailed);
expect((err as Error).message).toBe("bad jpeg");
const b = client.assemble(assembleJob(2));
workers[0].replyAssembled(2);
expect((await b).id).toBe(2);
});
it("a render result cannot settle a running assemble with the same id", async () => {
const { client, workers } = setup();
const a = client.assemble(assembleJob(5));
workers[0].ready();
workers[0].reply(5);
let done = false;
void settled(a).then(() => (done = true));
await Promise.resolve();
expect(done).toBe(false);
workers[0].replyAssembled(5);
expect((await a).id).toBe(5);
});
it("dispose rejects queued assembles", async () => {
const { client, workers } = setup();
void client.render(job(1)).catch(() => {});
workers[0].ready();
const a = client.assemble(assembleJob(2));
client.dispose();
expect(String(await settled(a))).toContain("disposed");
});
it("the main-thread client keeps the same lanes", async () => {
const gates: Array<() => void> = [];
const seen: string[] = [];
const client = createMainThreadClient(
(j) =>
new Promise((resolve) => {
seen.push(`render:${j.id}`);
gates.push(() => resolve(result(j.id)));
}),
(j) =>
new Promise((resolve) => {
seen.push(`assemble:${j.id}`);
gates.push(() => resolve(assembled(j.id)));
}),
);
const r1 = client.render(job(1));
const a = client.assemble(assembleJob(2));
gates[0]();
expect((await r1).id).toBe(1);
const r3 = client.render(job(3));
await Promise.resolve();
await Promise.resolve();
expect(seen).toEqual(["render:1", "assemble:2"]);
gates[1]();
expect((await a).id).toBe(2);
await Promise.resolve();
await Promise.resolve();
expect(seen).toEqual(["render:1", "assemble:2", "render:3"]);
gates[2]();
expect((await r3).id).toBe(3);
});
it("the auto client assembles on the main thread when the worker cannot start", async () => {
const client = createAutoClient({
workerFactory: () => {
throw new Error("no worker");
},
mainAssembleExecutor: async (j) => assembled(j.id),
});
expect((await client.assemble(assembleJob(9))).id).toBe(9);
expect(client.mode).toBe("main");
});
});
describe("main-thread client and fallback", () => {
it("runs jobs serially with latest-wins", async () => {
const gates: Array<() => void> = [];