Skip to content

Commit 4be99ff

Browse files
authored
fix(ext/node): node:test improvements (#33929)
- attach some missing functions to default export - fix globalThis leaking into mock function - support module level hooks - enables 5 node compat tests Fixes #33926
1 parent e69a05d commit 4be99ff

2 files changed

Lines changed: 113 additions & 18 deletions

File tree

ext/node/polyfills/testing.ts

Lines changed: 108 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// deno-lint-ignore-file prefer-primordials
44

55
(function () {
6+
"use strict";
67
const { core, primordials } = globalThis.__bootstrap;
78
const {
89
ArrayPrototypeForEach,
@@ -21,6 +22,7 @@ const {
2122
Promise,
2223
PromisePrototypeThen,
2324
ReflectApply,
25+
ReflectConstruct,
2426
SafeArrayIterator,
2527
SafeMap,
2628
String,
@@ -327,33 +329,65 @@ class NodeTestContext {
327329

328330
let currentSuite = null;
329331

332+
const rootBeforeHooks = [];
333+
const rootAfterHooks = [];
334+
const rootBeforeEachHooks = [];
335+
const rootAfterEachHooks = [];
336+
let rootBeforeRan = false;
337+
338+
async function runRootBeforeOnce() {
339+
if (rootBeforeRan) return;
340+
rootBeforeRan = true;
341+
if (rootBeforeHooks.length === 0) return;
342+
const rootCtx = { name: "<root>", fullName: "<root>" };
343+
for (const hook of new SafeArrayIterator(rootBeforeHooks)) {
344+
await hook(rootCtx);
345+
}
346+
}
347+
348+
async function runRootAfterIfDone() {
349+
if (activeNodeTests !== 0) return;
350+
if (rootAfterHooks.length === 0) return;
351+
const rootCtx = { name: "<root>", fullName: "<root>" };
352+
// Snapshot and clear so we only run once even if more tests get queued.
353+
const hooks = ArrayPrototypeSplice(rootAfterHooks, 0, rootAfterHooks.length);
354+
for (const hook of new SafeArrayIterator(hooks)) {
355+
try {
356+
await hook(rootCtx);
357+
} catch { /* ignore */ }
358+
}
359+
}
360+
330361
class TestSuite {
331362
#denoTestContext;
363+
nodeTestContext;
332364
entries = [];
333365
beforeAllHooks = [];
334366
afterAllHooks = [];
335367
beforeEachHooks = [];
336368
afterEachHooks = [];
337369

338-
constructor(t) {
370+
constructor(t, nodeTestContext) {
339371
this.#denoTestContext = t;
372+
this.nodeTestContext = nodeTestContext;
340373
}
341374

342375
addTest(name, options, fn, overrides) {
343376
const prepared = prepareOptions(name, options, fn, overrides);
344377
const beforeEach = this.beforeEachHooks;
345378
const afterEach = this.afterEachHooks;
379+
const suiteNodeContext = this.nodeTestContext;
346380
ArrayPrototypePush(this.entries, {
347381
name: prepared.name,
348382
fn: async (denoTestContext) => {
349383
const newNodeTextContext = new NodeTestContext(
350384
denoTestContext,
351-
undefined,
385+
suiteNodeContext,
352386
prepared.name,
353387
);
354388
try {
355389
for (const hook of new SafeArrayIterator(beforeEach)) {
356-
await hook();
390+
await hook(newNodeTextContext);
357391
}
358392
const result = await prepared.fn(newNodeTextContext);
359393
newNodeTextContext._checkPlan();
@@ -366,7 +400,9 @@ class TestSuite {
366400
}
367401
} finally {
368402
for (const hook of new SafeArrayIterator(afterEach)) {
369-
await hook();
403+
try {
404+
await hook(newNodeTextContext);
405+
} catch { /* ignore */ }
370406
}
371407
}
372408
},
@@ -377,9 +413,10 @@ class TestSuite {
377413
addSuite(name, options, fn, overrides) {
378414
const prepared = prepareOptions(name, options, fn, overrides);
379415
const { promise, resolve } = Promise.withResolvers();
416+
const parentSuiteContext = this.nodeTestContext;
380417
ArrayPrototypePush(this.entries, {
381418
name: prepared.name,
382-
fn: wrapSuiteFn(prepared.fn, resolve),
419+
fn: wrapSuiteFn(prepared.fn, resolve, prepared.name, parentSuiteContext),
383420
ignore: !!prepared.options.todo || !!prepared.options.skip,
384421
});
385422
return promise;
@@ -429,7 +466,13 @@ function prepareOptions(name, options, fn, overrides) {
429466
function wrapTestFn(fn, resolve, name) {
430467
return async function (t) {
431468
const nodeTestContext = new NodeTestContext(t, undefined, name);
469+
let beforeEachOk = false;
432470
try {
471+
await runRootBeforeOnce();
472+
for (const hook of new SafeArrayIterator(rootBeforeEachHooks)) {
473+
await hook(nodeTestContext);
474+
}
475+
beforeEachOk = true;
433476
if (fn.length >= 2) {
434477
await new Promise((testResolve, testReject) => {
435478
pendingCallbackReject = testReject;
@@ -469,7 +512,15 @@ function wrapTestFn(fn, resolve, name) {
469512
throw sanitizeThrowValue(err);
470513
}
471514
} finally {
515+
if (beforeEachOk) {
516+
for (const hook of new SafeArrayIterator(rootAfterEachHooks)) {
517+
try {
518+
await hook(nodeTestContext);
519+
} catch { /* swallow to match node behavior on hook error */ }
520+
}
521+
}
472522
activeNodeTests--;
523+
await runRootAfterIfDone();
473524
resolve();
474525
}
475526
};
@@ -496,12 +547,15 @@ function prepareDenoTest(name, options, fn, overrides) {
496547
return promise;
497548
}
498549

499-
function wrapSuiteFn(fn, resolve) {
550+
function wrapSuiteFn(fn, resolve, name, parentNodeContext) {
500551
return async function (t) {
552+
const isTopLevel = parentNodeContext === undefined;
553+
if (isTopLevel) await runRootBeforeOnce();
554+
const suiteNodeContext = new NodeTestContext(t, parentNodeContext, name);
501555
const prevSuite = currentSuite;
502-
const suite = currentSuite = new TestSuite(t);
556+
const suite = currentSuite = new TestSuite(t, suiteNodeContext);
503557
try {
504-
fn();
558+
fn(suiteNodeContext);
505559
} finally {
506560
currentSuite = prevSuite;
507561
}
@@ -516,7 +570,10 @@ function wrapSuiteFn(fn, resolve) {
516570
await hook();
517571
}
518572
} finally {
519-
activeNodeTests--;
573+
if (isTopLevel) {
574+
activeNodeTests--;
575+
await runRootAfterIfDone();
576+
}
520577
resolve();
521578
}
522579
}
@@ -532,7 +589,7 @@ function prepareDenoTestForSuite(name, options, fn, overrides) {
532589

533590
const denoTestOptions = {
534591
name: prepared.name,
535-
fn: wrapSuiteFn(prepared.fn, resolve),
592+
fn: wrapSuiteFn(prepared.fn, resolve, prepared.name, undefined),
536593
only: prepared.options.only,
537594
ignore: !!prepared.options.todo || !!prepared.options.skip,
538595
sanitizeOnly: false,
@@ -593,7 +650,7 @@ function before(fn, _options) {
593650
ArrayPrototypePush(currentSuite.beforeAllHooks, fn);
594651
return;
595652
}
596-
notImplemented("test.before (module-level, outside suite)");
653+
ArrayPrototypePush(rootBeforeHooks, fn);
597654
}
598655

599656
function after(fn, _options) {
@@ -604,7 +661,7 @@ function after(fn, _options) {
604661
ArrayPrototypePush(currentSuite.afterAllHooks, fn);
605662
return;
606663
}
607-
notImplemented("test.after (module-level, outside suite)");
664+
ArrayPrototypePush(rootAfterHooks, fn);
608665
}
609666

610667
function beforeEach(fn, _options) {
@@ -615,7 +672,7 @@ function beforeEach(fn, _options) {
615672
ArrayPrototypePush(currentSuite.beforeEachHooks, fn);
616673
return;
617674
}
618-
notImplemented("test.beforeEach (module-level, outside suite)");
675+
ArrayPrototypePush(rootBeforeEachHooks, fn);
619676
}
620677

621678
function afterEach(fn, _options) {
@@ -626,7 +683,7 @@ function afterEach(fn, _options) {
626683
ArrayPrototypePush(currentSuite.afterEachHooks, fn);
627684
return;
628685
}
629-
notImplemented("test.afterEach (module-level, outside suite)");
686+
ArrayPrototypePush(rootAfterEachHooks, fn);
630687
}
631688

632689
test.it = test;
@@ -679,23 +736,26 @@ class MockFunctionContext {
679736
this.#restore();
680737
this.#restore = undefined;
681738
}
739+
this._restored = true;
682740
const idx = ArrayPrototypeIndexOf(activeMocks, this);
683741
if (idx !== -1) {
684742
ArrayPrototypeSplice(activeMocks, idx, 1);
685743
}
686744
}
687745

688-
_recordCall(thisArg, args, result, error) {
746+
_recordCall(thisArg, args, result, error, target) {
689747
ArrayPrototypePush(this.#calls, {
690748
arguments: args,
691749
error,
692750
result,
693751
stack: new Error(),
752+
target,
694753
this: thisArg,
695754
});
696755
}
697756

698757
_shouldMock() {
758+
if (this._restored) return false;
699759
if (this.#times === undefined) return true;
700760
return this.#calls.length < this.#times;
701761
}
@@ -717,22 +777,47 @@ class MockFunctionContext {
717777

718778
function createMockFunction(original, implementation, ctx) {
719779
const mockFn = function (...args) {
780+
const newTarget = new.target;
781+
const isCtor = newTarget !== undefined;
782+
// The IIFE wrapping this module is sloppy, so a plain call leaks
783+
// globalThis as `this`. Match strict-mode/Node semantics.
784+
const thisArg = !isCtor && this === globalThis ? undefined : this;
720785
const impl = ctx._shouldMock()
721786
? (ctx._nextImpl() ?? implementation ?? original)
722787
: original;
723788

724789
let result;
725790
let error;
726791

792+
// If called directly (not via subclass), use the original constructor
793+
// so the produced instance has its prototype, and so call.target reports
794+
// the user's class (not the mock wrapper).
795+
const ctorTarget = isCtor && newTarget === mockFn ? impl : newTarget;
727796
try {
728-
result = impl ? ReflectApply(impl, this, args) : undefined;
797+
if (isCtor) {
798+
result = impl ? ReflectConstruct(impl, args, ctorTarget) : undefined;
799+
} else {
800+
result = impl ? ReflectApply(impl, thisArg, args) : undefined;
801+
}
729802
} catch (e) {
730803
error = e;
731-
ctx._recordCall(this, args, undefined, error);
804+
ctx._recordCall(
805+
isCtor ? thisArg : thisArg,
806+
args,
807+
undefined,
808+
error,
809+
ctorTarget,
810+
);
732811
throw e;
733812
}
734813

735-
ctx._recordCall(this, args, result);
814+
ctx._recordCall(
815+
isCtor ? result : thisArg,
816+
args,
817+
result,
818+
undefined,
819+
ctorTarget,
820+
);
736821
return result;
737822
};
738823

@@ -907,6 +992,11 @@ const mock = {
907992

908993
test.test = test;
909994
test.mock = mock;
995+
test.before = before;
996+
test.after = after;
997+
test.beforeEach = beforeEach;
998+
test.afterEach = afterEach;
999+
test.run = run;
9101000

9111001
return {
9121002
run,

tests/node_compat/config.jsonc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2920,6 +2920,7 @@
29202920
"parallel/test-require-resolve.js": {},
29212921
"parallel/test-require-resolve-invalid-paths.js": {},
29222922
"parallel/test-require-resolve-opts-paths-relative.js": {},
2923+
"parallel/test-runner-aftereach-runtime-skip.js": {},
29232924
"parallel/test-runner-aliases.js": {},
29242925
"parallel/test-runner-cli-concurrency.js": {
29252926
"ignore": true,
@@ -2939,6 +2940,7 @@
29392940
// "parallel/test-runner-coverage-thresholds.js": {},
29402941
"parallel/test-runner-enable-source-maps-issue.js": {},
29412942
"parallel/test-runner-filter-warning.js": {},
2943+
"parallel/test-runner-root-after-with-refed-handles.js": {},
29422944
"parallel/test-runner-snapshot-file-tests.js": {
29432945
"ignore": true,
29442946
"reason": "Node.js snapshot/heap profiling features (--build-snapshot, --heap-prof, --heapsnapshot-near-heap-limit) are not implemented in Deno"
@@ -4070,6 +4072,8 @@
40704072
"ignore": true,
40714073
"reason": "Node.js system CA certificate handling is not implemented in Deno"
40724074
},
4075+
"test-runner/test-output-arbitrary-output-colored.mjs": {},
4076+
"test-runner/test-output-no-tests.mjs": {},
40734077
"test-runner/test-output-output-cli.mjs": {
40744078
"ignore": true,
40754079
"reason": "Tests Node.js-specific CLI flags/options that are not supported in Deno"
@@ -4078,6 +4082,7 @@
40784082
"ignore": true,
40794083
"reason": "Tests Node.js-specific CLI flags/options that are not supported in Deno"
40804084
},
4085+
"test-runner/test-output-typescript-coverage.mjs": {},
40814086
// TODO(bartlomieju): disabled during work on `node:inspector`, this test didn't actualy run before
40824087
// "sequential/test-watch-mode-inspect.mjs": {},
40834088
// TODO(bartlomieju): this test is very flaky on CI, especially on Windows ARM

0 commit comments

Comments
 (0)