Add unattended end-to-end self-test mode for the production webview

VOICED_SELFTEST_OUT runs the Diagnostics checks plus an issue, archive,
export (searchable, flattened, re-render), cancel and archive-immutability
flow through the real IPC, writes a JSON report and exits. Inert without
the variable; doubles as the per-release gate on installed builds.
This commit is contained in:
2026-10-04 06:17:04 +05:30
parent 98e9e5860f
commit aec24be680
7 changed files with 374 additions and 0 deletions
+152
View File
@@ -0,0 +1,152 @@
import { api } from "./api";
import { createExportDeps } from "./exportDeps";
import { exportInvoice, issueAndArchive } from "./exportFlow";
import { parsePrefs } from "../pdf/model/prefs";
import { bytesEqual, dirOf, type StepLog } from "./selfTestSteps";
import type { BankAccount, Invoice, InvoiceInput, Settings } from "./types";
/**
* The end-to-end invoice flow through the real IPC, run against the fresh database the self-test
* launches with (it seeds the unregistered vendor, a default bank and an active series).
*/
const today = () => {
const d = new Date();
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
};
const hex = (bytes: Uint8Array) => Array.from(bytes.slice(0, 5), (b) => b.toString(16).padStart(2, "0")).join("");
const kb = (n: number) => `${(n / 1024).toFixed(1)} KB`;
export async function runE2eSteps(log: StepLog, reportPath: string): Promise<void> {
const outDir = dirOf(reportPath);
const searchablePath = `${outDir}/e2e-searchable.pdf`;
const flattenedPath = `${outDir}/e2e-flattened.pdf`;
let s!: Settings;
let bank!: BankAccount;
let invoice: Invoice | null = null;
const loaded = await log.run("e2e: get settings and default bank", async () => {
s = await api.getSettings();
const banks = await api.listBanks();
const found = banks.find((b) => b.id === s.defaultBankId) ?? banks[0];
if (!found) throw new Error("the fresh database has no bank account");
bank = found;
return `${s.vendorName}, ${s.gstRegistration}, state ${s.vendorStateCode}, bank "${bank.label}"`;
});
if (!loaded.ok) return;
const prefs = parsePrefs(s.renderPrefs);
const input: InvoiceInput = {
invoiceDate: today(),
dueDate: "",
clientId: null,
clientName: "Self-test Client Pvt Ltd",
clientAddress: "42 Example Road, Indiranagar, Bengaluru - 560038",
clientGstin: "",
poNumber: "PO-SELFTEST",
placeOfSupplyStateCode: "",
discount: 0,
taxType: s.gstRegistration === "unregistered" ? "none" : s.defaultTaxType,
taxRate: s.gstRegistration === "unregistered" ? 0 : s.defaultTaxRate,
reverseCharge: false,
bankAccountId: bank.id,
signaturePath: null,
notes: "Self-test invoice",
saveClient: false,
draftId: null,
items: [
{ description: "Voice-over, 60 second spot", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 4500, sortOrder: 0 },
{ description: "Studio session", mode: "rate", rate: 1200, unit: "hour", quantity: 2, amount: 0, sortOrder: 1 },
{ description: "Revision round", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 850.5, sortOrder: 2 },
],
};
const deps = createExportDeps({ settings: s, bank });
// What the export wrote, per path, so the checks need no way to read arbitrary files back.
const written = new Map<string, Uint8Array>();
const exportWith = (mode: "searchable" | "flattened", source: "original" | "rerender", path: string) =>
exportInvoice(
{ invoice: inv, mode, source },
{
...deps,
save: async () => path,
writeExportFile: async (p, bytes) => {
written.set(p, bytes);
return deps.writeExportFile(p, bytes);
},
},
);
let inv!: Invoice;
const issued = await log.run("e2e: issue and archive", async () => {
const outcome = await issueAndArchive(input, prefs, deps);
invoice = outcome.invoice;
return {
ok: outcome.archived,
detail: `${outcome.invoice.number} (id ${outcome.invoice.id}), total ${outcome.invoice.total}, archived=${outcome.archived}${outcome.error ? `, error: ${outcome.error}` : ""}`,
};
});
if (!issued.ok || !invoice) return;
inv = invoice as Invoice;
await log.run("e2e: archive status", async () => {
const st = await api.archiveStatus(inv.id);
const matches = st.archived && st.sha256 === inv.archivedPdfSha256 && st.fingerprint === inv.archivedFingerprint;
return { ok: matches, detail: `archived=${st.archived}, sha ${st.sha256?.slice(0, 12)}, fingerprint ${st.fingerprint?.slice(0, 12)}, matches issue result: ${matches}` };
});
await log.run("e2e: export searchable original", async () => {
const r = await exportWith("searchable", "original", searchablePath);
return { ok: r?.path === searchablePath, detail: `wrote ${r?.path}` };
});
await log.run("e2e: export flattened original", async () => {
const r = await exportWith("flattened", "original", flattenedPath);
return { ok: r?.path === flattenedPath, detail: `wrote ${r?.path}` };
});
await log.run("e2e: export searchable re-render", async () => {
const r = await exportWith("searchable", "rerender", `${outDir}/e2e-rerender.pdf`);
return { ok: !!r && r.identicalToIssued !== undefined, detail: `identicalToIssued=${r?.identicalToIssued}` };
});
await log.run("e2e: archive bytes equal the searchable export", async () => {
const archived = await api.readArchive(inv.id);
const exported = written.get(searchablePath);
const eq = !!exported && bytesEqual(archived, exported);
return { ok: eq, detail: `${kb(archived.byteLength)} header ${hex(archived)}, equal to export file: ${eq}` };
});
await log.run("e2e: last export directory", async () => {
const dir = await api.getLastExportDir();
return { ok: dir === outDir, detail: `${dir} (expected ${outDir})` };
});
await log.run("e2e: cancel keeps archive readable", async () => {
const before = await api.readArchive(inv.id);
const cancelled = await api.cancelInvoice(inv.id, "self-test");
const after = await api.readArchive(inv.id);
const st = await api.archiveStatus(inv.id);
return {
ok: cancelled.status === "cancelled" && st.archived && bytesEqual(before, after),
detail: `status ${cancelled.status}, archive still ${kb(after.byteLength)}, unchanged: ${bytesEqual(before, after)}`,
};
});
await log.run("e2e: archiving different bytes is refused", async () => {
const other = new TextEncoder().encode("%PDF-1.4\n% a different file\n");
const before = await api.readArchive(inv.id);
try {
await api.archivePdf(inv.id, other, "tampered");
} catch (e) {
const msg = String(e);
const after = await api.readArchive(inv.id);
const intact = bytesEqual(after, before);
return { ok: /cannot be replaced/.test(msg) && intact, detail: `refused: ${msg}; archive unchanged: ${intact}` };
}
return { ok: false, detail: "the replacement was accepted" };
});
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { bytesEqual, createStepLog, dirOf } from "./selfTestSteps";
describe("createStepLog", () => {
it("records passes, expectation failures and throws without throwing itself", async () => {
let t = 0;
const log = createStepLog(() => (t += 10));
await log.run("a", async () => "fine");
await log.run("b", async () => ({ ok: false, detail: "wrong" }));
await log.run("c", async () => {
throw new Error("boom");
});
expect(log.steps).toEqual([
{ name: "a", ok: true, ms: 10, detail: "fine" },
{ name: "b", ok: false, ms: 10, detail: "wrong" },
{ name: "c", ok: false, ms: 10, detail: "Error: boom" },
]);
});
});
describe("dirOf / bytesEqual", () => {
it("splits posix and windows paths", () => {
expect(dirOf("/tmp/x/report.json")).toBe("/tmp/x");
expect(dirOf("C:\\out\\report.json")).toBe("C:\\out");
expect(dirOf("/report.json")).toBe("/");
expect(dirOf("report.json")).toBe("");
});
it("compares bytes", () => {
expect(bytesEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2]))).toBe(true);
expect(bytesEqual(new Uint8Array([1, 2]), new Uint8Array([1, 3]))).toBe(false);
expect(bytesEqual(new Uint8Array([1]), new Uint8Array([1, 2]))).toBe(false);
});
});
+51
View File
@@ -0,0 +1,51 @@
/** Step log of the unattended end-to-end self-test: one entry per step with its verdict and timing. */
export interface StepResult {
name: string;
ok: boolean;
ms: number;
detail: string;
}
export interface StepLog {
steps: StepResult[];
/**
* Runs one step. The body returns its detail text, or throws to fail. A body may also return
* `{ ok: false, detail }` for a failed expectation. Never throws; resolves to the recorded result.
*/
run(name: string, body: () => Promise<string | { ok: boolean; detail: string }>): Promise<StepResult>;
}
const errText = (e: unknown) => (e instanceof Error ? `${e.name}: ${e.message}` : String(e));
export function createStepLog(now: () => number = () => performance.now()): StepLog {
const steps: StepResult[] = [];
return {
steps,
async run(name, body) {
const t0 = now();
let result: StepResult;
try {
const r = await body();
const { ok, detail } = typeof r === "string" ? { ok: true, detail: r } : r;
result = { name, ok, ms: Math.round(now() - t0), detail };
} catch (e) {
result = { name, ok: false, ms: Math.round(now() - t0), detail: errText(e) };
}
steps.push(result);
return result;
},
};
}
/** The directory part of an absolute path, whichever separator it uses. */
export function dirOf(path: string): string {
const i = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
return i <= 0 ? path.slice(0, i + 1) : path.slice(0, i);
}
export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.byteLength !== b.byteLength) return false;
for (let i = 0; i < a.byteLength; i++) if (a[i] !== b[i]) return false;
return true;
}