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:
@@ -2,6 +2,7 @@ mod commands;
|
|||||||
mod db;
|
mod db;
|
||||||
mod gst;
|
mod gst;
|
||||||
mod models;
|
mod models;
|
||||||
|
mod selftest;
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -135,6 +136,8 @@ pub fn run() {
|
|||||||
commands::files::get_last_export_dir,
|
commands::files::get_last_export_dir,
|
||||||
commands::files::reveal_in_folder,
|
commands::files::reveal_in_folder,
|
||||||
commands::files::open_file,
|
commands::files::open_file,
|
||||||
|
selftest::selftest_config,
|
||||||
|
selftest::selftest_report,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!());
|
.run(tauri::generate_context!());
|
||||||
|
|
||||||
|
|||||||
@@ -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<PathBuf> {
|
||||||
|
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<String> {
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@ import React from "react";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import "./styles/carbon.scss";
|
import "./styles/carbon.scss";
|
||||||
|
import { maybeRunSelfTest } from "./selftestBoot";
|
||||||
|
|
||||||
createRoot(document.getElementById("root") as HTMLElement).render(
|
createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
void maybeRunSelfTest();
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
let reportPath: string | null = null;
|
||||||
|
try {
|
||||||
|
reportPath = await invoke<string | null>("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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user