4141// that header in the shared `fetch` wrapper instead of on the rotating routes
4242// would rewrite the stored credential on an ordinary write — and every
4343// assertion in ① and ② would stay green.
44+ // - `④ the cold start stays outside every clocked window` — pins WHERE the
45+ // first scenario's one-time cost is paid (next section).
46+ //
47+ // ## [#20327] Why one throwaway scenario runs at MODULE SCOPE
48+ //
49+ // The first scenario in a worker pays a one-time cost no later one sees:
50+ // better-auth's lazily imported module graph, sql.js's WASM compile, and the
51+ // first-use costs of the sync and the sign-up. The phases are measured in
52+ // `auth-login-register-envelope.test.ts`, which carries the same arrangement
53+ // and the same fix. Every case here used to carry an explicit `60_000`
54+ // timeout, which WIDENED the first case's window around that cost instead of
55+ // moving the cost out of it: every budget a cost is moved into can be
56+ // exhausted by a heavier shard (`check:test-source-alias`, clocked-window
57+ // rule).
58+ //
59+ // So the cost is now paid by a module-scope `await`, during COLLECTION, which
60+ // no vitest clock covers: `@vitest/runner@4.1.11` wraps hooks and test bodies
61+ // in `withTimeout(...)` and awaits the file import bare. This is the repo's
62+ // convention: "clocked windows measure behaviour, never loading" (AGENTS.md,
63+ // Build & Test). The warm-up is the file's own `signedIn()`, the arrangement
64+ // every case runs, so no list of loads can drift from what the cases pay. With
65+ // the cost moved out, the `60_000`s are gone and every case runs on the
66+ // default `testTimeout`.
67+ //
68+ // ⛔ It shares nothing a case asserts on. Its engine and manager are its own
69+ // and are destroyed before any case starts. Every case still builds a fresh
70+ // engine, a fresh `AuthManager` and a fresh sign-up. What it leaves warm is
71+ // process-level: the module registry, sql.js's compiled WASM and the JIT,
72+ // which the first case used to leave to every later case.
73+ //
74+ // ⛔ Do not move it into a hook, and do not answer a recurrence by raising a
75+ // timeout: vitest clocks a hook exactly as it clocks a test body.
4476
4577import { describe , it , expect , vi , beforeEach , afterEach } from 'vitest' ;
78+ import { readFileSync } from 'node:fs' ;
79+ import { fileURLToPath } from 'node:url' ;
4680import { createHmac } from 'node:crypto' ;
4781import { ObjectQL } from '@objectstack/objectql' ;
4882import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm' ;
@@ -261,12 +295,8 @@ const signedCredentialFor = async (
261295 return String ( signed ) ;
262296} ;
263297
264- beforeEach ( ( ) => {
265- vi . spyOn ( console , 'warn' ) . mockImplementation ( ( ) => { } ) ;
266- vi . spyOn ( console , 'error' ) . mockImplementation ( ( ) => { } ) ;
267- } ) ;
268- afterEach ( async ( ) => {
269- vi . restoreAllMocks ( ) ;
298+ /** Destroy every engine a scenario opened: after each case, and after the warm-up. */
299+ const closeEngines = async ( ) : Promise < void > => {
270300 while ( engines . length ) {
271301 const e = engines . pop ( ) ;
272302 try {
@@ -275,6 +305,21 @@ afterEach(async () => {
275305 /* noop */
276306 }
277307 }
308+ } ;
309+
310+ // [#20327] The first scenario's one-time cost, paid during COLLECTION, which
311+ // no vitest clock covers (header, last section). ⛔ It stays at module scope,
312+ // and `④` below pins that.
313+ await signedIn ( ) ;
314+ await closeEngines ( ) ;
315+
316+ beforeEach ( ( ) => {
317+ vi . spyOn ( console , 'warn' ) . mockImplementation ( ( ) => { } ) ;
318+ vi . spyOn ( console , 'error' ) . mockImplementation ( ( ) => { } ) ;
319+ } ) ;
320+ afterEach ( async ( ) => {
321+ vi . restoreAllMocks ( ) ;
322+ await closeEngines ( ) ;
278323} ) ;
279324
280325// ───────────────────────────────────────────────────────────────────────────
@@ -335,7 +380,7 @@ describe("#16534 ① the card's own probe, with the manual re-set deleted", () =
335380 // status code: after the whole sequence the client is still holding a
336381 // credential that resolves to the same principal.
337382 expect ( await principalForStoredToken ( manager , client ) ) . toBe ( userId ) ;
338- } , 60_000 ) ;
383+ } ) ;
339384} ) ;
340385
341386// ───────────────────────────────────────────────────────────────────────────
@@ -358,7 +403,7 @@ describe('#16534 ② one assertion per rotating route — all three', () => {
358403 expect ( await principalForStoredToken ( manager , client ) ) . toBe ( userId ) ;
359404 // …and the one it replaced is genuinely gone.
360405 expect ( await principalFor ( manager , before ) ) . toBeNull ( ) ;
361- } , 60_000 ) ;
406+ } ) ;
362407
363408 it ( 'changePassword WITHOUT revokeOtherSessions rotates nothing and stores nothing' , async ( ) => {
364409 // The other half of the same route: `token` is `null` there, and a client
@@ -374,7 +419,7 @@ describe('#16534 ② one assertion per rotating route — all three', () => {
374419 expect ( result . token ) . toBeNull ( ) ;
375420 expect ( storedToken ( client ) ) . toBe ( before ) ;
376421 expect ( await principalForStoredToken ( manager , client ) ) . not . toBeNull ( ) ;
377- } , 60_000 ) ;
422+ } ) ;
378423
379424 it ( 'twoFactor.verifyTotp on the enrolment lane — the body echoes the LIVE token' , async ( ) => {
380425 const { engine, manager, client, email } = await signedIn ( ) ;
@@ -390,7 +435,7 @@ describe('#16534 ② one assertion per rotating route — all three', () => {
390435 // The row behind the replaced value was deleted, so asserting only "the
391436 // stored token changed" would not have been enough.
392437 expect ( await principalFor ( manager , before ) ) . toBeNull ( ) ;
393- } , 60_000 ) ;
438+ } ) ;
394439
395440 it ( 'twoFactor.disable — the credential arrives ONLY in the `set-auth-token` header' , async ( ) => {
396441 // The route triage singled out: it answers `{ status: true }`, so an
@@ -410,7 +455,7 @@ describe('#16534 ② one assertion per rotating route — all three', () => {
410455 expect ( storedToken ( client ) ) . not . toBe ( before ) ;
411456 expect ( await principalForStoredToken ( manager , client ) ) . toBe ( userId ) ;
412457 expect ( await principalFor ( manager , before ) ) . toBeNull ( ) ;
413- } , 60_000 ) ;
458+ } ) ;
414459} ) ;
415460
416461// ───────────────────────────────────────────────────────────────────────────
@@ -439,7 +484,7 @@ describe('#16534 ③ the negative control — a NON-rotating route changes nothi
439484 // Still the same live session at the end of it — the invariant is "did not
440485 // move", not "was emptied".
441486 expect ( await principalForStoredToken ( manager , client ) ) . not . toBeNull ( ) ;
442- } , 60_000 ) ;
487+ } ) ;
443488
444489 it ( "verifyBackupCode's already-logged-in lane leaves the stored credential byte-identical" , async ( ) => {
445490 // `/two-factor/verify-backup-code` shares `AuthTwoFactorVerificationResult`
@@ -472,5 +517,35 @@ describe('#16534 ③ the negative control — a NON-rotating route changes nothi
472517 expect ( result . token ) . not . toBe ( signed ) ;
473518 expect ( storedToken ( bearerClient ) , 'verifyBackupCode moved the stored credential' ) . toBe ( signed ) ;
474519 expect ( await principalForStoredToken ( manager , bearerClient ) ) . toBe ( userId ) ;
475- } , 60_000 ) ;
520+ } ) ;
521+ } ) ;
522+
523+ // ───────────────────────────────────────────────────────────────────────────
524+ describe ( '#16534 ④ the cold start stays outside every clocked window' , ( ) => {
525+ it ( 'pays the first scenario at module scope, no hook carries it, and no case widens its clock' , ( ) => {
526+ // [#20327] Read off this file's own text, so "do not move it into a
527+ // hook" is an assertion rather than a sentence nobody reads.
528+ const code = readFileSync ( fileURLToPath ( import . meta. url ) , 'utf8' ) ;
529+
530+ // Exactly one warm-up call, and it opens its own line at column 0. So it
531+ // sits in no function body, which is what "paid during collection"
532+ // reduces to. Comment lines start with `//` and cannot match.
533+ expect ( code . match ( / ^ a w a i t s i g n e d I n \( \) ; $ / gm) ?? [ ] ) . toHaveLength ( 1 ) ;
534+
535+ // ⛔ No `before*` hook may come back to carry it: vitest clocks a hook
536+ // with `hookTimeout` exactly as it clocks a test body with `testTimeout`.
537+ // This file's one hook is the console-spy `beforeEach`. Each hook is read
538+ // up to the next column-0 ` });`, so a hook indented inside a `describe`
539+ // reads on to that block's close and cannot hide a scenario from this.
540+ const hooks = [ ...code . matchAll ( / ^ \s * b e f o r e (?: A l l | E a c h ) \s * \( ( [ \s \S ] * ?) ^ \} \) ; $ / gm) ] . map (
541+ ( m ) => m [ 1 ] ,
542+ ) ;
543+ expect ( hooks ) . toHaveLength ( 1 ) ;
544+ for ( const body of hooks ) expect ( body ) . not . toMatch ( / \b ( s i g n e d I n | a r r a n g e ) \( / ) ;
545+
546+ // ⛔ And no case widens its own clock again. Every case here used to end
547+ // `}, 60_000);`, which kept the cold start inside a wider window instead
548+ // of moving it out.
549+ expect ( code ) . not . toMatch ( / ^ \s * \} , \s * [ \d _ ] + \s * \) ; $ / m) ;
550+ } ) ;
476551} ) ;
0 commit comments