commit 3db1be2a73f94e27d4085ac751457546624204ff parent 95edbce372bfc236d6ce3430184817961e2a5422 Author: Florian Dold <dold@taler.net> Date: Tue, 1 Sep 2026 19:53:06 +0200 taler-harness: reduce integration test process overhead Diffstat:
23 files changed, 551 insertions(+), 371 deletions(-)
diff --git a/packages/taler-harness/README.md b/packages/taler-harness/README.md @@ -101,6 +101,24 @@ Useful environment: `TALER_TEST_TIMEOUT`, `TALER_TEST_NO_TIMEOUT=1`, `TALER_TEST_LINGER=1` to keep the services up after a test (it waits on stdin, so hold that open), and `NODE_OPTIONS=--enable-source-maps`. +### Reusing the test worker + +By default, every test gets a fresh Node.js worker as well as fresh services, +directory and database. Starting Node and loading the harness bundle is +measurable for short tests. A local performance run can keep the JavaScript +worker loaded with: + +``` +taler-harness run-integrationtests --reuse-worker +``` + +The runner still creates and tears down every test's services, directory and +database. It also restores the worker's environment and working directory and +removes the fatal-event listeners installed for the finished test. Module +globals still share a process, however. Thus this mode deliberately provides +less isolation than the default; rerun a failure without `--reuse-worker` +before treating it as a product regression. + ## Headless Web Integration test 1) First you need the browsers that you are going to use to test diff --git a/packages/taler-harness/src/harness/fake-challenger.ts b/packages/taler-harness/src/harness/fake-challenger.ts @@ -29,6 +29,7 @@ import { respondJson, splitInTwoAt, } from "./http-server.js"; +import type { GlobalTestState } from "./harness.js"; const logger = new Logger("fake-challenger.ts"); @@ -41,18 +42,21 @@ export interface TestfakeChallengerService { /** * Testfake for the kyc service that the exchange talks to. */ -export async function startFakeChallenger(options: { - port: number; - addressType: string; - /** - * "expires" reported by /info, i.e. how long the validated address stays - * valid. Real challenger computes this as last_tx_time + - * VALIDATION_EXPIRATION, so it can be much closer than the configured - * lifetime, or already past, for an address validated a while ago. - * Defaults to one day from now. - */ - addressExpires?: TalerProtocolTimestamp; -}): Promise<TestfakeChallengerService> { +export async function startFakeChallenger( + t: GlobalTestState, + options: { + port: number; + addressType: string; + /** + * "expires" reported by /info, i.e. how long the validated address stays + * valid. Real challenger computes this as last_tx_time + + * VALIDATION_EXPIRATION, so it can be much closer than the configured + * lifetime, or already past, for an address validated a while ago. + * Defaults to one day from now. + */ + addressExpires?: TalerProtocolTimestamp; + }, +): Promise<TestfakeChallengerService> { let nextNonceId = 1; const addressType = options.addressType; @@ -172,10 +176,19 @@ export async function startFakeChallenger(options: { } }); await new Promise<void>((resolve, reject) => { - server.listen(options.port, () => resolve()); + server.once("error", reject); + server.listen(options.port, () => { + server.removeListener("error", reject); + resolve(); + }); }); + t.servers.push(server); return { stop() { + const index = t.servers.indexOf(server); + if (index !== -1) { + t.servers.splice(index, 1); + } server.close(); }, fakeVerification(nonce: string, address: Record<string, string>): void { diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts @@ -2805,22 +2805,25 @@ export async function runTestWithState( process.on("SIGINT", handleSignal); process.on("SIGTERM", handleSignal); - process.on("unhandledRejection", (reason: unknown, promise: any) => { + const handleUnhandledRejection = (reason: unknown, promise: any) => { logger.warn( `**** received unhandled rejection (${reason}), terminating test ${testName}`, ); logger.warn(`reason type: ${typeof reason}`); gc.shutdownSync(); process.exit(1); - }); - process.on("uncaughtException", (error, origin) => { + }; + const handleUncaughtException = (error: Error, origin: string) => { logger.warn( `**** received uncaught exception (${error}), terminating test ${testName}`, ); console.warn("stack", error.stack); gc.shutdownSync(); process.exit(1); - }); + }; + + process.on("unhandledRejection", handleUnhandledRejection); + process.on("uncaughtException", handleUncaughtException); try { logger.info("running test in directory", gc.testDir); @@ -2864,7 +2867,14 @@ export async function runTestWithState( fs.appendFileSync(steps, `FAIL ${(e as any).message}\n`); status = "fail"; } finally { - await gc.shutdown(); + try { + await gc.shutdown(); + } finally { + process.removeListener("SIGINT", handleSignal); + process.removeListener("SIGTERM", handleSignal); + process.removeListener("unhandledRejection", handleUnhandledRejection); + process.removeListener("uncaughtException", handleUncaughtException); + } } const afterMs = new Date().getTime(); return { diff --git a/packages/taler-harness/src/harness/lifecycle.test.ts b/packages/taler-harness/src/harness/lifecycle.test.ts @@ -0,0 +1,87 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import assert from "node:assert"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { startFakeChallenger } from "./fake-challenger.js"; +import { GlobalTestState, runTestWithState } from "./harness.js"; + +test("test lifecycle removes its process listeners", async (t) => { + const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "harness-lifecycle-")); + t.after(() => fs.rmSync(testDir, { recursive: true, force: true })); + + const events = [ + "SIGINT", + "SIGTERM", + "unhandledRejection", + "uncaughtException", + ] as const; + const countsBefore = events.map((event) => process.listenerCount(event)); + + for (let iteration = 0; iteration < 2; iteration++) { + const result = await runTestWithState( + new GlobalTestState({ testDir }), + async () => undefined, + `listener-cleanup-${iteration}`, + ); + assert.equal(result.status, "pass"); + } + + assert.deepEqual( + events.map((event) => process.listenerCount(event)), + countsBefore, + ); +}); + +test("test lifecycle closes fake Challenger servers", async (t) => { + const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "harness-lifecycle-")); + t.after(() => fs.rmSync(testDir, { recursive: true, force: true })); + + let port: number | undefined; + const firstState = new GlobalTestState({ testDir }); + const firstResult = await runTestWithState( + firstState, + async (testState) => { + await startFakeChallenger(testState, { + port: 0, + addressType: "postal-ch", + }); + const address = testState.servers[0]?.address(); + assert(address && typeof address !== "string"); + port = address.port; + }, + "fake-challenger-cleanup-first", + ); + assert.equal(firstResult.status, "pass"); + assert.equal(firstState.servers[0]?.listening, false); + const reboundPort = port; + assert(reboundPort !== undefined); + + const secondResult = await runTestWithState( + new GlobalTestState({ testDir }), + async (testState) => { + await startFakeChallenger(testState, { + port: reboundPort, + addressType: "postal-ch", + }); + }, + "fake-challenger-cleanup-second", + ); + assert.equal(secondResult.status, "pass"); +}); diff --git a/packages/taler-harness/src/harness/tops.ts b/packages/taler-harness/src/harness/tops.ts @@ -1044,11 +1044,11 @@ export async function setupMeasuresTestEnvironment( bank, } = await createTopsEnvironment(t); - const challengerPostal = await startFakeChallenger({ + const challengerPostal = await startFakeChallenger(t, { port: 6001, addressType: "postal-ch", }); - const challengerSms = await startFakeChallenger({ + const challengerSms = await startFakeChallenger(t, { port: 6002, addressType: "phone", }); diff --git a/packages/taler-harness/src/index.ts b/packages/taler-harness/src/index.ts @@ -2024,6 +2024,9 @@ talerHarnessCli .flag("noTimeout", ["--no-timeout"], { help: "Do not time out tests.", }) + .flag("reuseWorker", ["--reuse-worker"], { + help: "Reuse one child process across tests (faster, less isolation).", + }) .action(async (args) => { const noTimeout = process.env["TALER_TEST_NO_TIMEOUT"] === "1" ? true : undefined; @@ -2037,6 +2040,7 @@ talerHarnessCli includeExperimental: args.runIntegrationtests.experimental ?? false, strictTodo: args.runIntegrationtests.strictTodo ?? false, noTimeout: noTimeout ?? args.runIntegrationtests.noTimeout, + reuseWorker: args.runIntegrationtests.reuseWorker ?? false, testDir: args.runIntegrationtests.testDir, }); }); diff --git a/packages/taler-harness/src/integrationtests/test-kyc-merchant-wallet-reuse.ts b/packages/taler-harness/src/integrationtests/test-kyc-merchant-wallet-reuse.ts @@ -43,7 +43,7 @@ export async function runKycMerchantWalletReuseTest(t: GlobalTestState) { merchantAdminAccessToken, } = await createTopsEnvironment(t); - await startFakeChallenger({ + await startFakeChallenger(t, { port: 6001, addressType: "postal-ch", }); diff --git a/packages/taler-harness/src/integrationtests/test-merchant-bank-bad-wire-target.ts b/packages/taler-harness/src/integrationtests/test-merchant-bank-bad-wire-target.ts @@ -1,92 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2020 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * Imports. - */ -import { - Duration, - TalerExchangeHttpClient, - TalerMerchantApi, - TalerMerchantInstanceHttpClient, - j2s, - succeedOrThrow, -} from "@gnu-taler/taler-util"; -import { createSimpleTestkudosEnvironmentV3 } from "../harness/environments.js"; -import { GlobalTestState, waitMs } from "../harness/harness.js"; - -/** - * Verify that a merchant IBAN account is reported as unsupported when the - * exchange only supports x-taler-bank wire accounts. - */ -export async function runMerchantBankBadWireTargetTest(t: GlobalTestState) { - // Set up test environment - - const { exchange, merchant, merchantAdminAccessToken } = - await createSimpleTestkudosEnvironmentV3(t); - - // Create instance with bad wire target. - // x-taler-bank will work but iban doesn't since - // exchange only allows x-taler-bank - const { accessToken } = await merchant.addInstanceWithWireAccount( - { - id: "minst2", - name: "minst2", - // paytoUris: [`payto://x-taler-bank/localhost/2?receiver-name=random`], - paytoUris: [`payto://iban/DE1231231231?receiver-name=random`], - defaultWireTransferDelay: Duration.toTalerProtocolDuration( - Duration.fromSpec({ minutes: 1 }), - ), - }, - { adminAccessToken: merchantAdminAccessToken }, - ); - - const exchangeClient = new TalerExchangeHttpClient(exchange.baseUrl); - - const keys = succeedOrThrow(await exchangeClient.getKeys()); - - // KYC should be disabled - t.assertTrue(!keys.kyc_enabled); - - const merchantClient = new TalerMerchantInstanceHttpClient( - merchant.makeInstanceBaseUrl("minst2"), - ); - - while (true) { - const kycStatus = succeedOrThrow( - await merchantClient.getCurrentInstanceKycStatus(accessToken), - ); - - console.log(j2s(kycStatus)); - t.assertDeepEqual(kycStatus.kyc_data.length, 1); - const acc0 = kycStatus.kyc_data[0]; - if ( - acc0.status === - TalerMerchantApi.MerchantAccountKycStatus.EXCHANGE_UNREACHABLE - ) { - // Merchant needs more time to talk to the exchange - await waitMs(200); - continue; - } - t.assertDeepEqual( - acc0.status, - TalerMerchantApi.MerchantAccountKycStatus.UNSUPPORTED_ACCOUNT, - ); - break; - } -} - -runMerchantBankBadWireTargetTest.suites = ["merchant"]; diff --git a/packages/taler-harness/src/integrationtests/test-merchant-categories.ts b/packages/taler-harness/src/integrationtests/test-merchant-categories.ts @@ -1,188 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2021 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * Imports. - */ -import { URL, j2s } from "@gnu-taler/taler-util"; -import { - ExchangeService, - GlobalTestState, - MERCHANT_DEFAULT_AUTH, - MerchantService, - getTestHarnessPaytoForLabel, - harnessHttpLib, - setupDb, -} from "../harness/harness.js"; - -export async function runMerchantCategoriesTest(t: GlobalTestState) { - // Set up test environment - - const db = await setupDb(t); - - const exchange = ExchangeService.create(t, { - name: "testexchange-1", - currency: "TESTKUDOS", - httpPort: 8081, - database: db.connStr, - }); - - const merchant = await MerchantService.create(t, { - name: "testmerchant-1", - httpPort: 8083, - database: db.connStr, - }); - - // We add the exchange to the config, but note that the exchange won't be started. - merchant.addExchange(exchange); - - await merchant.start(); - await merchant.pingUntilAvailable(); - - // Base URL for the default instance. - const baseUrl = merchant.makeInstanceBaseUrl(); - - { - const r = await harnessHttpLib.fetch(new URL("config", baseUrl).href); - const data = await r.json(); - console.log(data); - t.assertDeepEqual(data.currency, "TESTKUDOS"); - } - - // Instances should initially be empty - { - const r = await harnessHttpLib.fetch( - new URL("management/instances", baseUrl).href, - ); - const data = await r.json(); - t.assertDeepEqual(data.instances, []); - } - - const { accessToken: adminAccessToken } = - await merchant.addInstanceWithWireAccount({ - id: "admin", - name: "Default Instance", - paytoUris: [getTestHarnessPaytoForLabel("merchant-default")], - auth: MERCHANT_DEFAULT_AUTH, - }); - - let myNewCategoryId: number; - - { - const url = new URL("private/categories", merchant.makeInstanceBaseUrl()); - const res = await harnessHttpLib.fetch(url.href, { - method: "POST", - body: { - name: "Snacks", - name_i18n: {}, - }, - headers: { - Authorization: `Bearer ${adminAccessToken}`, - }, - }); - - console.log(res.requestUrl); - console.log("status", res.status); - const categoryJson = await res.json(); - console.log(categoryJson); - t.assertTrue(res.status >= 200 && res.status < 300); - myNewCategoryId = categoryJson.category_id; - } - - { - const url = new URL("private/products", merchant.makeInstanceBaseUrl()); - const res = await harnessHttpLib.fetch(url.href, { - method: "POST", - body: { - product_id: "foo", - description: "Bla Bla", - unit: "item", - price: "TESTKUDOS:6", - total_stock: -1, - }, - headers: { - Authorization: `Bearer ${adminAccessToken}`, - }, - }); - t.assertTrue(res.status >= 200 && res.status < 300); - } - - { - const url = new URL("private/products", merchant.makeInstanceBaseUrl()); - const res = await harnessHttpLib.fetch(url.href, { - method: "POST", - body: { - product_id: "bar", - description: "Bla Bla", - unit: "item", - price: "TESTKUDOS:2", - total_stock: -1, - categories: [myNewCategoryId], - }, - headers: { - Authorization: `Bearer ${adminAccessToken}`, - }, - }); - t.assertTrue(res.status >= 200 && res.status < 300); - } - - { - const url = new URL("private/products", merchant.makeInstanceBaseUrl()); - const res = await harnessHttpLib.fetch(url.href, { - method: "POST", - body: { - product_id: "baz", - description: "Eggs", - unit: "item", - price: "TESTKUDOS:42", - total_stock: -1, - }, - headers: { - Authorization: `Bearer ${adminAccessToken}`, - }, - }); - t.assertTrue(res.status >= 200 && res.status < 300); - } - - { - const posUrl = new URL("private/pos", merchant.makeInstanceBaseUrl()); - const res = await harnessHttpLib.fetch(posUrl.href, { - method: "GET", - headers: { - Authorization: `Bearer ${adminAccessToken}`, - }, - }); - const posJson = await res.json(); - console.log(j2s(posJson)); - t.assertTrue(res.status >= 200 && res.status < 300); - - t.assertDeepEqual(posJson.products.length, 3); - - const prodFoo = posJson.products.find((x: any) => x.product_id == "foo"); - console.log(`prod foo`, prodFoo); - t.assertTrue(!!prodFoo); - // Only default category - t.assertDeepEqual(prodFoo.categories, [0]); - - const prodBar = posJson.products.find((x: any) => x.product_id == "bar"); - console.log(`prod bar`, prodBar); - t.assertTrue(!!prodBar); - // This should have the one we assigned to it. - t.assertDeepEqual(prodBar.categories, [myNewCategoryId]); - } -} - -runMerchantCategoriesTest.suites = ["merchant"]; diff --git a/packages/taler-harness/src/integrationtests/test-merchant-instances.ts b/packages/taler-harness/src/integrationtests/test-merchant-instances.ts @@ -20,6 +20,7 @@ import { AccessToken, HttpStatusCode, + j2s, LoginTokenScope, MerchantAuthMethod, succeedOrThrow, @@ -36,6 +37,92 @@ import { setupDb, } from "../harness/harness.js"; +async function checkMerchantCategories( + t: GlobalTestState, + merchant: MerchantService, + adminAccessToken: AccessToken, +): Promise<void> { + let categoryId: number; + + { + const url = new URL("private/categories", merchant.makeInstanceBaseUrl()); + const res = await harnessHttpLib.fetch(url.href, { + method: "POST", + body: { + name: "Snacks", + name_i18n: {}, + }, + headers: { + Authorization: `Bearer ${adminAccessToken}`, + }, + }); + + console.log(res.requestUrl); + console.log("status", res.status); + const categoryJson = await res.json(); + console.log(categoryJson); + t.assertTrue(res.status >= 200 && res.status < 300); + categoryId = categoryJson.category_id; + } + + for (const product of [ + { + product_id: "foo", + description: "Bla Bla", + unit: "item", + price: "TESTKUDOS:6", + total_stock: -1, + }, + { + product_id: "bar", + description: "Bla Bla", + unit: "item", + price: "TESTKUDOS:2", + total_stock: -1, + categories: [categoryId], + }, + { + product_id: "baz", + description: "Eggs", + unit: "item", + price: "TESTKUDOS:42", + total_stock: -1, + }, + ]) { + const url = new URL("private/products", merchant.makeInstanceBaseUrl()); + const res = await harnessHttpLib.fetch(url.href, { + method: "POST", + body: product, + headers: { + Authorization: `Bearer ${adminAccessToken}`, + }, + }); + t.assertTrue(res.status >= 200 && res.status < 300); + } + + const posUrl = new URL("private/pos", merchant.makeInstanceBaseUrl()); + const res = await harnessHttpLib.fetch(posUrl.href, { + method: "GET", + headers: { + Authorization: `Bearer ${adminAccessToken}`, + }, + }); + const posJson = await res.json(); + console.log(j2s(posJson)); + t.assertTrue(res.status >= 200 && res.status < 300); + t.assertDeepEqual(posJson.products.length, 3); + + const prodFoo = posJson.products.find((x: any) => x.product_id == "foo"); + console.log("prod foo", prodFoo); + t.assertTrue(!!prodFoo); + t.assertDeepEqual(prodFoo.categories, [0]); + + const prodBar = posJson.products.find((x: any) => x.product_id == "bar"); + console.log("prod bar", prodBar); + t.assertTrue(!!prodBar); + t.assertDeepEqual(prodBar.categories, [categoryId]); +} + /** * Do basic checks on instance management and authentication. */ @@ -150,6 +237,10 @@ export async function runMerchantInstancesTest(t: GlobalTestState) { }), ); + await t.runSpanAsync("merchant-categories", async () => { + await checkMerchantCategories(t, merchant, auth); + }); + console.log("requesting instances with no auth"); const exc = await merchantClient.listInstances("undefined" as AccessToken); t.assertTrue(exc.type === "fail"); diff --git a/packages/taler-harness/src/integrationtests/test-merchant-wire.ts b/packages/taler-harness/src/integrationtests/test-merchant-wire.ts @@ -21,6 +21,7 @@ import { AbsoluteTime, AmountString, Duration, + TalerExchangeHttpClient, TalerMerchantApi, TalerMerchantInstanceHttpClient, TransactionMajorState, @@ -34,7 +35,7 @@ import { createSimpleTestkudosEnvironmentV3, withdrawViaBankV3, } from "../harness/environments.js"; -import { GlobalTestState } from "../harness/harness.js"; +import { GlobalTestState, waitMs } from "../harness/harness.js"; /** * Test APIs related to merchant wire transfers. @@ -50,6 +51,51 @@ export async function runMerchantWireTest(t: GlobalTestState) { merchantAdminAccessToken, } = await createSimpleTestkudosEnvironmentV3(t); + await t.runSpanAsync("unsupported-wire-account", async () => { + const { accessToken } = await merchant.addInstanceWithWireAccount( + { + id: "unsupported-wire-account", + name: "Unsupported wire account", + paytoUris: [ + "payto://iban/DE1231231231?receiver-name=Unsupported%20Wire%20Account", + ], + defaultWireTransferDelay: Duration.toTalerProtocolDuration( + Duration.fromSpec({ minutes: 1 }), + ), + }, + { adminAccessToken: merchantAdminAccessToken }, + ); + + const exchangeClient = new TalerExchangeHttpClient(exchange.baseUrl); + const keys = succeedOrThrow(await exchangeClient.getKeys()); + t.assertTrue(!keys.kyc_enabled); + + const unsupportedAccountClient = new TalerMerchantInstanceHttpClient( + merchant.makeInstanceBaseUrl("unsupported-wire-account"), + ); + + while (true) { + const kycStatus = succeedOrThrow( + await unsupportedAccountClient.getCurrentInstanceKycStatus(accessToken), + ); + console.log(j2s(kycStatus)); + t.assertDeepEqual(kycStatus.kyc_data.length, 1); + const accountStatus = kycStatus.kyc_data[0].status; + if ( + accountStatus === + TalerMerchantApi.MerchantAccountKycStatus.EXCHANGE_UNREACHABLE + ) { + await waitMs(200); + continue; + } + t.assertDeepEqual( + accountStatus, + TalerMerchantApi.MerchantAccountKycStatus.UNSUPPORTED_ACCOUNT, + ); + break; + } + }); + // Withdraw digital cash into the wallet. await withdrawViaBankV3(t, { @@ -186,4 +232,4 @@ export async function runMerchantWireTest(t: GlobalTestState) { } } -runMerchantWireTest.suites = ["wallet"]; +runMerchantWireTest.suites = ["wallet", "merchant"]; diff --git a/packages/taler-harness/src/integrationtests/test-tops-aml-basic.ts b/packages/taler-harness/src/integrationtests/test-tops-aml-basic.ts @@ -51,7 +51,7 @@ export async function runTopsAmlBasicTest(t: GlobalTestState) { officerAcc, } = await createTopsEnvironment(t); - const challenger = await startFakeChallenger({ + const challenger = await startFakeChallenger(t, { port: 6001, addressType: "postal-ch", }); diff --git a/packages/taler-harness/src/integrationtests/test-tops-aml-custom-addr-postal.ts b/packages/taler-harness/src/integrationtests/test-tops-aml-custom-addr-postal.ts @@ -51,7 +51,7 @@ export async function runTopsAmlCustomAddrPostalTest(t: GlobalTestState) { wireGatewayApi, } = await createTopsEnvironment(t); - const challenger = await startFakeChallenger({ + const challenger = await startFakeChallenger(t, { port: 6001, addressType: "postal-ch", }); diff --git a/packages/taler-harness/src/integrationtests/test-tops-aml-custom-addr-sms.ts b/packages/taler-harness/src/integrationtests/test-tops-aml-custom-addr-sms.ts @@ -54,7 +54,7 @@ export async function runTopsAmlCustomAddrSmsTest(t: GlobalTestState) { merchantAdminAccessToken, } = await createTopsEnvironment(t); - const challengerSms = await startFakeChallenger({ + const challengerSms = await startFakeChallenger(t, { port: 6002, addressType: "phone", }); diff --git a/packages/taler-harness/src/integrationtests/test-tops-aml-kyx-natural.ts b/packages/taler-harness/src/integrationtests/test-tops-aml-kyx-natural.ts @@ -48,7 +48,7 @@ export async function runTopsAmlKyxNaturalTest(t: GlobalTestState) { bank, } = await createTopsEnvironment(t); - const challenger = await startFakeChallenger({ + const challenger = await startFakeChallenger(t, { port: 6001, addressType: "postal-ch", }); diff --git a/packages/taler-harness/src/integrationtests/test-tops-aml-p2p-fresh-wallet.ts b/packages/taler-harness/src/integrationtests/test-tops-aml-p2p-fresh-wallet.ts @@ -96,7 +96,7 @@ export async function runTopsAmlP2pFreshWalletTest(t: GlobalTestState) { }, }); - const challengerSms = await startFakeChallenger({ + const challengerSms = await startFakeChallenger(t, { port: 6002, addressType: "phone", }); diff --git a/packages/taler-harness/src/integrationtests/test-tops-aml-sanction-preserves-rules.ts b/packages/taler-harness/src/integrationtests/test-tops-aml-sanction-preserves-rules.ts @@ -149,7 +149,7 @@ export async function runTopsAmlSanctionPreservesRulesTest(t: GlobalTestState) { }, }); - const challengerSms = await startFakeChallenger({ + const challengerSms = await startFakeChallenger(t, { port: 6002, addressType: "phone", }); diff --git a/packages/taler-harness/src/integrationtests/test-tops-aml-stale-validation.ts b/packages/taler-harness/src/integrationtests/test-tops-aml-stale-validation.ts @@ -95,7 +95,7 @@ export async function runTopsAmlStaleValidationTest(t: GlobalTestState) { // An address whose validation has already aged out. The regex is the // correct one, so the number itself is accepted. - const challengerSms = await startFakeChallenger({ + const challengerSms = await startFakeChallenger(t, { port: 6002, addressType: "phone", addressExpires: AbsoluteTime.toProtocolTimestamp( diff --git a/packages/taler-harness/src/integrationtests/test-tops-merchant-swt-kycauth.ts b/packages/taler-harness/src/integrationtests/test-tops-merchant-swt-kycauth.ts @@ -48,7 +48,7 @@ import { topsKycRulesConf, topsProvidersTestConf } from "../harness/tops.js"; export async function runTopsMerchantSwtKycauthTest(t: GlobalTestState) { const db = await setupDb(t); - await startFakeChallenger({ + await startFakeChallenger(t, { port: 6001, addressType: "postal-ch", }); diff --git a/packages/taler-harness/src/integrationtests/test-tops-nexus-swt.ts b/packages/taler-harness/src/integrationtests/test-tops-nexus-swt.ts @@ -48,7 +48,7 @@ import { topsKycRulesConf, topsProvidersTestConf } from "../harness/tops.js"; export async function runTopsNexusSwtTest(t: GlobalTestState) { const db = await setupDb(t); - await startFakeChallenger({ + await startFakeChallenger(t, { port: 6001, addressType: "postal-ch", }); diff --git a/packages/taler-harness/src/integrationtests/test-tops-peer.ts b/packages/taler-harness/src/integrationtests/test-tops-peer.ts @@ -41,7 +41,7 @@ export async function runTopsPeerTest(t: GlobalTestState) { const { walletClient, bankClient, exchange, exchangeApi, officerAcc } = await createTopsEnvironment(t); - const challenger = await startFakeChallenger({ + const challenger = await startFakeChallenger(t, { port: 6001, addressType: "postal-ch", }); diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-deposit-kyc-auth-swiss.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-deposit-kyc-auth-swiss.ts @@ -56,7 +56,7 @@ export async function runWalletWebUiPwaDepositKycAuthSwissTest( t: GlobalTestState, ) { const db = await setupDb(t); - const challenger = await startFakeChallenger({ + const challenger = await startFakeChallenger(t, { port: 6001, addressType: "postal-ch", }); diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -114,8 +114,6 @@ import { runLibeufinBankWebuiMoneyFlowsTest, } from "./test-libeufin-bank-webui.js"; import { runMerchantAcctselTest } from "./test-merchant-acctsel.js"; -import { runMerchantBankBadWireTargetTest } from "./test-merchant-bank-bad-wire-target.js"; -import { runMerchantCategoriesTest } from "./test-merchant-categories.js"; import { runMerchantDepositLargeTest } from "./test-merchant-deposit-large.js"; import { runMerchantExchangeConfusionTest } from "./test-merchant-exchange-confusion.js"; import { runMerchantExchangeDuplicateTest } from "./test-merchant-exchange-duplicate.js"; @@ -410,7 +408,6 @@ const allTests: TestMainFunction[] = [ runWithdrawalFlexTest, runExchangeCurrencyChangeTest, runExchangeMasterPubChangeTest, - runMerchantCategoriesTest, runMerchantSelfProvisionActivationTest, runMerchantWebuiBootstrapTest, runMerchantWebuiMfaTest, @@ -490,7 +487,6 @@ const allTests: TestMainFunction[] = [ runTopsAmlPdfTest, runMerchantWireTest, runWalletExchangeFeaturesTest, - runMerchantBankBadWireTargetTest, runWalletBbanTest, runCurrencyScopeSeparationTest, runWalletRefreshRedenominateTest, @@ -556,6 +552,12 @@ export interface TestRunSpec { */ strictTodo: boolean; noTimeout: boolean; + /** + * Reuse one loaded child process across tests. The test environments and + * databases remain separate, but process-global state is no longer isolated + * by an exit between tests. + */ + reuseWorker?: boolean; verbosity: number; } @@ -610,8 +612,132 @@ interface RunTestChildInstruction { testRootDir: string; } +interface ReusableWorkerResultMessage { + type: "result"; + result: TestRunResult; +} + +interface ReusableWorkerReadyMessage { + type: "ready"; +} + +type ReusableWorkerMessage = + | ReusableWorkerResultMessage + | ReusableWorkerReadyMessage; + +class ReusableTestWorker { + readonly child: child_process.ChildProcess; + private active: + | { + resolve: (result: TestRunResult) => void; + reject: (error: Error) => void; + } + | undefined; + private currentLogStream: fs.WriteStream | undefined; + private readyResolve!: () => void; + private readyReject!: (error: Error) => void; + private readonly ready: Promise<void>; + private stopped = false; + + constructor(myFilename: string, verbosity: number) { + this.ready = new Promise<void>((resolve, reject) => { + this.readyResolve = resolve; + this.readyReject = reject; + }); + this.child = child_process.fork( + myFilename, + ["__TWCLI_REUSABLE_TESTWORKER"], + { + env: { + TWCLI_LOGLEVEL: logger.getGlobalLogLevel(), + ...process.env, + }, + stdio: ["pipe", "pipe", "pipe", "ipc"], + }, + ); + + this.child.stdout?.on("data", (chunk: Buffer) => { + this.currentLogStream?.write(chunk); + if (verbosity > 0) { + process.stdout.write(chunk); + } + }); + this.child.stderr?.on("data", (chunk: Buffer) => { + this.currentLogStream?.write(chunk); + if (verbosity > 0) { + process.stderr.write(chunk); + } + }); + this.child.on("message", (message: ReusableWorkerMessage) => { + if (message.type === "ready") { + this.readyResolve(); + return; + } + if (!this.active) { + this.stop(); + return; + } + const active = this.active; + this.active = undefined; + active.resolve(message.result); + }); + this.child.on("exit", (code, signal) => { + this.stopped = true; + const detail = signal ? `signal ${signal}` : `code ${code}`; + const error = new Error(`reusable test worker exited with ${detail}`); + this.readyReject(error); + this.active?.reject(error); + this.active = undefined; + }); + this.child.on("error", (error) => { + this.readyReject(error); + this.active?.reject(error); + this.active = undefined; + }); + } + + async run( + instruction: RunTestChildInstruction, + logStream: fs.WriteStream, + ): Promise<TestRunResult> { + await this.ready; + if (this.stopped) { + throw Error("reusable test worker is not running"); + } + if (this.active) { + throw Error("reusable test worker already has an active test"); + } + this.currentLogStream = logStream; + return new Promise<TestRunResult>((resolve, reject) => { + this.active = { resolve, reject }; + this.child.send({ type: "run", instruction }, (error) => { + if (!error) { + return; + } + this.active = undefined; + reject(error); + }); + }); + } + + finishTest(): void { + this.currentLogStream = undefined; + } + + stop(): void { + if (this.stopped) { + return; + } + this.stopped = true; + this.child.disconnect(); + } +} + export async function runTests(spec: TestRunSpec) { validateTestMetadata(allTests); + if (spec.reuseWorker && shouldLingerInTest()) { + throw Error("--reuse-worker cannot be combined with TALER_TEST_LINGER"); + } let testRootDir: string; if (spec.testDir != null) { @@ -693,6 +819,7 @@ export async function runTests(spec: TestRunSpec) { let numFailed = 0; let numTodoFailed = 0; + let reusableWorker: ReusableTestWorker | undefined; for (const [n, testCase] of filteredTests.entries()) { const testName = getTestName(testCase); @@ -712,28 +839,35 @@ export async function runTests(spec: TestRunSpec) { const myFilename = url.fileURLToPath(import.meta.url); - currentChild = child_process.fork(myFilename, ["__TWCLI_TESTWORKER"], { - env: { - TWCLI_RUN_TEST_INSTRUCTION: JSON.stringify(testInstr), - TWCLI_LOGLEVEL: logger.getGlobalLogLevel(), - ...process.env, - }, - stdio: ["pipe", "pipe", "pipe", "ipc"], - }); - const testDir = path.join(testRootDir, testName); fs.mkdirSync(testDir, { recursive: true }); const harnessLogFilename = path.join(testRootDir, testName, "harness.log"); const harnessLogStream = fs.createWriteStream(harnessLogFilename); - if (spec.verbosity > 0) { - currentChild.stderr?.pipe(process.stderr); - currentChild.stdout?.pipe(process.stdout); - } + let reusableResultPromise: Promise<TestRunResult> | undefined; + if (spec.reuseWorker) { + reusableWorker ??= new ReusableTestWorker(myFilename, spec.verbosity); + currentChild = reusableWorker.child; + reusableResultPromise = reusableWorker.run(testInstr, harnessLogStream); + } else { + currentChild = child_process.fork(myFilename, ["__TWCLI_TESTWORKER"], { + env: { + TWCLI_RUN_TEST_INSTRUCTION: JSON.stringify(testInstr), + TWCLI_LOGLEVEL: logger.getGlobalLogLevel(), + ...process.env, + }, + stdio: ["pipe", "pipe", "pipe", "ipc"], + }); + + if (spec.verbosity > 0) { + currentChild.stderr?.pipe(process.stderr); + currentChild.stdout?.pipe(process.stdout); + } - currentChild.stdout?.pipe(harnessLogStream); - currentChild.stderr?.pipe(harnessLogStream); + currentChild.stdout?.pipe(harnessLogStream); + currentChild.stderr?.pipe(harnessLogStream); + } // Default timeout when the test doesn't override it. let defaultTimeout = 60000; @@ -771,8 +905,9 @@ export async function runTests(spec: TestRunSpec) { ? CancellationToken.CONTINUE : CancellationToken.timeout(testTimeoutMs).token; - const resultPromise: Promise<TestRunResult> = new Promise( - (resolve, reject) => { + const resultPromise: Promise<TestRunResult> = + reusableResultPromise ?? + new Promise((resolve, reject) => { let msg: TestRunResult | undefined; currentChild!.on("message", (m) => { if (token.isCancelled) { @@ -805,8 +940,7 @@ export async function runTests(spec: TestRunSpec) { } reject(err); }); - }, - ); + }); let result: TestRunResult; @@ -821,6 +955,7 @@ export async function runTests(spec: TestRunSpec) { name: testName, }; currentChild.kill("SIGTERM"); + reusableWorker = undefined; } else if (e instanceof Error) { result = { status: "fail", @@ -829,8 +964,10 @@ export async function runTests(spec: TestRunSpec) { name: testName, }; currentChild.kill("SIGTERM"); + reusableWorker = undefined; } else { currentChild.kill("SIGTERM"); + reusableWorker = undefined; // Should never happen throw Error("test failed with strange exception"); } @@ -852,6 +989,7 @@ export async function runTests(spec: TestRunSpec) { } } + reusableWorker?.finishTest(); harnessLogStream.close(); const stepsFile = `${testDir}/steps.txt`; @@ -874,6 +1012,7 @@ export async function runTests(spec: TestRunSpec) { } } + reusableWorker?.stop(); reportAndQuit(testRootDir, testResults, { strictTodo: spec.strictTodo }); } @@ -965,53 +1104,52 @@ export function getTestInfo(): TestInfo[] { })); } +async function runChildInstruction({ + testRootDir, + testName, +}: RunTestChildInstruction): Promise<TestRunResult> { + const testMain = allTests.find((test) => getTestName(test) === testName); + if (!testMain) { + throw Error(`test ${testName} not found`); + } + + const testDir = path.join(testRootDir, testName); + logger.info(`running test ${testName}`); + const gc = new GlobalTestState({ testDir }); + const testResult = await runTestWithState(gc, testMain, testName); + logger.info(`done test ${testName}: ${testResult.status}`); + return testResult; +} + +function flushWorkerOutput(): Promise<unknown[]> { + return Promise.all([ + new Promise<void>((resolve) => process.stdout.write("", () => resolve())), + new Promise<void>((resolve) => process.stderr.write("", () => resolve())), + ]); +} + const runTestInstrStr = process.env["TWCLI_RUN_TEST_INSTRUCTION"]; if (runTestInstrStr && process.argv.includes("__TWCLI_TESTWORKER")) { setGlobalLogLevelFromString(process.env["TWCLI_LOGLEVEL"] ?? "INFO"); - // Test will call taler-wallet-cli, so we must not propagate this variable. + // Test will call taler-wallet-cli, so we must not propagate these variables. delete process.env["TWCLI_RUN_TEST_INSTRUCTION"]; delete process.env["TWCLI_LOGLEVEL"]; - const { testRootDir, testName } = JSON.parse( - runTestInstrStr, - ) as RunTestChildInstruction; + const instruction = JSON.parse(runTestInstrStr) as RunTestChildInstruction; process.on("disconnect", () => { logger.trace("got disconnect from parent"); process.exit(3); }); - const runTest = async () => { - let testMain: TestMainFunction | undefined; - for (const t of allTests) { - if (getTestName(t) === testName) { - testMain = t; - break; + runChildInstruction(instruction) + .then((testResult) => { + if (!process.send) { + throw Error("can't communicate with parent"); } - } - - if (!process.send) { - logger.error("can't communicate with parent"); - process.exit(2); - } - - if (!testMain) { - logger.info(`test ${testName} not found`); - process.exit(2); - } - - const testDir = path.join(testRootDir, testName); - logger.info(`running test ${testName}`); - const gc = new GlobalTestState({ - testDir, - }); - const testResult = await runTestWithState(gc, testMain, testName); - logger.info(`done test ${testName}: ${testResult.status}`); - process.send(testResult); - }; - - runTest() + process.send(testResult); + }) .then(() => { - logger.trace(`test ${testName} finished in worker`); + logger.trace(`test ${instruction.testName} finished in worker`); if (shouldLingerInTest()) { logger.trace("lingering ..."); return; @@ -1023,3 +1161,56 @@ if (runTestInstrStr && process.argv.includes("__TWCLI_TESTWORKER")) { process.exit(1); }); } + +if (process.argv.includes("__TWCLI_REUSABLE_TESTWORKER")) { + setGlobalLogLevelFromString(process.env["TWCLI_LOGLEVEL"] ?? "INFO"); + delete process.env["TWCLI_LOGLEVEL"]; + const baselineEnvironment = { ...process.env }; + const baselineWorkingDirectory = process.cwd(); + let running = false; + + process.on("disconnect", () => { + logger.trace("reusable worker disconnected from parent"); + process.exit(0); + }); + + process.on( + "message", + (message: { type?: string; instruction?: RunTestChildInstruction }) => { + if (message.type !== "run" || !message.instruction) { + logger.error("reusable worker received an invalid instruction"); + process.exit(2); + } + if (running) { + logger.error("reusable worker received overlapping tests"); + process.exit(2); + } + running = true; + runChildInstruction(message.instruction) + .then(async (result) => { + for (const key of Object.keys(process.env)) { + if (!(key in baselineEnvironment)) { + delete process.env[key]; + } + } + Object.assign(process.env, baselineEnvironment); + process.chdir(baselineWorkingDirectory); + await flushWorkerOutput(); + if (!process.send) { + throw Error("can't communicate with parent"); + } + running = false; + process.send({ type: "result", result }); + }) + .catch((error) => { + logger.error(error); + process.exit(1); + }); + }, + ); + + if (!process.send) { + throw Error("can't communicate with parent"); + } + process.send({ type: "ready" }); +}