refactor(cli): move runTests() and runBenchmarks() to rust (#18563)

Stores the test/bench functions in rust op state during registration.
The functions are wrapped in JS first so that they return a directly
convertible `TestResult`/`BenchResult`. Test steps are still mostly
handled in JS since they are pretty much invoked by the user. Allows
removing a bunch of infrastructure for communicating between JS and
rust. Allows using rust utilities for things like shuffling tests
(`Vec::shuffle`). We can progressively move op and resource sanitization
to rust as well.

Fixes #17122.
Fixes #17312.
This commit is contained in:
Nayeem Rahman 2023-04-13 18:43:23 +01:00 committed by GitHub
parent 4e53bc5a94
commit 6e8618ae0f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 450 additions and 705 deletions

View file

@ -2,21 +2,16 @@
const core = globalThis.Deno.core;
const ops = core.ops;
const internals = globalThis.__bootstrap.internals;
import { setExitHandler } from "ext:runtime/30_os.js";
import { Console } from "ext:deno_console/02_console.js";
import { serializePermissions } from "ext:runtime/10_permissions.js";
import { assert } from "ext:deno_web/00_infra.js";
const primordials = globalThis.__bootstrap.primordials;
const {
ArrayFrom,
ArrayPrototypeFilter,
ArrayPrototypeJoin,
ArrayPrototypeMap,
ArrayPrototypePush,
ArrayPrototypeShift,
ArrayPrototypeSort,
BigInt,
DateNow,
Error,
FunctionPrototype,
@ -36,6 +31,7 @@ const {
} = primordials;
const opSanitizerDelayResolveQueue = [];
let hasSetOpSanitizerDelayMacrotask = false;
// Even if every resource is closed by the end of a test, there can be a delay
// until the pending ops have all finished. This function returns a promise
@ -47,6 +43,10 @@ const opSanitizerDelayResolveQueue = [];
// before that, though, in order to give time for worker message ops to finish
// (since timeouts of 0 don't queue tasks in the timer queue immediately).
function opSanitizerDelay() {
if (!hasSetOpSanitizerDelayMacrotask) {
core.setMacrotaskCallback(handleOpSanitizerDelayMacrotask);
hasSetOpSanitizerDelayMacrotask = true;
}
return new Promise((resolve) => {
setTimeout(() => {
ArrayPrototypePush(opSanitizerDelayResolveQueue, resolve);
@ -415,9 +415,28 @@ function assertExit(fn, isTest) {
};
}
function assertTestStepScopes(fn) {
function wrapOuter(fn, desc) {
return async function outerWrapped() {
try {
if (desc.ignore) {
return "ignored";
}
return await fn(desc) ?? "ok";
} catch (error) {
return { failed: { jsError: core.destructureError(error) } };
} finally {
const state = MapPrototypeGet(testStates, desc.id);
for (const childDesc of state.children) {
stepReportResult(childDesc, { failed: "incomplete" }, 0);
}
state.completed = true;
}
};
}
function wrapInner(fn) {
/** @param desc {TestDescription | TestStepDescription} */
return async function testStepSanitizer(desc) {
return async function innerWrapped(desc) {
function getRunningStepDescs() {
const results = [];
let childDesc = desc;
@ -458,11 +477,17 @@ function assertTestStepScopes(fn) {
};
}
await fn(MapPrototypeGet(testStates, desc.id).context);
let failedSteps = 0;
for (const childDesc of MapPrototypeGet(testStates, desc.id).children) {
if (!MapPrototypeGet(testStates, childDesc.id).completed) {
const state = MapPrototypeGet(testStates, childDesc.id);
if (!state.completed) {
return { failed: "incompleteSteps" };
}
if (state.failed) {
failedSteps++;
}
}
return failedSteps == 0 ? null : { failed: { failedSteps } };
};
}
@ -495,7 +520,6 @@ function withPermissions(fn, permissions) {
* fn: TestFunction
* origin: string,
* location: TestLocation,
* filteredOut: boolean,
* ignore: boolean,
* only: boolean.
* sanitizeOps: boolean,
@ -538,7 +562,6 @@ function withPermissions(fn, permissions) {
* name: string,
* fn: BenchFunction
* origin: string,
* filteredOut: boolean,
* ignore: boolean,
* only: boolean.
* sanitizeExit: boolean,
@ -546,14 +569,8 @@ function withPermissions(fn, permissions) {
* }} BenchDescription
*/
/** @type {TestDescription[]} */
const testDescs = [];
/** @type {Map<number, TestState | TestStepState>} */
const testStates = new Map();
/** @type {BenchDescription[]} */
const benchDescs = [];
let isTestSubcommand = false;
let isBenchSubcommand = false;
// Main test function provided by Deno.
function test(
@ -561,7 +578,7 @@ function test(
optionsOrFn,
maybeFn,
) {
if (!isTestSubcommand) {
if (typeof ops.op_register_test != "function") {
return;
}
@ -647,19 +664,17 @@ function test(
// Delete this prop in case the user passed it. It's used to detect steps.
delete testDesc.parent;
testDesc.origin = getTestOrigin();
const jsError = core.destructureError(new Error());
testDesc.location = {
fileName: jsError.frames[1].fileName,
lineNumber: jsError.frames[1].lineNumber,
columnNumber: jsError.frames[1].columnNumber,
};
testDesc.fn = wrapTest(testDesc);
const { id, filteredOut } = ops.op_register_test(testDesc);
const { id, origin } = ops.op_register_test(testDesc);
testDesc.id = id;
testDesc.filteredOut = filteredOut;
ArrayPrototypePush(testDescs, testDesc);
testDesc.origin = origin;
MapPrototypeSet(testStates, testDesc.id, {
context: createTestContext(testDesc),
children: [],
@ -673,7 +688,7 @@ function bench(
optionsOrFn,
maybeFn,
) {
if (!isBenchSubcommand) {
if (typeof ops.op_register_bench != "function") {
return;
}
@ -756,36 +771,13 @@ function bench(
benchDesc = { ...defaults, ...nameOrFnOrOptions, fn, name };
}
benchDesc.origin = getBenchOrigin();
const AsyncFunction = (async () => {}).constructor;
benchDesc.async = AsyncFunction === benchDesc.fn.constructor;
benchDesc.fn = wrapBenchmark(benchDesc);
const { id, filteredOut } = ops.op_register_bench(benchDesc);
const { id, origin } = ops.op_register_bench(benchDesc);
benchDesc.id = id;
benchDesc.filteredOut = filteredOut;
ArrayPrototypePush(benchDescs, benchDesc);
}
async function runTest(desc) {
if (desc.ignore) {
return "ignored";
}
let testFn = wrapTestFnWithSanitizers(desc.fn, desc);
if (!("parent" in desc) && desc.permissions) {
testFn = withPermissions(
testFn,
desc.permissions,
);
}
try {
const result = await testFn(desc);
if (result) return result;
const failedSteps = failedChildStepsCount(desc);
return failedSteps === 0 ? "ok" : { failed: { failedSteps } };
} catch (error) {
return { failed: { jsError: core.destructureError(error) } };
}
benchDesc.origin = origin;
}
function compareMeasurements(a, b) {
@ -808,8 +800,7 @@ function benchStats(n, highPrecision, avg, min, max, all) {
};
}
async function benchMeasure(timeBudget, desc) {
const fn = desc.fn;
async function benchMeasure(timeBudget, fn, async) {
let n = 0;
let avg = 0;
let wavg = 0;
@ -823,7 +814,7 @@ async function benchMeasure(timeBudget, desc) {
let iterations = 20;
let budget = 10 * 1e6;
if (!desc.async) {
if (!async) {
while (budget > 0 || iterations-- > 0) {
const t1 = benchNow();
@ -854,7 +845,7 @@ async function benchMeasure(timeBudget, desc) {
let iterations = 10;
let budget = timeBudget * 1e6;
if (!desc.async) {
if (!async) {
while (budget > 0 || iterations-- > 0) {
const t1 = benchNow();
@ -887,7 +878,7 @@ async function benchMeasure(timeBudget, desc) {
let iterations = 10;
let budget = timeBudget * 1e6;
if (!desc.async) {
if (!async) {
while (budget > 0 || iterations-- > 0) {
const t1 = benchNow();
for (let c = 0; c < lowPrecisionThresholdInNs; c++) fn();
@ -920,173 +911,49 @@ async function benchMeasure(timeBudget, desc) {
return benchStats(n, wavg > lowPrecisionThresholdInNs, avg, min, max, all);
}
async function runBench(desc) {
let token = null;
/** Wrap a user benchmark function in one which returns a structured result. */
function wrapBenchmark(desc) {
const fn = desc.fn;
return async function outerWrapped() {
let token = null;
const originalConsole = globalThis.console;
try {
if (desc.permissions) {
token = pledgePermissions(desc.permissions);
}
if (desc.sanitizeExit) {
setExitHandler((exitCode) => {
assert(
false,
`Bench attempted to exit with exit code: ${exitCode}`,
);
try {
globalThis.console = new Console((s) => {
ops.op_dispatch_bench_event({ output: s });
});
if (desc.permissions) {
token = pledgePermissions(desc.permissions);
}
if (desc.sanitizeExit) {
setExitHandler((exitCode) => {
assert(
false,
`Bench attempted to exit with exit code: ${exitCode}`,
);
});
}
const benchTimeInMs = 500;
const stats = await benchMeasure(benchTimeInMs, fn, desc.async);
return { ok: stats };
} catch (error) {
return { failed: core.destructureError(error) };
} finally {
globalThis.console = originalConsole;
if (bench.sanitizeExit) setExitHandler(null);
if (token !== null) restorePermissions(token);
}
const benchTimeInMs = 500;
const stats = await benchMeasure(benchTimeInMs, desc);
return { ok: stats };
} catch (error) {
return { failed: core.destructureError(error) };
} finally {
if (bench.sanitizeExit) setExitHandler(null);
if (token !== null) restorePermissions(token);
}
}
let origin = null;
function getTestOrigin() {
if (origin == null) {
origin = ops.op_get_test_origin();
}
return origin;
}
function getBenchOrigin() {
if (origin == null) {
origin = ops.op_get_bench_origin();
}
return origin;
};
}
function benchNow() {
return ops.op_bench_now();
}
function enableTest() {
isTestSubcommand = true;
}
function enableBench() {
isBenchSubcommand = true;
}
async function runTests({
shuffle = null,
} = {}) {
core.setMacrotaskCallback(handleOpSanitizerDelayMacrotask);
const origin = getTestOrigin();
const only = ArrayPrototypeFilter(testDescs, (test) => test.only);
const filtered = ArrayPrototypeFilter(
only.length > 0 ? only : testDescs,
(desc) => !desc.filteredOut,
);
ops.op_dispatch_test_event({
plan: {
origin,
total: filtered.length,
filteredOut: testDescs.length - filtered.length,
usedOnly: only.length > 0,
},
});
if (shuffle !== null) {
// http://en.wikipedia.org/wiki/Linear_congruential_generator
// Use BigInt for everything because the random seed is u64.
const nextInt = function (state) {
const m = 0x80000000n;
const a = 1103515245n;
const c = 12345n;
return function (max) {
return state = ((a * state + c) % m) % BigInt(max);
};
}(BigInt(shuffle));
for (let i = filtered.length - 1; i > 0; i--) {
const j = nextInt(i);
[filtered[i], filtered[j]] = [filtered[j], filtered[i]];
}
}
for (const desc of filtered) {
if (ops.op_tests_should_stop()) {
break;
}
ops.op_dispatch_test_event({ wait: desc.id });
const earlier = DateNow();
const result = await runTest(desc);
const elapsed = DateNow() - earlier;
const state = MapPrototypeGet(testStates, desc.id);
state.completed = true;
for (const childDesc of state.children) {
stepReportResult(childDesc, { failed: "incomplete" }, 0);
}
ops.op_dispatch_test_event({
result: [desc.id, result, elapsed],
});
}
}
async function runBenchmarks() {
core.setMacrotaskCallback(handleOpSanitizerDelayMacrotask);
const origin = getBenchOrigin();
const originalConsole = globalThis.console;
globalThis.console = new Console((s) => {
ops.op_dispatch_bench_event({ output: s });
});
const only = ArrayPrototypeFilter(benchDescs, (bench) => bench.only);
const filtered = ArrayPrototypeFilter(
only.length > 0 ? only : benchDescs,
(desc) => !desc.filteredOut && !desc.ignore,
);
let groups = new Set();
// make sure ungrouped benchmarks are placed above grouped
groups.add(undefined);
for (const desc of filtered) {
desc.group ||= undefined;
groups.add(desc.group);
}
groups = ArrayFrom(groups);
ArrayPrototypeSort(
filtered,
(a, b) => groups.indexOf(a.group) - groups.indexOf(b.group),
);
ops.op_dispatch_bench_event({
plan: {
origin,
total: filtered.length,
usedOnly: only.length > 0,
names: ArrayPrototypeMap(filtered, (desc) => desc.name),
},
});
for (const desc of filtered) {
desc.baseline = !!desc.baseline;
ops.op_dispatch_bench_event({ wait: desc.id });
ops.op_dispatch_bench_event({
result: [desc.id, await runBench(desc)],
});
}
globalThis.console = originalConsole;
}
function getFullName(desc) {
if ("parent" in desc) {
return `${getFullName(desc.parent)} ... ${desc.name}`;
@ -1108,13 +975,6 @@ function stepReportResult(desc, result, elapsed) {
});
}
function failedChildStepsCount(desc) {
return ArrayPrototypeFilter(
MapPrototypeGet(testStates, desc.id).children,
(d) => MapPrototypeGet(testStates, d.id).failed,
).length;
}
/** @param desc {TestDescription | TestStepDescription} */
function createTestContext(desc) {
let parent;
@ -1191,7 +1051,6 @@ function createTestContext(desc) {
stepDesc.sanitizeOps ??= desc.sanitizeOps;
stepDesc.sanitizeResources ??= desc.sanitizeResources;
stepDesc.sanitizeExit ??= desc.sanitizeExit;
stepDesc.origin = getTestOrigin();
const jsError = core.destructureError(new Error());
stepDesc.location = {
fileName: jsError.frames[1].fileName,
@ -1202,8 +1061,10 @@ function createTestContext(desc) {
stepDesc.parent = desc;
stepDesc.rootId = rootId;
stepDesc.rootName = rootName;
const { id } = ops.op_register_test_step(stepDesc);
stepDesc.fn = wrapTest(stepDesc);
const { id, origin } = ops.op_register_test_step(stepDesc);
stepDesc.id = id;
stepDesc.origin = origin;
const state = {
context: createTestContext(stepDesc),
children: [],
@ -1218,10 +1079,9 @@ function createTestContext(desc) {
ops.op_dispatch_test_event({ stepWait: stepDesc.id });
const earlier = DateNow();
const result = await runTest(stepDesc);
const result = await stepDesc.fn(stepDesc);
const elapsed = DateNow() - earlier;
state.failed = !!result.failed;
state.completed = true;
stepReportResult(stepDesc, result, elapsed);
return result == "ok";
},
@ -1229,37 +1089,29 @@ function createTestContext(desc) {
}
/**
* Wrap a user test function in one which returns a structured result.
* @template T {Function}
* @param testFn {T}
* @param opts {{
* sanitizeOps: boolean,
* sanitizeResources: boolean,
* sanitizeExit: boolean,
* }}
* @param desc {TestDescription | TestStepDescription}
* @returns {T}
*/
function wrapTestFnWithSanitizers(testFn, opts) {
testFn = assertTestStepScopes(testFn);
if (opts.sanitizeOps) {
function wrapTest(desc) {
let testFn = wrapInner(desc.fn);
if (desc.sanitizeOps) {
testFn = assertOps(testFn);
}
if (opts.sanitizeResources) {
if (desc.sanitizeResources) {
testFn = assertResources(testFn);
}
if (opts.sanitizeExit) {
if (desc.sanitizeExit) {
testFn = assertExit(testFn, true);
}
return testFn;
if (!("parent" in desc) && desc.permissions) {
testFn = withPermissions(testFn, desc.permissions);
}
return wrapOuter(testFn, desc);
}
internals.testing = {
runTests,
runBenchmarks,
enableTest,
enableBench,
};
import { denoNs } from "ext:runtime/90_deno_ns.js";
denoNs.bench = bench;
denoNs.test = test;