@@ -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
145143const 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+
147166class 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
268353let 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+
270364class 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
322437function 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
429545function 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) {
509636export const it = test ;
510637export 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
528683test . it = test ;
0 commit comments