Skip to content

Commit be0502b

Browse files
authored
fix(ext/node): implement missing node:test APIs (#33764)
Incremental improvements to the `node:test` polyfill, implementing several previously-stubbed APIs: - **`t.name`, `t.fullName`, `t.signal`** - were throwing `notImplemented`, now return the test name, full parent chain, and an `AbortSignal` - **`t.plan(count)`** - assertion planning with count verification at test completion - **Suite-level hooks** - `before()`/`after()`/`beforeEach()`/`afterEach()` now work inside `describe`/`suite` blocks - **`t.beforeEach()` / `t.afterEach()`** - per-subtest hooks on `TestContext`
1 parent 0f570e2 commit be0502b

5 files changed

Lines changed: 322 additions & 40 deletions

File tree

ext/node/polyfills/testing.ts

Lines changed: 194 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,6 @@ const {
2020
ReflectApply,
2121
SafeArrayIterator,
2222
SafeMap,
23-
SafePromiseAll,
24-
SafePromisePrototypeFinally,
2523
String,
2624
Symbol,
2725
SymbolFor,
@@ -144,34 +142,95 @@ function noop() {}
144142

145143
const skippedSymbol = Symbol("skipped");
146144

145+
class TestPlan {
146+
#expected: number;
147+
#actual: number = 0;
148+
149+
constructor(count: number) {
150+
this.#expected = count;
151+
}
152+
153+
increment() {
154+
this.#actual++;
155+
}
156+
157+
check() {
158+
if (this.#actual !== this.#expected) {
159+
throw new Error(
160+
`plan expected ${this.#expected} assertion(s) but received ${this.#actual}`,
161+
);
162+
}
163+
}
164+
}
165+
147166
class NodeTestContext {
148167
#denoContext: Deno.TestContext;
149168
#afterHooks: (() => void)[] = [];
150169
#beforeHooks: (() => void)[] = [];
151170
#parent: NodeTestContext | undefined;
152171
#skipped = false;
172+
#name: string;
173+
#abortController: AbortController = new AbortController();
174+
#plan: TestPlan | undefined;
175+
#planAssert: Record<string, unknown> | undefined;
176+
#beforeEachHooks: (() => void | Promise<void>)[] = [];
177+
#afterEachHooks: (() => void | Promise<void>)[] = [];
153178

154-
constructor(t: Deno.TestContext, parent: NodeTestContext | undefined) {
179+
constructor(
180+
t: Deno.TestContext,
181+
parent: NodeTestContext | undefined,
182+
name: string,
183+
) {
155184
this.#denoContext = t;
156185
this.#parent = parent;
186+
this.#name = name;
157187
}
158188

159189
get [skippedSymbol]() {
160190
return this.#skipped || (this.#parent?.[skippedSymbol] ?? false);
161191
}
162192

163193
get assert() {
194+
if (this.#plan) {
195+
if (!this.#planAssert) {
196+
const plan = this.#plan;
197+
const base = getAssertObject();
198+
const wrapped = { __proto__: null };
199+
ArrayPrototypeForEach(methodsToCopy, (method) => {
200+
wrapped[method] = function (...args) {
201+
plan.increment();
202+
return ReflectApply(base[method], this, args);
203+
};
204+
});
205+
this.#planAssert = wrapped;
206+
}
207+
return this.#planAssert;
208+
}
164209
return getAssertObject();
165210
}
166211

212+
plan(count: number) {
213+
validateInteger(count, "count", 1);
214+
this.#plan = new TestPlan(count);
215+
}
216+
217+
_checkPlan() {
218+
if (this.#plan) this.#plan.check();
219+
}
220+
167221
get signal() {
168-
notImplemented("test.TestContext.signal");
169-
return null;
222+
return this.#abortController.signal;
170223
}
171224

172225
get name() {
173-
notImplemented("test.TestContext.name");
174-
return null;
226+
return this.#name;
227+
}
228+
229+
get fullName() {
230+
if (this.#parent) {
231+
return this.#parent.fullName + " > " + this.#name;
232+
}
233+
return this.#name;
175234
}
176235

177236
diagnostic(message) {
@@ -200,6 +259,9 @@ class NodeTestContext {
200259

201260
test(name, options, fn) {
202261
const prepared = prepareOptions(name, options, fn, {});
262+
// Subtests count toward the parent's plan (Node counts both
263+
// assertions and subtests).
264+
if (this.#plan) this.#plan.increment();
203265
// deno-lint-ignore no-this-alias
204266
const parentContext = this;
205267
const after = async () => {
@@ -219,10 +281,19 @@ class NodeTestContext {
219281
const newNodeTextContext = new NodeTestContext(
220282
denoTestContext,
221283
parentContext,
284+
prepared.name,
222285
);
223286
try {
224287
await before();
288+
for (
289+
const hook of new SafeArrayIterator(
290+
parentContext.#beforeEachHooks,
291+
)
292+
) {
293+
await hook();
294+
}
225295
await prepared.fn(newNodeTextContext);
296+
newNodeTextContext._checkPlan();
226297
await after();
227298
} catch (err) {
228299
if (!newNodeTextContext[skippedSymbol]) {
@@ -231,6 +302,14 @@ class NodeTestContext {
231302
try {
232303
await after();
233304
} catch { /* ignore, test is already failing */ }
305+
} finally {
306+
for (
307+
const hook of new SafeArrayIterator(
308+
parentContext.#afterEachHooks,
309+
)
310+
) {
311+
await hook();
312+
}
234313
}
235314
},
236315
ignore: !!prepared.options.todo || !!prepared.options.skip,
@@ -256,67 +335,103 @@ class NodeTestContext {
256335
ArrayPrototypePush(this.#afterHooks, fn);
257336
}
258337

259-
beforeEach(_fn, _options) {
260-
notImplemented("test.TestContext.beforeEach");
338+
beforeEach(fn, _options) {
339+
if (typeof fn !== "function") {
340+
throw new TypeError("beforeEach() requires a function");
341+
}
342+
ArrayPrototypePush(this.#beforeEachHooks, fn);
261343
}
262344

263-
afterEach(_fn, _options) {
264-
notImplemented("test.TestContext.afterEach");
345+
afterEach(fn, _options) {
346+
if (typeof fn !== "function") {
347+
throw new TypeError("afterEach() requires a function");
348+
}
349+
ArrayPrototypePush(this.#afterEachHooks, fn);
265350
}
266351
}
267352

268353
let currentSuite: TestSuite | null = null;
269354

355+
// Entries are step definitions collected during the suite body. Steps are
356+
// not created via t.step() until the suite executes, so that before/after
357+
// hooks run at the correct time.
358+
interface SuiteEntry {
359+
name: string;
360+
fn: (t: Deno.TestContext) => Promise<void> | void;
361+
ignore: boolean;
362+
}
363+
270364
class TestSuite {
271365
#denoTestContext: Deno.TestContext;
272-
steps: Promise<boolean>[] = [];
366+
entries: SuiteEntry[] = [];
367+
beforeAllHooks: (() => void | Promise<void>)[] = [];
368+
afterAllHooks: (() => void | Promise<void>)[] = [];
369+
beforeEachHooks: (() => void | Promise<void>)[] = [];
370+
afterEachHooks: (() => void | Promise<void>)[] = [];
273371

274372
constructor(t: Deno.TestContext) {
275373
this.#denoTestContext = t;
276374
}
277375

278376
addTest(name, options, fn, overrides) {
279377
const prepared = prepareOptions(name, options, fn, overrides);
280-
const step = this.#denoTestContext.step({
378+
const beforeEach = this.beforeEachHooks;
379+
const afterEach = this.afterEachHooks;
380+
ArrayPrototypePush(this.entries, {
281381
name: prepared.name,
282382
fn: async (denoTestContext) => {
283383
const newNodeTextContext = new NodeTestContext(
284384
denoTestContext,
285385
undefined,
386+
prepared.name,
286387
);
287388
try {
288-
return await prepared.fn(newNodeTextContext);
389+
for (const hook of new SafeArrayIterator(beforeEach)) {
390+
await hook();
391+
}
392+
const result = await prepared.fn(newNodeTextContext);
393+
newNodeTextContext._checkPlan();
394+
return result;
289395
} catch (err) {
290396
if (newNodeTextContext[skippedSymbol]) {
291397
return undefined;
292398
} else {
293399
throw err;
294400
}
401+
} finally {
402+
for (const hook of new SafeArrayIterator(afterEach)) {
403+
await hook();
404+
}
295405
}
296406
},
297407
ignore: !!prepared.options.todo || !!prepared.options.skip,
298-
sanitizeExit: false,
299-
sanitizeOps: false,
300-
sanitizeResources: false,
301408
});
302-
ArrayPrototypePush(this.steps, step);
303409
}
304410

305411
addSuite(name, options, fn, overrides) {
306412
const prepared = prepareOptions(name, options, fn, overrides);
307413
// deno-lint-ignore prefer-primordials
308414
const { promise, resolve } = Promise.withResolvers();
309-
const step = this.#denoTestContext.step({
415+
ArrayPrototypePush(this.entries, {
310416
name: prepared.name,
311417
fn: wrapSuiteFn(prepared.fn, resolve),
312418
ignore: !!prepared.options.todo || !!prepared.options.skip,
313-
sanitizeExit: false,
314-
sanitizeOps: false,
315-
sanitizeResources: false,
316419
});
317-
ArrayPrototypePush(this.steps, step);
318420
return promise;
319421
}
422+
423+
async execute() {
424+
for (const entry of new SafeArrayIterator(this.entries)) {
425+
await this.#denoTestContext.step({
426+
name: entry.name,
427+
fn: entry.fn,
428+
ignore: entry.ignore,
429+
sanitizeExit: false,
430+
sanitizeOps: false,
431+
sanitizeResources: false,
432+
});
433+
}
434+
}
320435
}
321436

322437
function prepareOptions(name, options, fn, overrides) {
@@ -348,9 +463,9 @@ function prepareOptions(name, options, fn, overrides) {
348463
return { fn, options: finalOptions, name };
349464
}
350465

351-
function wrapTestFn(fn, resolve) {
466+
function wrapTestFn(fn, resolve, name) {
352467
return async function (t) {
353-
const nodeTestContext = new NodeTestContext(t, undefined);
468+
const nodeTestContext = new NodeTestContext(t, undefined, name);
354469
try {
355470
if (fn.length >= 2) {
356471
// Callback-style test
@@ -390,6 +505,7 @@ function wrapTestFn(fn, resolve) {
390505
// Promise-style or sync test
391506
await ReflectApply(fn, nodeTestContext, [nodeTestContext]);
392507
}
508+
nodeTestContext._checkPlan();
393509
} catch (err) {
394510
if (!nodeTestContext[skippedSymbol]) {
395511
throw sanitizeThrowValue(err);
@@ -414,7 +530,7 @@ function prepareDenoTest(name, options, fn, overrides) {
414530

415531
const denoTestOptions = {
416532
name: prepared.name,
417-
fn: wrapTestFn(prepared.fn, resolve),
533+
fn: wrapTestFn(prepared.fn, resolve, prepared.name),
418534
only: prepared.options.only,
419535
ignore: !!prepared.options.todo || !!prepared.options.skip,
420536
sanitizeOnly: false,
@@ -427,18 +543,29 @@ function prepareDenoTest(name, options, fn, overrides) {
427543
}
428544

429545
function wrapSuiteFn(fn, resolve) {
430-
return function (t) {
546+
return async function (t) {
431547
const prevSuite = currentSuite;
432548
const suite = currentSuite = new TestSuite(t);
433549
try {
434550
fn();
435551
} finally {
436552
currentSuite = prevSuite;
437553
}
438-
return SafePromisePrototypeFinally(SafePromiseAll(suite.steps), () => {
439-
activeNodeTests--;
440-
resolve();
441-
});
554+
try {
555+
for (const hook of new SafeArrayIterator(suite.beforeAllHooks)) {
556+
await hook();
557+
}
558+
await suite.execute();
559+
} finally {
560+
try {
561+
for (const hook of new SafeArrayIterator(suite.afterAllHooks)) {
562+
await hook();
563+
}
564+
} finally {
565+
activeNodeTests--;
566+
resolve();
567+
}
568+
}
442569
};
443570
}
444571

@@ -509,20 +636,48 @@ suite.only = function only(name, options, fn) {
509636
export const it = test;
510637
export const describe = suite;
511638

512-
export function before() {
513-
notImplemented("test.before");
639+
export function before(fn, _options) {
640+
if (typeof fn !== "function") {
641+
throw new TypeError("before() requires a function argument");
642+
}
643+
if (currentSuite) {
644+
ArrayPrototypePush(currentSuite.beforeAllHooks, fn);
645+
return;
646+
}
647+
notImplemented("test.before (module-level, outside suite)");
514648
}
515649

516-
export function after() {
517-
notImplemented("test.after");
650+
export function after(fn, _options) {
651+
if (typeof fn !== "function") {
652+
throw new TypeError("after() requires a function argument");
653+
}
654+
if (currentSuite) {
655+
ArrayPrototypePush(currentSuite.afterAllHooks, fn);
656+
return;
657+
}
658+
notImplemented("test.after (module-level, outside suite)");
518659
}
519660

520-
export function beforeEach() {
521-
notImplemented("test.beforeEach");
661+
export function beforeEach(fn, _options) {
662+
if (typeof fn !== "function") {
663+
throw new TypeError("beforeEach() requires a function argument");
664+
}
665+
if (currentSuite) {
666+
ArrayPrototypePush(currentSuite.beforeEachHooks, fn);
667+
return;
668+
}
669+
notImplemented("test.beforeEach (module-level, outside suite)");
522670
}
523671

524-
export function afterEach() {
525-
notImplemented("test.afterEach");
672+
export function afterEach(fn, _options) {
673+
if (typeof fn !== "function") {
674+
throw new TypeError("afterEach() requires a function argument");
675+
}
676+
if (currentSuite) {
677+
ArrayPrototypePush(currentSuite.afterEachHooks, fn);
678+
return;
679+
}
680+
notImplemented("test.afterEach (module-level, outside suite)");
526681
}
527682

528683
test.it = test;

0 commit comments

Comments
 (0)