From 65c38fd4c7725ecb036a799e2ec4eb85f74609da Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Sun, 4 Oct 2026 06:17:04 +0530 Subject: [PATCH] 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. --- src-tauri/src/lib.rs | 3 + src-tauri/src/selftest.rs | 46 ++++++++++ src/lib/selfTestE2e.ts | 152 ++++++++++++++++++++++++++++++++++ src/lib/selfTestSteps.test.ts | 33 ++++++++ src/lib/selfTestSteps.ts | 51 ++++++++++++ src/main.tsx | 3 + src/selftestBoot.ts | 86 +++++++++++++++++++ 7 files changed, 374 insertions(+) create mode 100644 src-tauri/src/selftest.rs create mode 100644 src/lib/selfTestE2e.ts create mode 100644 src/lib/selfTestSteps.test.ts create mode 100644 src/lib/selfTestSteps.ts create mode 100644 src/selftestBoot.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 97c200b..87f9d94 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod commands; mod db; mod gst; mod models; +mod selftest; use rusqlite::Connection; use std::path::{Path, PathBuf}; @@ -135,6 +136,8 @@ pub fn run() { commands::files::get_last_export_dir, commands::files::reveal_in_folder, commands::files::open_file, + selftest::selftest_config, + selftest::selftest_report, ]) .run(tauri::generate_context!()); diff --git a/src-tauri/src/selftest.rs b/src-tauri/src/selftest.rs new file mode 100644 index 0000000..d46e343 --- /dev/null +++ b/src-tauri/src/selftest.rs @@ -0,0 +1,46 @@ +//! Unattended end-to-end self-test hook. Inert unless `VOICED_SELFTEST_OUT` is set: the webview asks +//! for the report path at startup, runs its checks and hands the JSON back, and the process exits with +//! the verdict. This is the release gate that proves the real webview works, which Node tests cannot. +use std::io::Write; +use std::path::{Path, PathBuf}; + +const ENV_VAR: &str = "VOICED_SELFTEST_OUT"; + +fn configured_path() -> Option { + std::env::var_os(ENV_VAR).filter(|v| !v.is_empty()).map(PathBuf::from) +} + +fn write_report(path: &Path, json: &str) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("Could not create {}: {e}", parent.display()))?; + } + let mut file = std::fs::File::create(path).map_err(|e| format!("Could not write the report: {e}"))?; + file.write_all(json.as_bytes()) + .and_then(|()| file.sync_all()) + .map_err(|e| format!("Could not write the report: {e}")) +} + +#[tauri::command] +pub fn selftest_config() -> Option { + configured_path().map(|p| p.to_string_lossy().into_owned()) +} + +#[tauri::command] +pub fn selftest_report(json: String, ok: bool) -> Result<(), String> { + let path = configured_path().ok_or_else(|| "The self-test is not enabled".to_string())?; + write_report(&path, &json)?; + std::process::exit(if ok { 0 } else { 1 }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn writes_the_report_creating_folders() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("a/b/report.json"); + write_report(&path, "{\"ok\":true}").unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\"ok\":true}"); + } +} diff --git a/src/lib/selfTestE2e.ts b/src/lib/selfTestE2e.ts new file mode 100644 index 0000000..5f41a03 --- /dev/null +++ b/src/lib/selfTestE2e.ts @@ -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 { + 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(); + 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" }; + }); +} diff --git a/src/lib/selfTestSteps.test.ts b/src/lib/selfTestSteps.test.ts new file mode 100644 index 0000000..e9959c4 --- /dev/null +++ b/src/lib/selfTestSteps.test.ts @@ -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); + }); +}); diff --git a/src/lib/selfTestSteps.ts b/src/lib/selfTestSteps.ts new file mode 100644 index 0000000..7afb9e4 --- /dev/null +++ b/src/lib/selfTestSteps.ts @@ -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): Promise; +} + +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; +} diff --git a/src/main.tsx b/src/main.tsx index c8d81fc..2d0b5b0 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,9 +2,12 @@ import React from "react"; import { createRoot } from "react-dom/client"; import App from "./App"; import "./styles/carbon.scss"; +import { maybeRunSelfTest } from "./selftestBoot"; createRoot(document.getElementById("root") as HTMLElement).render( , ); + +void maybeRunSelfTest(); diff --git a/src/selftestBoot.ts b/src/selftestBoot.ts new file mode 100644 index 0000000..f9cb5c6 --- /dev/null +++ b/src/selftestBoot.ts @@ -0,0 +1,86 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { SelfTestReport } from "./lib/selfTest"; +import type { StepResult } from "./lib/selfTestSteps"; + +/** + * Unattended self-test mode, enabled by the VOICED_SELFTEST_OUT environment variable (the backend + * returns the report path, or null in normal use). It runs the diagnostics and an end-to-end invoice + * flow in this very webview, writes a JSON report and makes the process exit with the verdict. + */ + +const WATCHDOG_MS = 120_000; + +export async function maybeRunSelfTest(): Promise { + let reportPath: string | null = null; + try { + reportPath = await invoke("selftest_config"); + } catch { + return; // Not running inside Tauri (plain browser dev server). + } + if (!reportPath) return; + const path = reportPath; + + const start = performance.now(); + const startedAt = new Date().toISOString(); + let diagnostics: SelfTestReport | null = null; + let steps: StepResult[] = []; + let renderMode: () => string = () => "unknown"; + let finished = false; + + const finish = async (ok: boolean, error?: string) => { + if (finished) return; + finished = true; + const body = { + ok, + ...(error ? { error } : {}), + startedAt, + totalMs: Math.round(performance.now() - start), + userAgent: navigator.userAgent, + renderMode: renderMode(), + steps, + diagnostics, + }; + try { + await invoke("selftest_report", { json: JSON.stringify(body, null, 2), ok }); + } catch (e) { + console.error("self-test report could not be written", e); + } + }; + + const failWith = (what: string) => void finish(false, what); + const timer = window.setTimeout(() => { + if (finished) return; + void finish(false, "timeout"); + }, WATCHDOG_MS); + window.addEventListener("error", (e) => failWith(`uncaught error: ${e.message}`)); + window.addEventListener("unhandledrejection", (e) => failWith(`unhandled rejection: ${String(e.reason)}`)); + + try { + const [{ createStepLog }, { runSelfTest }, { browserSelfTestDeps }, { runE2eSteps }, { getRenderClient }] = await Promise.all([ + import("./lib/selfTestSteps"), + import("./lib/selfTest"), + import("./lib/selfTestBrowser"), + import("./lib/selfTestE2e"), + import("./lib/renderClient"), + ]); + renderMode = () => getRenderClient().mode; + const log = createStepLog(); + steps = log.steps; + + await log.run("diagnostics: render, rasterise, fingerprint, flatten", async () => { + const report = await runSelfTest(browserSelfTestDeps(), (r) => { + diagnostics = r; + }); + diagnostics = report; + const failed = report.sections.flatMap((s) => s.checks.filter((c) => c.status === "fail").map((c) => `${s.title} / ${c.label}: ${c.detail ?? ""}`)); + return { ok: report.passed, detail: failed.length ? failed.join("\n") : "all checks passed" }; + }); + await runE2eSteps(log, path); + + window.clearTimeout(timer); + await finish(log.steps.every((s) => s.ok)); + } catch (e) { + window.clearTimeout(timer); + await finish(false, e instanceof Error ? `${e.name}: ${e.message}\n${e.stack ?? ""}` : String(e)); + } +}