@power-doctest/tester
Version:
A Test Runner for A power-doctest.
230 lines (220 loc) • 7.09 kB
text/typescript
import * as vm from "node:vm";
import { convertCode } from "@power-doctest/core";
import type { ParsedCode } from "@power-doctest/types";
// @ts-expect-error: no types
import assert from "power-assert";
export interface PowerDoctestRunnerOptions {
// pseudo file path for code
filePath?: string;
// sandbox context for code
// context defined global variables
context?: {
[index: string]: any;
};
// sandbox require mock for code
requireMock?: {
[index: string]: any;
};
// If it is true, console.log output to console
// If you want to mock console, please pass `console` to `context: { console: consoleMock }`
//
// Exception:
// Always suppress console and assertion, because it is converted to assert function
// ```
// console.log(1); // => 1
// ```
console?: boolean;
// Timeout millisecond
// Default: 2000
timeout?: number;
// Default: all
// If runMode is all, all assertions are finished and resolve it
// If runMode is any, anyone assertion is finished and resolve it
// In Both, anyone is failed and reject it
runMode?: "any" | "all";
// Internal Option
powerDoctestCallbackFunctionName?: string;
}
const CALLBACK_FUNCTION_NAME = "__power_doctest_runner_callback__";
export interface testOptions {
// if it is true and Each testOptions.state is "none", run the test
// if it is false and Each testOptions.state is "none", do not run the test
// Default: false
disableRunning: boolean;
// default values for doctestRunnerOptions
// It is override by test case's `doctestRunnerOptions`.
defaultDoctestRunnerOptions?: PowerDoctestRunnerOptions;
}
export type PowerDocTestError = Error & {
// file path of the code
fileName?: string;
// line number of the code
lineNumber?: number;
// column number of the code
columnNumber?: number;
// metadata of the code
meta?: {
[index: string]: unknown;
};
};
export function test(parsedCode: ParsedCode, oprions?: testOptions): Promise<void> {
if (parsedCode.state === "disabled") {
return Promise.resolve();
}
if (oprions?.disableRunning && parsedCode.state === "none") {
return Promise.resolve();
}
return run(parsedCode.code, {
...(oprions?.defaultDoctestRunnerOptions ? oprions.defaultDoctestRunnerOptions : {}),
...parsedCode.doctestOptions,
}).catch((error) => {
// if it is expected error, resolve it
if (parsedCode.expectedError && error.name === parsedCode.expectedError) {
return Promise.resolve();
}
const doctestOptions: PowerDoctestRunnerOptions | undefined = parsedCode.doctestOptions;
error.fileName = doctestOptions?.filePath ? doctestOptions.filePath : error.fileName;
error.lineNumber = parsedCode.location.start.line;
error.columnNumber = parsedCode.location.start.column;
const metadata = parsedCode.metadata;
if (metadata) {
error.meta = metadata;
}
return Promise.reject(error);
});
}
export function run(code: string, options: PowerDoctestRunnerOptions = {}): Promise<void> {
const filePath = options.filePath || "default.js";
const runMode = options.runMode || "all";
const timeout = options.timeout !== undefined ? options.timeout : 2000;
const postCallbackName = options.powerDoctestCallbackFunctionName || CALLBACK_FUNCTION_NAME;
const context = options.context || {};
// total count of assert
let totalAssertionCount = 0;
// current count of assert
let countOfExecutedAssertion = 0;
return new Promise((resolve, reject) => {
let isSettled = false;
const timeoutId = setTimeout(() => {
if (isSettled) {
return;
}
restoreListener();
reject(
new Error(`Timeout error
${
runMode === "all"
? `If you use { "runMode": "all" }, you should check all condition flow is passed.
Total Assertion: ${totalAssertionCount}
Executed Assertion: ${countOfExecutedAssertion}
Also, you should consider to use { "runMode": "any" }`
: ""
}`),
);
}, timeout);
// Test Runner like mocha listen unhandledRejection and uncaughtException
// Disable these listener before running code
const originalUnhandledRejection = process.listeners("unhandledRejection");
const originalUncaughtException = process.listeners("uncaughtException");
process.removeAllListeners("uncaughtException");
process.removeAllListeners("unhandledRejection");
const unhandledRejection = (reason: {}, _promise: Promise<void>): void => {
restoreListener();
reject(reason);
};
const uncaughtException = (error: Error): void => {
restoreListener();
reject(error);
};
const restoreListener = () => {
isSettled = true;
process.removeListener("uncaughtException", uncaughtException);
process.removeListener("unhandledRejection", unhandledRejection);
// restore
const currentUncaughtException = process.listeners("uncaughtException");
const currentUnhandledRejection = process.listeners("unhandledRejection");
originalUncaughtException
.filter((listener) => {
// remove duplicated
return !currentUncaughtException.includes(listener);
})
.forEach((listener) => {
process.addListener("uncaughtException", listener);
});
originalUnhandledRejection
.filter((listener) => {
// remove duplicated
return !currentUnhandledRejection.includes(listener);
})
.forEach((listener) => {
process.addListener("unhandledRejection", listener);
});
// clearTimeout
clearTimeout(timeoutId);
};
process.on("uncaughtException", uncaughtException);
process.on("unhandledRejection", unhandledRejection as any);
const poweredCode = convertCode(code, {
assertAfterCallbackName: postCallbackName,
filePath: filePath,
});
// total count of assert
totalAssertionCount = poweredCode.split(CALLBACK_FUNCTION_NAME).length - 1;
// current count of assert
countOfExecutedAssertion = 0;
try {
const HostBuildIns = Object.freeze({
__proto__: null,
version: parseInt(process.versions.node.split(".")[0]),
process,
console,
setTimeout,
setInterval,
setImmediate,
clearTimeout,
clearInterval,
clearImmediate,
});
const script = new vm.Script(poweredCode, {
filename: options.filePath,
});
const vmContext = vm.createContext({
require: (moduleName: string) => {
if (moduleName === "power-assert") {
return assert;
}
// options.requireMock
if (options.requireMock && Object.hasOwn(options.requireMock, moduleName)) {
return options.requireMock[moduleName];
}
return require(moduleName);
},
[postCallbackName]: (_id: string) => {
countOfExecutedAssertion++;
if (runMode === "all" && countOfExecutedAssertion === totalAssertionCount) {
// when all finish
restoreListener();
resolve();
} else if (runMode === "any") {
// when anyone finish
restoreListener();
resolve();
}
},
...HostBuildIns,
...context,
});
script.runInNewContext(vmContext, {
timeout,
});
} catch (error) {
restoreListener();
reject(error);
}
// No assertion code
if (totalAssertionCount === 0) {
restoreListener();
resolve();
}
});
}