wallet-core/packages/taler-wallet-cli/src/integrationtests/testrunner.ts

411 lines
12 KiB
TypeScript

/*
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/>
*/
import {
delayMs,
GlobalTestState,
runTestWithState,
shouldLingerInTest,
TestRunResult,
} from "./harness";
import { runPaymentTest } from "./test-payment";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
import * as child_process from "child_process";
import { runBankApiTest } from "./test-bank-api";
import { runClaimLoopTest } from "./test-claim-loop";
import { runExchangeManagementTest } from "./test-exchange-management";
import { runFeeRegressionTest } from "./test-fee-regression";
import { runMerchantLongpollingTest } from "./test-merchant-longpolling";
import { runMerchantRefundApiTest } from "./test-merchant-refund-api";
import { runPayAbortTest } from "./test-pay-abort";
import { runPayPaidTest } from "./test-pay-paid";
import { runPaymentClaimTest } from "./test-payment-claim";
import { runPaymentFaultTest } from "./test-payment-fault";
import { runPaymentIdempotencyTest } from "./test-payment-idempotency";
import { runPaymentMultipleTest } from "./test-payment-multiple";
import { runPaymentTransientTest } from "./test-payment-transient";
import { runPaywallFlowTest } from "./test-paywall-flow";
import { runRefundAutoTest } from "./test-refund-auto";
import { runRefundGoneTest } from "./test-refund-gone";
import { runRefundIncrementalTest } from "./test-refund-incremental";
import { runRefundTest } from "./test-refund";
import { runRevocationTest } from "./test-revocation";
import { runTimetravelAutorefreshTest } from "./test-timetravel-autorefresh";
import { runTimetravelWithdrawTest } from "./test-timetravel-withdraw";
import { runTippingTest } from "./test-tipping";
import { runWallettestingTest } from "./test-wallettesting";
import { runTestWithdrawalManualTest } from "./test-withdrawal-manual";
import { runWithdrawalAbortBankTest } from "./test-withdrawal-abort-bank";
import { runWithdrawalBankIntegratedTest } from "./test-withdrawal-bank-integrated";
import M from "minimatch";
import { runMerchantExchangeConfusionTest } from "./test-merchant-exchange-confusion";
import { runLibeufinBasicTest } from "./test-libeufin-basic";
import { runLibeufinRefundTest } from "./test-libeufin-refund";
import { runLibeufinTutorialTest } from "./test-libeufin-tutorial";
import { runDepositTest } from "./test-deposit";
import CancellationToken from "cancellationtoken";
/**
* Test runner.
*/
/**
* Spec for one test.
*/
interface TestMainFunction {
(t: GlobalTestState): Promise<void>;
timeoutMs?: number;
suites?: string[];
}
const allTests: TestMainFunction[] = [
runBankApiTest,
runClaimLoopTest,
runDepositTest,
runExchangeManagementTest,
runFeeRegressionTest,
runLibeufinBasicTest,
runLibeufinTutorialTest,
runLibeufinRefundTest,
runMerchantExchangeConfusionTest,
runMerchantLongpollingTest,
runMerchantRefundApiTest,
runPayAbortTest,
runPaymentClaimTest,
runPaymentFaultTest,
runPaymentIdempotencyTest,
runPaymentMultipleTest,
runPaymentTest,
runPaymentTransientTest,
runPayPaidTest,
runPaywallFlowTest,
runRefundAutoTest,
runRefundGoneTest,
runRefundIncrementalTest,
runRefundTest,
runRevocationTest,
runTestWithdrawalManualTest,
runTimetravelAutorefreshTest,
runTimetravelWithdrawTest,
runTippingTest,
runWallettestingTest,
runWithdrawalAbortBankTest,
runWithdrawalBankIntegratedTest,
];
export interface TestRunSpec {
includePattern?: string;
suiteSpec?: string;
dryRun?: boolean,
}
export interface TestInfo {
name: string;
}
function updateCurrentSymlink(testDir: string): void {
const currLink = path.join(os.tmpdir(), "taler-integrationtests-current");
try {
fs.unlinkSync(currLink);
} catch (e) {
// Ignore
}
try {
fs.symlinkSync(testDir, currLink);
} catch (e) {
console.log(e);
// Ignore
}
}
export function getTestName(tf: TestMainFunction): string {
const res = tf.name.match(/run([a-zA-Z0-9]*)Test/);
if (!res) {
throw Error("invalid test name, must be 'run${NAME}Test'");
}
return res[1]
.replace(/[a-z0-9][A-Z]/g, (x) => {
return x[0] + "-" + x[1];
})
.toLowerCase();
}
interface RunTestChildInstruction {
testName: string;
testRootDir: string;
}
export async function runTests(spec: TestRunSpec) {
const testRootDir = fs.mkdtempSync(
path.join(os.tmpdir(), "taler-integrationtests-"),
);
updateCurrentSymlink(testRootDir);
console.log("testsuite root directory: ", testRootDir);
const testResults: TestRunResult[] = [];
let currentChild: child_process.ChildProcess | undefined;
const handleSignal = (s: NodeJS.Signals) => {
console.log(`received signal ${s} in test parent`);
if (currentChild) {
currentChild.kill("SIGTERM");
}
reportAndQuit(testRootDir, testResults, true);
};
process.on("SIGINT", (s) => handleSignal(s));
process.on("SIGTERM", (s) => handleSignal(s));
//process.on("unhandledRejection", handleSignal);
//process.on("uncaughtException", handleSignal);
let suites: Set<string> | undefined;
if (spec.suiteSpec) {
suites = new Set(spec.suiteSpec.split(",").map((x) => x.trim()));
}
for (const [n, testCase] of allTests.entries()) {
const testName = getTestName(testCase);
if (spec.includePattern && !M(testName, spec.includePattern)) {
continue;
}
if (suites) {
const ts = new Set(testCase.suites ?? []);
const intersection = new Set([...suites].filter((x) => ts.has(x)));
if (intersection.size === 0) {
continue;
}
}
if (spec.dryRun) {
console.log(`dry run: would run test ${testName}`);
continue;
}
const testInstr: RunTestChildInstruction = {
testName,
testRootDir,
};
currentChild = child_process.fork(__filename, ["__TWCLI_TESTWORKER"], {
env: {
TWCLI_RUN_TEST_INSTRUCTION: JSON.stringify(testInstr),
...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);
currentChild.stderr?.pipe(process.stderr);
currentChild.stdout?.pipe(process.stdout);
currentChild.stdout?.pipe(harnessLogStream);
currentChild.stderr?.pipe(harnessLogStream);
const defaultTimeout = 60000;
const testTimeoutMs = testCase.timeoutMs ?? defaultTimeout;
console.log(`running ${testName} with timeout ${testTimeoutMs}ms`);
const { token } = CancellationToken.timeout(testTimeoutMs);
const resultPromise: Promise<TestRunResult> = new Promise(
(resolve, reject) => {
let msg: TestRunResult | undefined;
currentChild!.on("message", (m) => {
if (token.isCancelled) {
return;
}
msg = m as TestRunResult;
});
currentChild!.on("exit", (code, signal) => {
if (token.isCancelled) {
return;
}
console.log(`process exited code=${code} signal=${signal}`);
if (signal) {
reject(new Error(`test worker exited with signal ${signal}`));
} else if (code != 0) {
reject(new Error(`test worker exited with code ${code}`));
} else if (!msg) {
reject(
new Error(
`test worker exited without giving back the test results`,
),
);
} else {
resolve(msg);
}
});
currentChild!.on("error", (err) => {
if (token.isCancelled) {
return;
}
reject(err);
});
},
);
let result: TestRunResult;
try {
result = await token.racePromise(resultPromise);
} catch (e) {
console.error(`test ${testName} timed out`);
if (token.isCancelled) {
result = {
status: "fail",
reason: "timeout",
timeSec: testTimeoutMs / 1000,
name: testName,
};
currentChild.kill("SIGTERM");
} else {
throw Error(e);
}
}
harnessLogStream.close();
console.log(`parent: got result ${JSON.stringify(result)}`);
testResults.push(result);
}
reportAndQuit(testRootDir, testResults);
}
export function reportAndQuit(
testRootDir: string,
testResults: TestRunResult[],
interrupted: boolean = false,
): never {
let numTotal = 0;
let numFail = 0;
let numSkip = 0;
let numPass = 0;
for (const result of testResults) {
numTotal++;
if (result.status === "fail") {
numFail++;
} else if (result.status === "skip") {
numSkip++;
} else if (result.status === "pass") {
numPass++;
}
}
const resultsFile = path.join(testRootDir, "results.json");
fs.writeFileSync(
path.join(testRootDir, "results.json"),
JSON.stringify({ testResults, interrupted }, undefined, 2),
);
if (interrupted) {
console.log("test suite was interrupted");
}
console.log(`See ${resultsFile} for details`);
console.log(`Skipped: ${numSkip}/${numTotal}`);
console.log(`Failed: ${numFail}/${numTotal}`);
console.log(`Passed: ${numPass}/${numTotal}`);
if (interrupted) {
process.exit(3);
} else if (numPass < numTotal - numSkip) {
process.exit(1);
} else {
process.exit(0);
}
}
export function getTestInfo(): TestInfo[] {
return allTests.map((x) => ({
name: getTestName(x),
}));
}
const runTestInstrStr = process.env["TWCLI_RUN_TEST_INSTRUCTION"];
if (runTestInstrStr && process.argv.includes("__TWCLI_TESTWORKER")) {
// Test will call taler-wallet-cli, so we must not propagate this variable.
delete process.env["TWCLI_RUN_TEST_INSTRUCTION"];
const { testRootDir, testName } = JSON.parse(
runTestInstrStr,
) as RunTestChildInstruction;
console.log(`running test ${testName} in worker process`);
process.on("disconnect", () => {
console.log("got disconnect from parent");
process.exit(3);
});
try {
require("source-map-support").install();
} catch (e) {
// Do nothing.
}
const runTest = async () => {
let testMain: TestMainFunction | undefined;
for (const t of allTests) {
if (getTestName(t) === testName) {
testMain = t;
break;
}
}
if (!process.send) {
console.error("can't communicate with parent");
process.exit(2);
}
if (!testMain) {
console.log(`test ${testName} not found`);
process.exit(2);
}
const testDir = path.join(testRootDir, testName);
console.log(`running test ${testName}`);
const gc = new GlobalTestState({
testDir,
});
const testResult = await runTestWithState(gc, testMain, testName);
process.send(testResult);
};
runTest()
.then(() => {
console.log(`test ${testName} finished in worker`);
if (shouldLingerInTest()) {
console.log("lingering ...");
return;
}
process.exit(0);
})
.catch((e) => {
console.log(e);
process.exit(1);
});
}