@remix-run/test 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +43 -44
  2. package/dist/app/client/entry.js +4 -0
  3. package/dist/app/server.d.ts.map +1 -1
  4. package/dist/app/server.js +10 -10
  5. package/dist/cli.d.ts +30 -0
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +87 -23
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/lib/config.d.ts +55 -21
  11. package/dist/lib/config.d.ts.map +1 -1
  12. package/dist/lib/config.js +82 -33
  13. package/dist/lib/context.d.ts +5 -5
  14. package/dist/lib/coverage-loader.js +2 -2
  15. package/dist/lib/coverage.js +1 -1
  16. package/dist/lib/fake-timers.d.ts +39 -0
  17. package/dist/lib/fake-timers.d.ts.map +1 -1
  18. package/dist/lib/fake-timers.js +27 -8
  19. package/dist/lib/framework.d.ts +12 -6
  20. package/dist/lib/framework.d.ts.map +1 -1
  21. package/dist/lib/framework.js +24 -12
  22. package/dist/lib/import-module.d.ts.map +1 -1
  23. package/dist/lib/import-module.js +13 -3
  24. package/dist/lib/reporters/dot.d.ts.map +1 -1
  25. package/dist/lib/reporters/dot.js +10 -0
  26. package/dist/lib/reporters/files.d.ts.map +1 -1
  27. package/dist/lib/reporters/files.js +10 -0
  28. package/dist/lib/reporters/results.d.ts +1 -1
  29. package/dist/lib/reporters/results.d.ts.map +1 -1
  30. package/dist/lib/reporters/spec.d.ts.map +1 -1
  31. package/dist/lib/reporters/spec.js +10 -0
  32. package/dist/lib/reporters/tap.d.ts.map +1 -1
  33. package/dist/lib/reporters/tap.js +10 -0
  34. package/dist/lib/runner-browser.d.ts.map +1 -1
  35. package/dist/lib/runner-browser.js +40 -2
  36. package/dist/lib/runner.d.ts +18 -1
  37. package/dist/lib/runner.d.ts.map +1 -1
  38. package/dist/lib/runner.js +187 -38
  39. package/dist/lib/worker-e2e-file.d.ts +11 -0
  40. package/dist/lib/worker-e2e-file.d.ts.map +1 -0
  41. package/dist/lib/worker-e2e-file.js +69 -0
  42. package/dist/lib/worker-e2e.js +11 -47
  43. package/dist/lib/worker-process.d.ts +2 -0
  44. package/dist/lib/worker-process.d.ts.map +1 -0
  45. package/dist/lib/worker-process.js +55 -0
  46. package/dist/lib/worker-results.d.ts +3 -0
  47. package/dist/lib/worker-results.d.ts.map +1 -0
  48. package/dist/lib/worker-results.js +20 -0
  49. package/dist/lib/worker-server.d.ts +10 -0
  50. package/dist/lib/worker-server.d.ts.map +1 -0
  51. package/dist/lib/worker-server.js +112 -0
  52. package/dist/lib/worker.js +6 -55
  53. package/package.json +5 -5
  54. package/src/app/client/entry.ts +4 -0
  55. package/src/app/server.ts +11 -10
  56. package/src/cli.ts +121 -28
  57. package/src/index.ts +1 -1
  58. package/src/lib/config.ts +144 -58
  59. package/src/lib/context.ts +5 -5
  60. package/src/lib/coverage-loader.ts +2 -2
  61. package/src/lib/coverage.ts +1 -1
  62. package/src/lib/fake-timers.ts +65 -8
  63. package/src/lib/framework.ts +53 -36
  64. package/src/lib/import-module.ts +14 -3
  65. package/src/lib/reporters/dot.ts +9 -0
  66. package/src/lib/reporters/files.ts +9 -0
  67. package/src/lib/reporters/results.ts +1 -1
  68. package/src/lib/reporters/spec.ts +9 -0
  69. package/src/lib/reporters/tap.ts +9 -0
  70. package/src/lib/runner-browser.ts +46 -2
  71. package/src/lib/runner.ts +253 -50
  72. package/src/lib/ts-transform.ts +1 -1
  73. package/src/lib/worker-e2e-file.ts +98 -0
  74. package/src/lib/worker-e2e.ts +14 -51
  75. package/src/lib/worker-process.ts +69 -0
  76. package/src/lib/worker-results.ts +22 -0
  77. package/src/lib/worker-server.ts +123 -0
  78. package/src/lib/worker.ts +7 -47
  79. package/tsconfig.json +6 -3
package/src/lib/config.ts CHANGED
@@ -44,19 +44,23 @@ const cliOptions = {
44
44
  },
45
45
  'glob.browser': {
46
46
  type: 'string',
47
- description: 'Glob pattern for browser test files',
47
+ multiple: true,
48
+ description: 'Glob pattern(s) for browser test files',
48
49
  },
49
50
  'glob.e2e': {
50
51
  type: 'string',
51
- description: 'Glob pattern for E2E test files',
52
+ multiple: true,
53
+ description: 'Glob pattern(s) for E2E test files',
52
54
  },
53
55
  'glob.exclude': {
54
56
  type: 'string',
55
- description: 'Glob pattern for paths to exclude from discovery',
57
+ multiple: true,
58
+ description: 'Glob pattern(s) for paths to exclude from discovery',
56
59
  },
57
60
  'glob.test': {
58
61
  type: 'string',
59
- description: 'Glob pattern for all test files',
62
+ multiple: true,
63
+ description: 'Glob pattern(s) for all test files',
60
64
  },
61
65
  concurrency: {
62
66
  type: 'string',
@@ -113,7 +117,12 @@ const cliOptions = {
113
117
  project: {
114
118
  type: 'string',
115
119
  short: 'p',
116
- description: 'Filter to a specific Playwright project (comma-separated)',
120
+ multiple: true,
121
+ description: 'Filter to specific Playwright project(s)',
122
+ },
123
+ pool: {
124
+ type: 'string',
125
+ description: 'Pool used to run server and E2E test files: forks, threads (default: forks)',
117
126
  },
118
127
  reporter: {
119
128
  type: 'string',
@@ -123,7 +132,8 @@ const cliOptions = {
123
132
  type: {
124
133
  type: 'string',
125
134
  short: 't',
126
- description: 'Comma-separated test types to run (default: server,browser,e2e)',
135
+ multiple: true,
136
+ description: 'Test types to run (default: server, browser, e2e)',
127
137
  },
128
138
  watch: {
129
139
  type: 'boolean',
@@ -148,19 +158,33 @@ const defaultValues: ResolvedRemixTestConfig = {
148
158
  functions: undefined,
149
159
  },
150
160
  glob: {
151
- test: '**/*.test{,.e2e,.browser}.{ts,tsx}',
152
- browser: '**/*.test.browser.{ts,tsx}',
153
- e2e: '**/*.test.e2e.{ts,tsx}',
154
- exclude: 'node_modules/**',
161
+ test: ['**/*.test{,.e2e,.browser}.{ts,tsx}'],
162
+ browser: ['**/*.test.browser.{ts,tsx}'],
163
+ e2e: ['**/*.test.e2e.{ts,tsx}'],
164
+ exclude: ['node_modules/**'],
155
165
  },
156
- reporter: process.env.CI === 'true' ? 'files' : 'spec',
157
- type: 'server,browser,e2e',
158
- setup: undefined,
166
+ pool: 'forks',
159
167
  playwrightConfig: undefined,
160
168
  project: undefined,
169
+ reporter: process.env.CI === 'true' ? 'files' : 'spec',
170
+ setup: undefined,
171
+ type: ['server', 'browser', 'e2e'],
161
172
  watch: false,
162
- } as const
173
+ }
163
174
 
175
+ /**
176
+ * Worker pool used by `remix-test` to run server and E2E test files.
177
+ * `'forks'` (default) uses child processes for stronger isolation; `'threads'`
178
+ * uses worker threads for projects that prefer lower-overhead startup.
179
+ */
180
+ export type RemixTestPool = 'forks' | 'threads'
181
+
182
+ /**
183
+ * User-facing configuration for the `remix-test` CLI. Every field is
184
+ * optional — unset fields fall back to runner defaults. The same shape can
185
+ * be exported from a config file (see `--config`) or passed inline to
186
+ * {@link runRemixTest} via the corresponding flags.
187
+ */
164
188
  export interface RemixTestConfig {
165
189
  /**
166
190
  * Options for controlling the playwright browser
@@ -172,17 +196,18 @@ export interface RemixTestConfig {
172
196
  open?: boolean
173
197
  }
174
198
  /**
175
- * Glob patterns to identify test files
176
- * - `glob.test`: Glob pattern for all test files (--glob.test)
177
- * - `glob.browser`: Glob pattern for the subset of browser test files (--glob.browser)
178
- * - `glob.e2e`: Glob pattern for the subset of e2e test files (--glob.e2e)
179
- * - `glob.exclude`: Glob pattern for paths to exclude from discovery (--glob.exclude)
199
+ * Glob patterns to identify test files. Each field accepts a single pattern
200
+ * or an array of patterns; arrays are unioned during discovery.
201
+ * - `glob.test`: Glob pattern(s) for all test files (--glob.test)
202
+ * - `glob.browser`: Glob pattern(s) for the subset of browser test files (--glob.browser)
203
+ * - `glob.e2e`: Glob pattern(s) for the subset of e2e test files (--glob.e2e)
204
+ * - `glob.exclude`: Glob pattern(s) for paths to exclude from discovery (--glob.exclude)
180
205
  */
181
206
  glob?: {
182
- test?: string
183
- browser?: string
184
- e2e?: string
185
- exclude?: string
207
+ test?: string | string[]
208
+ browser?: string | string[]
209
+ e2e?: string | string[]
210
+ exclude?: string | string[]
186
211
  }
187
212
  /** Max number of concurrent test workers (--concurrency) */
188
213
  concurrency?: number | string
@@ -194,8 +219,8 @@ export interface RemixTestConfig {
194
219
  | boolean
195
220
  | {
196
221
  dir?: string
197
- include?: string[]
198
- exclude?: string[]
222
+ include?: string | string[]
223
+ exclude?: string | string[]
199
224
  statements?: number | string
200
225
  lines?: number | string
201
226
  branches?: number | string
@@ -211,12 +236,23 @@ export interface RemixTestConfig {
211
236
  * PlaywrightTestConfig object. CLI `--playwrightConfig` only accepts a file path.
212
237
  */
213
238
  playwrightConfig?: string | PlaywrightTestConfig
214
- /** Filter tests to a specific playwright project or comma-separated list of projects (--project) */
215
- project?: string
239
+ /**
240
+ * Pool used to run server and E2E test files. Forked child processes are the default,
241
+ * but worker threads are available for projects that prefer the previous behavior.
242
+ */
243
+ pool?: RemixTestPool
244
+ /**
245
+ * Filter tests to specific playwright project(s) (--project). Accepts a single
246
+ * project name or an array of names; `--project` may be repeated on the CLI.
247
+ */
248
+ project?: string | string[]
216
249
  /** Test reporter (--reporter) */
217
250
  reporter?: string
218
- /** Comma-separated list of test types to run (--type) */
219
- type?: string
251
+ /**
252
+ * Test type(s) to run (--type). Accepts a single type or an array of types;
253
+ * `--type` may be repeated on the CLI. Valid values: "server", "browser", "e2e".
254
+ */
255
+ type?: string | string[]
220
256
  /** Watch mode — re-run tests on file changes (--watch) */
221
257
  watch?: boolean
222
258
  }
@@ -239,16 +275,17 @@ export interface ResolvedRemixTestConfig {
239
275
  }
240
276
  | undefined
241
277
  glob: {
242
- test: string
243
- browser: string
244
- e2e: string
245
- exclude: string
278
+ test: string[]
279
+ browser: string[]
280
+ e2e: string[]
281
+ exclude: string[]
246
282
  }
247
283
  playwrightConfig: string | PlaywrightTestConfig | undefined
248
- project: string | undefined
284
+ project: string[] | undefined
249
285
  reporter: string
286
+ pool: RemixTestPool
250
287
  setup: string | undefined
251
- type: string
288
+ type: string[]
252
289
  watch: boolean
253
290
  }
254
291
 
@@ -259,12 +296,21 @@ export async function loadConfig(args: string[] = process.argv.slice(2), cwd = p
259
296
  return config
260
297
  }
261
298
 
299
+ /**
300
+ * Returns the formatted `remix-test --help` text. Useful for embedding the
301
+ * runner's CLI options in higher-level tooling.
302
+ *
303
+ * @param _target Output stream the help text will be written to. Reserved
304
+ * for future use (e.g. width-aware formatting); currently
305
+ * unused.
306
+ * @returns The help text as a single string ready to write to a stream.
307
+ */
262
308
  export function getRemixTestHelpText(_target: NodeJS.WriteStream = process.stdout): string {
263
309
  let lines = [
264
- 'Usage: remix-test [glob] [options]',
310
+ 'Usage: remix-test [glob...] [options]',
265
311
  '',
266
312
  'Arguments:',
267
- ` glob Glob pattern for test files (default: "${defaultValues.glob.test}")`,
313
+ ` glob Glob pattern(s) for test files (default: "${defaultValues.glob.test.join(', ')}")`,
268
314
  '',
269
315
  'Options:',
270
316
  ]
@@ -284,6 +330,19 @@ function parseCliArgs(args: string[]) {
284
330
  return util.parseArgs({ args, options: cliOptions, allowPositionals: true })
285
331
  }
286
332
 
333
+ function toArray<T>(value: T | readonly T[]): T[] {
334
+ return Array.isArray(value) ? [...value] : [value as T]
335
+ }
336
+
337
+ function toCommaSeparatedArray(value: string | readonly string[]): string[] {
338
+ return toArray(value).flatMap((item) =>
339
+ item
340
+ .split(',')
341
+ .map((part) => part.trim())
342
+ .filter(Boolean),
343
+ )
344
+ }
345
+
287
346
  function resolveConfig(
288
347
  fileConfig: RemixTestConfig,
289
348
  { values: cliValues, positionals }: ReturnType<typeof parseCliArgs>,
@@ -291,14 +350,18 @@ function resolveConfig(
291
350
  let fileCoverage = typeof fileConfig.coverage === 'boolean' ? {} : fileConfig.coverage || {}
292
351
  return {
293
352
  glob: {
294
- test:
295
- positionals[0] ??
296
- cliValues['glob.test'] ??
297
- fileConfig.glob?.test ??
298
- defaultValues.glob.test,
299
- browser: cliValues['glob.browser'] ?? fileConfig.glob?.browser ?? defaultValues.glob.browser,
300
- e2e: cliValues['glob.e2e'] ?? fileConfig.glob?.e2e ?? defaultValues.glob.e2e,
301
- exclude: cliValues['glob.exclude'] ?? fileConfig.glob?.exclude ?? defaultValues.glob.exclude,
353
+ test: toArray(
354
+ positionals.length > 0
355
+ ? positionals
356
+ : (cliValues['glob.test'] ?? fileConfig.glob?.test ?? defaultValues.glob.test),
357
+ ),
358
+ browser: toArray(
359
+ cliValues['glob.browser'] ?? fileConfig.glob?.browser ?? defaultValues.glob.browser,
360
+ ),
361
+ e2e: toArray(cliValues['glob.e2e'] ?? fileConfig.glob?.e2e ?? defaultValues.glob.e2e),
362
+ exclude: toArray(
363
+ cliValues['glob.exclude'] ?? fileConfig.glob?.exclude ?? defaultValues.glob.exclude,
364
+ ),
302
365
  },
303
366
  browser: {
304
367
  echo: cliValues['browser.echo'] ?? fileConfig.browser?.echo ?? defaultValues.browser.echo,
@@ -311,14 +374,20 @@ function resolveConfig(
311
374
  cliValues.coverage === true || !!fileConfig.coverage
312
375
  ? {
313
376
  dir: cliValues['coverage.dir'] ?? fileCoverage.dir ?? defaultValues.coverage!.dir,
314
- include:
315
- cliValues['coverage.include'] ??
316
- fileCoverage.include ??
317
- defaultValues.coverage!.include,
318
- exclude:
319
- cliValues['coverage.exclude'] ??
320
- fileCoverage.exclude ??
321
- defaultValues.coverage!.exclude,
377
+ include: (() => {
378
+ let raw =
379
+ cliValues['coverage.include'] ??
380
+ fileCoverage.include ??
381
+ defaultValues.coverage!.include
382
+ return raw === undefined ? undefined : toArray(raw)
383
+ })(),
384
+ exclude: (() => {
385
+ let raw =
386
+ cliValues['coverage.exclude'] ??
387
+ fileCoverage.exclude ??
388
+ defaultValues.coverage!.exclude
389
+ return raw === undefined ? undefined : toArray(raw)
390
+ })(),
322
391
  statements:
323
392
  cliValues['coverage.statements'] !== undefined
324
393
  ? Number(cliValues['coverage.statements'])
@@ -348,13 +417,25 @@ function resolveConfig(
348
417
  setup: cliValues.setup ?? fileConfig.setup ?? defaultValues.setup,
349
418
  playwrightConfig:
350
419
  cliValues.playwrightConfig ?? fileConfig.playwrightConfig ?? defaultValues.playwrightConfig,
351
- project: cliValues.project ?? fileConfig.project ?? defaultValues.project,
420
+ pool: resolvePool(cliValues.pool ?? fileConfig.pool ?? defaultValues.pool),
421
+ project: (() => {
422
+ let raw = cliValues.project ?? fileConfig.project ?? defaultValues.project
423
+ return raw === undefined ? undefined : toCommaSeparatedArray(raw)
424
+ })(),
352
425
  reporter: cliValues.reporter ?? fileConfig.reporter ?? defaultValues.reporter,
353
- type: cliValues.type ?? fileConfig.type ?? defaultValues.type,
426
+ type: toCommaSeparatedArray(cliValues.type ?? fileConfig.type ?? defaultValues.type),
354
427
  watch: cliValues.watch ?? fileConfig.watch ?? defaultValues.watch,
355
428
  }
356
429
  }
357
430
 
431
+ function resolvePool(value: string): RemixTestPool {
432
+ if (value === 'forks' || value === 'threads') {
433
+ return value
434
+ }
435
+
436
+ throw new Error(`Unsupported test pool "${value}". Supported pools are: forks, threads`)
437
+ }
438
+
358
439
  async function loadConfigFile(
359
440
  configPath: string | undefined,
360
441
  cwd: string,
@@ -366,11 +447,16 @@ async function loadConfigFile(
366
447
  for (let candidate of candidates) {
367
448
  try {
368
449
  await fsp.access(candidate)
369
- let mod = await importModule(candidate, import.meta)
370
- return mod.default ?? mod
371
450
  } catch {
372
- // not found or failed to load — try next
451
+ // not found — try the next candidate
452
+ continue
373
453
  }
454
+ // The file exists; let import errors propagate rather than silently
455
+ // falling through to defaults — that masking is what hid "Windows
456
+ // absolute paths aren't valid ESM specifiers" by classifying every
457
+ // browser test as a server test.
458
+ let mod = await importModule(candidate, import.meta)
459
+ return mod.default ?? mod
374
460
  }
375
461
 
376
462
  return {}
@@ -15,7 +15,7 @@ export interface TestServer {
15
15
  }
16
16
 
17
17
  /**
18
- * Test Context providing utilities for testing via remix-test. The context is
18
+ * Test Context providing utilities for testing via `remix-test`. The context is
19
19
  * passed as the first argument to the {@link test}/{@link it} functions.
20
20
  *
21
21
  * @example
@@ -36,8 +36,8 @@ export interface TestContext {
36
36
  after(fn: () => void): void
37
37
 
38
38
  /**
39
- * Mock tracker for the current test. Mirrors the shape of Node's
40
- * `t.mock`. Method mocks created here are auto-restored on test completion.
39
+ * Mock tracker for the current test using {@link mock}. Mirrors the shape of Node's
40
+ * `t.mock`. Method mocks created via `t.mock` are auto-restored on test completion.
41
41
  */
42
42
  mock: {
43
43
  /**
@@ -77,8 +77,8 @@ export interface TestContext {
77
77
  /**
78
78
  * Wires a running test server up to a Playwright page so the test can drive
79
79
  * it. The server is closed automatically when the test ends. Pair with
80
- * `createTestServer` from `@remix-run/node-fetch-server/test` (or any other
81
- * source of a `{ baseUrl, close }` handle) to spin up the server first.
80
+ * {@link createTestServer} from `@remix-run/node-fetch-server/test` to spin
81
+ * up the server.
82
82
  *
83
83
  * @param server - The running server the page should target
84
84
  * @returns A `Page` whose `baseURL` is set to `server.baseUrl`.
@@ -4,8 +4,8 @@ import { transformTypeScript } from './ts-transform.ts'
4
4
 
5
5
  // Custom ESM loader hook for TypeScript files.
6
6
  //
7
- // Replaces tsx's minified transformation with an un-minified esbuild transform
8
- // that preserves line structure. This ensures V8 coverage byte offsets map
7
+ // Applies an un-minified esbuild transform that preserves line structure.
8
+ // This ensures V8 coverage byte offsets map
9
9
  // cleanly to TypeScript source lines via the inline source map, giving
10
10
  // accurate per-line coverage rather than collapsing multiple statements onto
11
11
  // a single minified line.
@@ -236,7 +236,7 @@ export async function collectServerCoverageMap(
236
236
  let { code } = await transformTypeScript(tsSource, filePath)
237
237
  let success = await addV8EntryToCoverageMap(coverageMap, filePath, entry.functions, code)
238
238
  if (success) converted++
239
- } catch (e) {
239
+ } catch {
240
240
  // Skip files that can't be converted
241
241
  }
242
242
  }
@@ -1,7 +1,46 @@
1
1
  import { mock } from './mock.ts'
2
2
 
3
+ /**
4
+ * Handle returned by `mock.timers.enable()` for driving fake timers during a
5
+ * test. While enabled, `setTimeout`, `setInterval`, `clearTimeout`,
6
+ * `clearInterval`, and `Date.now` use the fake clock instead of the real one;
7
+ * timers fire only when the test calls `advance` (or `advanceAsync`).
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * it('debounces save calls', (t) => {
12
+ * let timers = t.mock.timers.enable()
13
+ * let save = t.mock.fn()
14
+ * let debounced = debounce(save, 100)
15
+ * debounced(); debounced(); debounced()
16
+ * timers.advance(100)
17
+ * assert.equal(save.mock.calls.length, 1)
18
+ * })
19
+ * ```
20
+ */
3
21
  export interface FakeTimers {
22
+ /**
23
+ * Advance the fake clock by `ms` milliseconds, synchronously firing every
24
+ * timer whose deadline is reached during the advance.
25
+ *
26
+ * @param ms Number of milliseconds to advance.
27
+ */
4
28
  advance(ms: number): void
29
+ /**
30
+ * Like `advance`, but yields to microtasks between each timer firing so
31
+ * Promise continuations (and any timers they schedule) can settle before
32
+ * the next firing is processed. Use this when a callback awaits work that
33
+ * itself depends on the fake clock.
34
+ *
35
+ * @param ms Number of milliseconds to advance.
36
+ * @returns A promise that resolves once all reachable timers have fired.
37
+ */
38
+ advanceAsync(ms: number): Promise<void>
39
+ /**
40
+ * Restore the original timer functions and the real clock. Called
41
+ * automatically after the test finishes; may also be called early to
42
+ * disable fake timers mid-test.
43
+ */
5
44
  restore(): void
6
45
  }
7
46
 
@@ -35,20 +74,38 @@ export function createFakeTimers(): FakeTimers {
35
74
  cancel as unknown as typeof clearInterval,
36
75
  )
37
76
 
77
+ function takeNext(targetTime: number) {
78
+ let next = pending.filter((t) => t.time <= targetTime).sort((a, b) => a.time - b.time)[0]
79
+ if (!next) return null
80
+ currentTime = next.time
81
+ pending = pending.filter((t) => t.id !== next.id)
82
+ // Requeue intervals before running the callback so that calling
83
+ // clearInterval(id) from inside the callback can cancel the next firing.
84
+ if (next.repeatMs !== undefined) {
85
+ pending.push({ ...next, time: next.time + Math.max(1, next.repeatMs) })
86
+ }
87
+ return next
88
+ }
89
+
38
90
  return {
39
91
  advance(ms: number) {
40
92
  let targetTime = currentTime + ms
41
93
  while (true) {
42
- let next = pending.filter((t) => t.time <= targetTime).sort((a, b) => a.time - b.time)[0]
94
+ let next = takeNext(targetTime)
95
+ if (!next) break
96
+ next.fn()
97
+ }
98
+ currentTime = targetTime
99
+ },
100
+ async advanceAsync(ms: number) {
101
+ let targetTime = currentTime + ms
102
+ while (true) {
103
+ let next = takeNext(targetTime)
43
104
  if (!next) break
44
- currentTime = next.time
45
- pending = pending.filter((t) => t.id !== next.id)
46
- // Requeue intervals before running the callback so that calling
47
- // clearInterval(id) from inside the callback can cancel the next firing.
48
- if (next.repeatMs !== undefined) {
49
- pending.push({ ...next, time: next.time + Math.max(1, next.repeatMs) })
50
- }
51
105
  next.fn()
106
+ // Drain microtasks so Promise continuations (and any timers they
107
+ // schedule) can settle before we look for the next firing.
108
+ await Promise.resolve()
52
109
  }
53
110
  currentTime = targetTime
54
111
  },
@@ -57,6 +57,13 @@ function registerDescribe(
57
57
  }
58
58
  let suite: TestSuite = { name: fullName, tests: [], ...flags }
59
59
 
60
+ // Children inherit `skip`/`only` from their parent so that
61
+ // `describe.skip('parent', () => describe('child', () => it(...)))` actually
62
+ // skips the child's tests. The executor walks `rootSuites` as a flat list and
63
+ // only inspects each suite's own flag, so the propagation has to happen here.
64
+ if (currentSuite?.skip) suite.skip = true
65
+ if (currentSuite?.only) suite.only = true
66
+
60
67
  // Inherit lifecycle hooks from parent suite (or root hooks if at top level)
61
68
  let parent = currentSuite ?? rootHooks
62
69
  if (parent.beforeEach) suite.beforeEach = parent.beforeEach
@@ -80,9 +87,20 @@ function registerDescribe(
80
87
  }
81
88
  }
82
89
 
90
+ // We implement this standalone so we can leverage multiple signatures through
91
+ // typedoc, but we need to do the `const describe = Object.assign()` thing below to
92
+ // get the modifiers onto the method in a typescript-aware way.
93
+ function describeImpl(name: string, fn: () => void): void
94
+ function describeImpl(name: string, meta: SuiteMeta, fn: () => void): void
95
+ function describeImpl(name: string, metaOrFn: SuiteMeta | (() => void), fn?: () => void): void {
96
+ let meta = typeof metaOrFn === 'function' ? {} : metaOrFn
97
+ let suiteFn = typeof metaOrFn === 'function' ? metaOrFn : fn!
98
+ registerDescribe(name, suiteFn, meta)
99
+ }
100
+
83
101
  /**
84
- * Groups related tests into a named suite. Suites can be nested snd will be displayed
85
- * as such or joined with ` > ` in reporter output. Lifecycle hooks registered inside
102
+ * Groups related tests into a named suite. Suites can be nested and will be displayed
103
+ * as such in reporter output. Lifecycle hooks registered inside
86
104
  * a `describe` block apply only to tests within that block.
87
105
  *
88
106
  * @example
@@ -96,26 +114,20 @@ function registerDescribe(
96
114
  * describe.todo('planned suite')
97
115
  *
98
116
  * @param name - The suite name shown in reporter output.
117
+ * @param meta - Suite metadata such as `skip` or `only`.
99
118
  * @param fn - A function that registers the tests and lifecycle hooks in this suite.
100
119
  */
101
- export const describe = Object.assign(
102
- (name: string, metaOrFn: SuiteMeta | (() => void), fn?: () => void) => {
103
- let meta = typeof metaOrFn === 'function' ? {} : metaOrFn
104
- let suiteFn = typeof metaOrFn === 'function' ? metaOrFn : fn!
105
- registerDescribe(name, suiteFn, meta)
106
- },
107
- {
108
- skip: (name: string, fn: () => void) => registerDescribe(name, fn, { skip: true }),
109
- only: (name: string, fn: () => void) => registerDescribe(name, fn, { only: true }),
110
- todo: (name: string) => {
111
- let fullName = currentSuite ? `${currentSuite.name} > ${name}` : name
112
- if (rootSuites.some((s) => s.name === fullName)) {
113
- throw new Error(`Duplicate suite name: "${fullName}"`)
114
- }
115
- rootSuites.push({ name: fullName, tests: [], todo: true })
116
- },
120
+ export const describe = Object.assign(describeImpl, {
121
+ skip: (name: string, fn: () => void) => registerDescribe(name, fn, { skip: true }),
122
+ only: (name: string, fn: () => void) => registerDescribe(name, fn, { only: true }),
123
+ todo: (name: string) => {
124
+ let fullName = currentSuite ? `${currentSuite.name} > ${name}` : name
125
+ if (rootSuites.some((s) => s.name === fullName)) {
126
+ throw new Error(`Duplicate suite name: "${fullName}"`)
127
+ }
128
+ rootSuites.push({ name: fullName, tests: [], todo: true })
117
129
  },
118
- )
130
+ })
119
131
 
120
132
  type SuiteMeta = { skip?: boolean; only?: boolean }
121
133
  type TestMeta = { skip?: boolean; only?: boolean }
@@ -129,6 +141,17 @@ function registerIt(name: string, fn: TestFn, flags?: { only?: boolean; skip?: b
129
141
  suite.tests.push({ name, fn, suite, ...flags })
130
142
  }
131
143
 
144
+ // We implement this standalone so we can leverage multiple signatures through
145
+ // typedoc, but we need to do the `const it = Object.assign()` thing below to
146
+ // get the modifiers onto the method in a typescript-aware way.
147
+ function itImpl(name: string, fn: TestFn): void
148
+ function itImpl(name: string, meta: TestMeta, fn: TestFn): void
149
+ function itImpl(name: string, metaOrFn: TestMeta | TestFn, fn?: TestFn): void {
150
+ let meta = typeof metaOrFn === 'function' ? {} : metaOrFn
151
+ let testFn = typeof metaOrFn === 'function' ? metaOrFn : fn!
152
+ registerIt(name, testFn, meta)
153
+ }
154
+
132
155
  /**
133
156
  * Defines a single test case. The optional `TestContext` argument `t` provides
134
157
  * mock helpers and per-test cleanup registration.
@@ -145,26 +168,20 @@ function registerIt(name: string, fn: TestFn, flags?: { only?: boolean; skip?: b
145
168
  * it.todo('coming soon')
146
169
  *
147
170
  * @param name - The test name shown in reporter output.
171
+ * @param meta - Test metadata such as `skip` or `only`.
148
172
  * @param fn - The test body, receiving a {@link TestContext} as its first argument.
149
173
  */
150
- export const it = Object.assign(
151
- (name: string, metaOrFn: TestMeta | TestFn, fn?: TestFn) => {
152
- let meta = typeof metaOrFn === 'function' ? {} : metaOrFn
153
- let testFn = typeof metaOrFn === 'function' ? metaOrFn : fn!
154
- registerIt(name, testFn, meta)
155
- },
156
- {
157
- skip: (name: string, fn?: TestFn) => registerIt(name, fn ?? (() => {}), { skip: true }),
158
- only: (name: string, fn: TestFn) => registerIt(name, fn, { only: true }),
159
- todo: (name: string) => {
160
- let suite = currentSuite ?? getImplicitRootSuite()
161
- if (suite.tests.some((t) => t.name === name)) {
162
- throw new Error(`Duplicate test name: "${name}" in suite "${suite.name || 'Global'}"`)
163
- }
164
- suite.tests.push({ name, fn: () => {}, suite, todo: true })
165
- },
174
+ export const it = Object.assign(itImpl, {
175
+ skip: (name: string, fn?: TestFn) => registerIt(name, fn ?? (() => {}), { skip: true }),
176
+ only: (name: string, fn: TestFn) => registerIt(name, fn, { only: true }),
177
+ todo: (name: string) => {
178
+ let suite = currentSuite ?? getImplicitRootSuite()
179
+ if (suite.tests.some((t) => t.name === name)) {
180
+ throw new Error(`Duplicate test name: "${name}" in suite "${suite.name || 'Global'}"`)
181
+ }
182
+ suite.tests.push({ name, fn: () => {}, suite, todo: true })
166
183
  },
167
- )
184
+ })
168
185
 
169
186
  /** Alias for {@link describe}. */
170
187
  export const suite = describe
@@ -1,4 +1,5 @@
1
- import { tsImport } from 'tsx/esm/api'
1
+ import * as path from 'node:path'
2
+ import { pathToFileURL } from 'node:url'
2
3
  import { IS_BUN } from './runtime.ts'
3
4
 
4
5
  interface ImportMetaWithResolve extends ImportMeta {
@@ -17,13 +18,23 @@ function hasImportMetaResolve(meta: ImportMeta): meta is ImportMetaWithResolve {
17
18
  * @returns The imported module namespace.
18
19
  */
19
20
  export async function importModule(specifier: string, meta: ImportMeta): Promise<any> {
21
+ // Absolute Windows paths (`C:\foo\bar.ts`) aren't valid ESM specifiers — only
22
+ // `file:///C:/foo/bar.ts` URLs, relative specifiers, or POSIX absolute paths
23
+ // are. Convert any absolute filesystem path to its `file:` URL so module
24
+ // loaders and `import()` accept it on every platform. POSIX absolute paths
25
+ // happen to work as specifiers without conversion, but going through
26
+ // `pathToFileURL` is safe and platform-agnostic.
27
+ let resolvedSpecifier = path.isAbsolute(specifier) ? pathToFileURL(specifier).href : specifier
28
+
20
29
  if (IS_BUN) {
21
30
  if (!hasImportMetaResolve(meta)) {
22
31
  throw new Error('importModule() requires import.meta.resolve() in Bun')
23
32
  }
24
33
 
25
- return import(meta.resolve(specifier, meta.url))
34
+ return import(meta.resolve(resolvedSpecifier, meta.url))
26
35
  }
27
36
 
28
- return tsImport(specifier, meta.url)
37
+ // node-tsx uses Node APIs that fail in Bun if statically imported
38
+ let { loadModule } = await import('@remix-run/node-tsx/load-module')
39
+ return loadModule(resolvedSpecifier, meta.url)
29
40
  }
@@ -6,10 +6,17 @@ import type { Counts, TestResult, TestResults } from './results.ts'
6
6
  export class DotReporter implements Reporter {
7
7
  #failures: { name: string; error: TestResult['error'] }[] = []
8
8
  #dotCount = 0
9
+ #files = new Set<string>()
10
+ #suites = new Set<string>()
9
11
 
10
12
  onSectionStart(_label: string) {}
11
13
 
12
14
  onResult(results: TestResults, _env?: string) {
15
+ for (let test of results.tests) {
16
+ if (test.filePath) this.#files.add(test.filePath)
17
+ if (test.suiteName) this.#suites.add(test.suiteName)
18
+ }
19
+
13
20
  for (let test of results.tests) {
14
21
  if (test.status === 'passed') {
15
22
  process.stdout.write(colors.green('.'))
@@ -47,6 +54,8 @@ export class DotReporter implements Reporter {
47
54
  let { passed, failed, skipped, todo } = counts
48
55
  let info = colors.cyan('ℹ')
49
56
  console.log()
57
+ console.log(`${info} files ${this.#files.size}`)
58
+ console.log(`${info} suites ${this.#suites.size}`)
50
59
  console.log(`${info} tests ${passed + failed + skipped + todo}`)
51
60
  console.log(`${info} pass ${passed}`)
52
61
  console.log(`${info} fail ${failed}`)