taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 61adc575d1d9a61db305b66d030d6cd1049d8eba
parent 4da3a1023b13ca36c1f6a59213cf11a98b8872db
Author: Florian Dold <dold@taler.net>
Date:   Sat,  5 Sep 2026 18:17:22 +0200

taler-harness: make integration test log auditing configurable

Diffstat:
Mpackages/taler-harness/README.md | 29+++++++++++++++++++----------
Mpackages/taler-harness/src/harness/harness.ts | 11++++++++---
Mpackages/taler-harness/src/harness/log-diagnostics.test.ts | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/index.ts | 9+++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 32++++++++++++++++++++++++++------
5 files changed, 171 insertions(+), 19 deletions(-)

diff --git a/packages/taler-harness/README.md b/packages/taler-harness/README.md @@ -121,14 +121,22 @@ before treating it as a product regression. ### Service warnings and errors -After every integration test, the harness parses structured `WARN`, `WARNING`, -and `ERROR` records from that test's `*-stderr.log` files (`WARN` is normalized -to `WARNING`). This includes the Taler/GNUnet layout, Libeufin's Kotlin logging +By default, after every integration test the harness parses structured `WARN`, +`WARNING`, and `ERROR` records from that test's `*-stderr.log` files (`WARN` is +normalized to `WARNING`). This includes the Taler/GNUnet layout, Libeufin's Kotlin logging layout, and diagnostics with a case-insensitive `ERROR:`, `WARNING:`, or -`WARN:` severity prefix. Any -unexpected record fails the test. Text such as `ERROR` in an INFO or TRACE -message does not count: the parser uses a structured severity field or prefix, -not a substring search. +`WARN:` severity prefix. Unexpected records are reported without failing the +test. Control this behavior with `taler-harness run-integrationtests --log-audit=MODE`: + +- `warn` (default): print unexpected entries and retain them in `results.json`, + without failing the test. Log scan errors are reported as warnings. +- `error`: unexpected entries or log scan errors fail an otherwise passing test. +- `no`: skip log auditing, including postmortem scans. Raw service log files are + still written. + +Assertions, crashes, and timeouts still fail tests in every mode. +Text such as `ERROR` in an INFO or TRACE message does not count: the parser uses +a structured severity field or prefix, not a substring search. First investigate a diagnostic and fix its cause or severity. If a test deliberately exercises the condition, declare an exact, bounded expectation @@ -150,9 +158,10 @@ justification. Catch-all file patterns, unanchored expressions, and global or sticky expressions are rejected. Keep dynamic expressions as narrow as possible and never add an expectation merely to make the suite green. -Diagnostics are printed even in quiet mode and retained in `results.json` with -their file and line number. The runner also performs a parent-side scan when a -worker crashes or times out, after giving the child time to flush its logs. +With auditing enabled, diagnostics are printed even in quiet mode and retained +in `results.json` with their file and line number. The runner also performs a +parent-side scan when a worker crashes or times out, after giving the child time +to flush its logs. Those entries are labeled as postmortem diagnostics rather than unexpected: the parent cannot recover expectations that existed only in the terminated worker, while the crash or timeout already makes the test fail. diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts @@ -3105,6 +3105,8 @@ export class MerchantService implements MerchantServiceInterface { type TestStatus = "pass" | "fail" | "skip"; +export type LogAuditMode = "error" | "warn" | "no"; + export interface TestRunResult { /** * Name of the test. @@ -3146,6 +3148,7 @@ export async function runTestWithState( testMain: (t: GlobalTestState) => Promise<void>, testName: string, linger: boolean = false, + logAudit: LogAuditMode = "warn", ): Promise<TestRunResult> { const startMs = new Date().getTime(); @@ -3243,12 +3246,12 @@ export async function runTestWithState( } } let unexpectedLogEntries: ServiceLogEntry[] | undefined; - if (!shouldLingerInTest()) { + if (logAudit !== "no" && !shouldLingerInTest()) { try { const entries = gc.readUnexpectedServiceLogs(); if (entries.length > 0) { unexpectedLogEntries = entries; - if (status === "pass") { + if (logAudit === "error" && status === "pass") { status = "fail"; reason = "unexpected WARNING/ERROR entries in service logs"; } @@ -3257,7 +3260,9 @@ export async function runTestWithState( const scanFailure = `service log scan failed: ${ error instanceof Error ? error.message : String(error) }`; - if (status === "pass") { + if (logAudit === "warn") { + logger.warn(scanFailure); + } else if (status === "pass") { status = "fail"; reason = scanFailure; } else { diff --git a/packages/taler-harness/src/harness/log-diagnostics.test.ts b/packages/taler-harness/src/harness/log-diagnostics.test.ts @@ -21,6 +21,7 @@ import path from "node:path"; import { test, type TestContext } from "node:test"; import { GlobalTestState, + LogAuditMode, readServiceLogEntries, runTestWithState, } from "./harness.js"; @@ -263,6 +264,8 @@ test("unexpected service diagnostics fail an otherwise passing test", async (t) ); }, "unexpected-service-log", + false, + "error", ); assert.equal(result.status, "fail"); @@ -293,8 +296,114 @@ test("service output is flushed before post-test scanning", async (t) => { await child.wait(); }, "service-log-flush", + false, + "error", ); assert.equal(result.status, "pass"); assert.equal(result.unexpectedLogEntries, undefined); }); + +for (const mode of [undefined, "warn"] as const) { + test(`unexpected diagnostics do not fail a test in ${mode ?? "default"} mode`, async (t) => { + const testDir = makeTestDir(t); + const result = await runTestWithState( + new GlobalTestState({ testDir }), + async () => { + fs.writeFileSync( + path.join(testDir, "service-stderr.log"), + "ERROR: unexpected diagnostic\n", + ); + }, + "warn-service-log", + false, + mode, + ); + assert.equal(result.status, "pass"); + assert.equal(result.reason, undefined); + assert.equal( + result.unexpectedLogEntries?.[0].message, + "unexpected diagnostic", + ); + }); +} + +test("no audit mode skips scanning and still shuts down services", async (t) => { + const testDir = makeTestDir(t); + const state = new GlobalTestState({ testDir }); + const scan = t.mock.method(state, "readUnexpectedServiceLogs", () => { + throw Error("scan must not run"); + }); + const shutdown = t.mock.method(state, "shutdown"); + const result = await runTestWithState( + state, + async () => { + fs.writeFileSync( + path.join(testDir, "service-stderr.log"), + "ERROR: broken\n", + ); + }, + "no-log-audit", + false, + "no", + ); + assert.equal(result.status, "pass"); + assert.equal(result.unexpectedLogEntries, undefined); + assert.equal(scan.mock.callCount(), 0); + assert.equal(shutdown.mock.callCount(), 1); + assert.equal( + fs.readFileSync(path.join(testDir, "service-stderr.log"), "utf8"), + "ERROR: broken\n", + ); +}); + +for (const mode of ["error", "warn", "no"] satisfies LogAuditMode[]) { + test(`actual failures remain failures in ${mode} mode`, async (t) => { + const testDir = makeTestDir(t); + const result = await runTestWithState( + new GlobalTestState({ testDir }), + async () => { + fs.writeFileSync( + path.join(testDir, "service-stderr.log"), + "ERROR: broken\n", + ); + throw Error("assertion failed"); + }, + "actual-failure", + false, + mode, + ); + assert.equal(result.status, "fail"); + assert.equal(result.reason, "assertion failed"); + assert.equal( + result.unexpectedLogEntries?.length ?? 0, + mode === "no" ? 0 : 1, + ); + }); + + for (const fails of [false, true]) { + test(`scan errors in ${mode} mode with a ${fails ? "failing" : "passing"} test`, async (t) => { + const state = new GlobalTestState({ testDir: makeTestDir(t) }); + t.mock.method(state, "readUnexpectedServiceLogs", () => { + throw Error("scan broken"); + }); + const result = await runTestWithState( + state, + async () => { + if (fails) throw Error("assertion failed"); + }, + "scan-error", + false, + mode, + ); + assert.equal(result.status, fails || mode === "error" ? "fail" : "pass"); + const reasons = fails ? ["assertion failed"] : []; + if (mode === "error") + reasons.push("service log scan failed: scan broken"); + assert.equal( + result.reason, + reasons.length ? reasons.join("; ") : undefined, + ); + }); + } +} diff --git a/packages/taler-harness/src/index.ts b/packages/taler-harness/src/index.ts @@ -2030,7 +2030,15 @@ talerHarnessCli .flag("reuseWorker", ["--reuse-worker"], { help: "Reuse one child process across tests (faster, less isolation).", }) + .maybeOption("logAudit", ["--log-audit"], clk.STRING, { + help: "Service log auditing: error (fail on unexpected entries), warn (report only, default), no (disable auditing).", + default: "warn", + }) .action(async (args) => { + const logAudit = args.runIntegrationtests.logAudit ?? "warn"; + if (logAudit !== "error" && logAudit !== "warn" && logAudit !== "no") { + throw Error("invalid --log-audit value: expected error, warn, or no"); + } const noTimeout = process.env["TALER_TEST_NO_TIMEOUT"] === "1" ? true : undefined; await runTests({ @@ -2044,6 +2052,7 @@ talerHarnessCli strictTodo: args.runIntegrationtests.strictTodo ?? false, noTimeout: noTimeout ?? args.runIntegrationtests.noTimeout, reuseWorker: args.runIntegrationtests.reuseWorker ?? false, + logAudit, testDir: args.runIntegrationtests.testDir, }); }); diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -28,6 +28,7 @@ import * as path from "node:path"; import url from "node:url"; import { GlobalTestState, + LogAuditMode, readServiceLogEntries, runTestWithState, shouldLingerInTest, @@ -547,6 +548,8 @@ const allTests: TestMainFunction[] = [ ]; export interface TestRunSpec { + /** Service log audit policy, defaulting to warn. */ + logAudit?: LogAuditMode; includePattern?: string; suiteSpec?: string; testDir?: string; @@ -617,6 +620,7 @@ function validateTestMetadata(testCases: TestMainFunction[]): void { interface RunTestChildInstruction { testName: string; testRootDir: string; + logAudit: LogAuditMode; } interface ReusableWorkerResultMessage { @@ -760,6 +764,7 @@ async function waitForWorkerClose( } export async function runTests(spec: TestRunSpec) { + const logAudit = spec.logAudit ?? "warn"; validateTestMetadata(allTests); if (spec.reuseWorker && shouldLingerInTest()) { throw Error("--reuse-worker cannot be combined with TALER_TEST_LINGER"); @@ -861,6 +866,7 @@ export async function runTests(spec: TestRunSpec) { const testInstr: RunTestChildInstruction = { testName, testRootDir, + logAudit, }; const myFilename = url.fileURLToPath(import.meta.url); @@ -1004,11 +1010,18 @@ export async function runTests(spec: TestRunSpec) { if (needsPostMortemLogScan) { await waitForWorkerClose(currentChild); - try { - result.postmortemLogEntries = readServiceLogEntries(testDir); - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - result.reason = `${result.reason}; service log scan failed: ${detail}`; + if (logAudit !== "no") { + try { + result.postmortemLogEntries = readServiceLogEntries(testDir); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + const scanFailure = `service log scan failed: ${detail}`; + if (logAudit === "warn") { + logger.warn(scanFailure); + } else { + result.reason = `${result.reason}; ${scanFailure}`; + } + } } } @@ -1173,6 +1186,7 @@ export function getTestInfo(): TestInfo[] { async function runChildInstruction({ testRootDir, testName, + logAudit, }: RunTestChildInstruction): Promise<TestRunResult> { const testMain = allTests.find((test) => getTestName(test) === testName); if (!testMain) { @@ -1182,7 +1196,13 @@ async function runChildInstruction({ const testDir = path.join(testRootDir, testName); logger.info(`running test ${testName}`); const gc = new GlobalTestState({ testDir }); - const testResult = await runTestWithState(gc, testMain, testName); + const testResult = await runTestWithState( + gc, + testMain, + testName, + false, + logAudit, + ); logger.info(`done test ${testName}: ${testResult.status}`); return testResult; }