@remix-run/test 0.2.0 → 0.3.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 (60) hide show
  1. package/README.md +39 -33
  2. package/dist/app/client/entry.js +4 -0
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +68 -23
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/lib/config.d.ts +35 -21
  8. package/dist/lib/config.d.ts.map +1 -1
  9. package/dist/lib/config.js +73 -33
  10. package/dist/lib/fake-timers.d.ts +7 -0
  11. package/dist/lib/fake-timers.d.ts.map +1 -1
  12. package/dist/lib/fake-timers.js +27 -8
  13. package/dist/lib/import-module.d.ts.map +1 -1
  14. package/dist/lib/import-module.js +11 -2
  15. package/dist/lib/reporters/dot.d.ts.map +1 -1
  16. package/dist/lib/reporters/dot.js +10 -0
  17. package/dist/lib/reporters/files.d.ts.map +1 -1
  18. package/dist/lib/reporters/files.js +10 -0
  19. package/dist/lib/reporters/spec.d.ts.map +1 -1
  20. package/dist/lib/reporters/spec.js +10 -0
  21. package/dist/lib/reporters/tap.d.ts.map +1 -1
  22. package/dist/lib/reporters/tap.js +10 -0
  23. package/dist/lib/runner-browser.d.ts.map +1 -1
  24. package/dist/lib/runner-browser.js +6 -0
  25. package/dist/lib/runner.d.ts +18 -1
  26. package/dist/lib/runner.d.ts.map +1 -1
  27. package/dist/lib/runner.js +187 -38
  28. package/dist/lib/worker-e2e-file.d.ts +11 -0
  29. package/dist/lib/worker-e2e-file.d.ts.map +1 -0
  30. package/dist/lib/worker-e2e-file.js +69 -0
  31. package/dist/lib/worker-e2e.js +11 -47
  32. package/dist/lib/worker-process.d.ts +2 -0
  33. package/dist/lib/worker-process.d.ts.map +1 -0
  34. package/dist/lib/worker-process.js +55 -0
  35. package/dist/lib/worker-results.d.ts +3 -0
  36. package/dist/lib/worker-results.d.ts.map +1 -0
  37. package/dist/lib/worker-results.js +20 -0
  38. package/dist/lib/worker-server.d.ts +10 -0
  39. package/dist/lib/worker-server.d.ts.map +1 -0
  40. package/dist/lib/worker-server.js +113 -0
  41. package/dist/lib/worker.js +6 -55
  42. package/package.json +4 -4
  43. package/src/app/client/entry.ts +4 -0
  44. package/src/cli.ts +91 -28
  45. package/src/index.ts +1 -1
  46. package/src/lib/config.ts +124 -58
  47. package/src/lib/fake-timers.ts +33 -8
  48. package/src/lib/import-module.ts +12 -2
  49. package/src/lib/reporters/dot.ts +9 -0
  50. package/src/lib/reporters/files.ts +9 -0
  51. package/src/lib/reporters/spec.ts +9 -0
  52. package/src/lib/reporters/tap.ts +9 -0
  53. package/src/lib/runner-browser.ts +6 -0
  54. package/src/lib/runner.ts +253 -50
  55. package/src/lib/worker-e2e-file.ts +98 -0
  56. package/src/lib/worker-e2e.ts +14 -51
  57. package/src/lib/worker-process.ts +69 -0
  58. package/src/lib/worker-results.ts +22 -0
  59. package/src/lib/worker-server.ts +123 -0
  60. package/src/lib/worker.ts +7 -47
package/src/cli.ts CHANGED
@@ -1,16 +1,16 @@
1
1
  import * as fsp from 'node:fs/promises'
2
2
  import type * as http from 'node:http'
3
3
  import * as path from 'node:path'
4
+ import type * as browserTestRunner from './lib/runner-browser.ts'
4
5
  import {
5
6
  getRemixTestHelpText,
6
7
  IS_RUNNING_FROM_SRC,
7
8
  loadConfig,
8
9
  type ResolvedRemixTestConfig,
9
10
  } from './lib/config.ts'
11
+ import type * as playwrightSupport from './lib/playwright.ts'
10
12
  import { generateCombinedCoverageReport } from './lib/coverage.ts'
11
- import { loadPlaywrightConfig, resolveProjects } from './lib/playwright.ts'
12
13
  import { createReporter } from './lib/reporters/index.ts'
13
- import { runBrowserTests } from './lib/runner-browser.ts'
14
14
  import { runServerTests } from './lib/runner.ts'
15
15
  import { createWatcher } from './lib/watcher.ts'
16
16
  import { importModule } from './lib/import-module.ts'
@@ -20,11 +20,16 @@ import { isMainThread } from 'node:worker_threads'
20
20
 
21
21
  export { getRemixTestHelpText }
22
22
 
23
+ const MISSING_PLAYWRIGHT_MESSAGE =
24
+ 'Playwright is required to run browser and E2E tests. Install it with `npm i -D playwright`.'
25
+
23
26
  export interface RunRemixTestOptions {
24
27
  argv?: string[]
25
28
  cwd?: string
26
29
  }
27
30
 
31
+ type RunBrowserTests = typeof browserTestRunner.runBrowserTests
32
+
28
33
  interface DiscoveredTests {
29
34
  files: string[]
30
35
  serverFiles: string[]
@@ -157,11 +162,6 @@ async function runRemixTestInCwd(argv: string[], cwd: string): Promise<number> {
157
162
  browserPort = result.port
158
163
  }
159
164
 
160
- let playwrightConfig =
161
- config.playwrightConfig == null || typeof config.playwrightConfig === 'string'
162
- ? await loadPlaywrightConfig(config.playwrightConfig, cwd)
163
- : config.playwrightConfig
164
-
165
165
  let reporter = createReporter(config.reporter)
166
166
  let startTime = performance.now()
167
167
 
@@ -183,6 +183,7 @@ async function runRemixTestInCwd(argv: string[], cwd: string): Promise<number> {
183
183
  {
184
184
  coverage: config.coverage,
185
185
  cwd,
186
+ pool: config.pool,
186
187
  },
187
188
  )
188
189
  counts.failed += serverResult.failed
@@ -194,18 +195,26 @@ async function runRemixTestInCwd(argv: string[], cwd: string): Promise<number> {
194
195
 
195
196
  // Run browser/e2e tests for all browsers configured by the user
196
197
  if (browserFiles.length > 0 || e2eFiles.length > 0) {
198
+ let { loadPlaywrightConfig, resolveProjects } = await importPlaywrightSupport()
199
+ let runBrowserTests =
200
+ browserFiles.length > 0 ? (await importBrowserTestRunner()).runBrowserTests : undefined
201
+ let playwrightConfig =
202
+ config.playwrightConfig == null || typeof config.playwrightConfig === 'string'
203
+ ? await loadPlaywrightConfig(config.playwrightConfig, cwd)
204
+ : config.playwrightConfig
197
205
  let projects = resolveProjects(playwrightConfig)
206
+
198
207
  if (config.project) {
199
- let projectNames = config.project.split(',').map((project) => project.trim())
200
- projects = projects.filter(
201
- (project) => project.name && projectNames.includes(project.name),
202
- )
208
+ let projectNames = new Set(config.project)
209
+ projects = projects.filter((project) => project.name && projectNames.has(project.name))
203
210
  if (projects.length === 0) {
204
- throw new Error(`No playwright projects found with name(s) "${config.project}"`)
211
+ throw new Error(
212
+ `No playwright projects found with name(s) "${config.project.join(', ')}"`,
213
+ )
205
214
  }
206
215
  }
207
216
 
208
- let lastBrowserResult: Awaited<ReturnType<typeof runBrowserTests>> | null = null
217
+ let lastBrowserResult: Awaited<ReturnType<RunBrowserTests>> | null = null
209
218
 
210
219
  for (let project of projects) {
211
220
  reporter.onSectionStart(`\nRunning tests for project \`${project.name}\`:`)
@@ -222,7 +231,7 @@ async function runRemixTestInCwd(argv: string[], cwd: string): Promise<number> {
222
231
  }
223
232
 
224
233
  let [browserResult, e2eResult] = await Promise.all([
225
- browserFiles.length > 0
234
+ runBrowserTests != null
226
235
  ? runBrowserTests({
227
236
  baseUrl: `http://localhost:${browserPort}`,
228
237
  console: config.browser?.echo,
@@ -241,6 +250,7 @@ async function runRemixTestInCwd(argv: string[], cwd: string): Promise<number> {
241
250
  projectName: project.name,
242
251
  coverage: config.coverage,
243
252
  cwd,
253
+ pool: config.pool,
244
254
  })
245
255
  : null,
246
256
  ])
@@ -312,6 +322,42 @@ async function runRemixTestInCwd(argv: string[], cwd: string): Promise<number> {
312
322
  return await runPromise
313
323
  }
314
324
 
325
+ async function importPlaywrightSupport(): Promise<typeof playwrightSupport> {
326
+ try {
327
+ return await import('./lib/playwright.ts')
328
+ } catch (error) {
329
+ throw toPlaywrightImportError(error)
330
+ }
331
+ }
332
+
333
+ async function importBrowserTestRunner(): Promise<typeof browserTestRunner> {
334
+ try {
335
+ return await import('./lib/runner-browser.ts')
336
+ } catch (error) {
337
+ throw toPlaywrightImportError(error)
338
+ }
339
+ }
340
+
341
+ function toPlaywrightImportError(error: unknown): unknown {
342
+ return isMissingPlaywrightImport(error) ? new Error(MISSING_PLAYWRIGHT_MESSAGE) : error
343
+ }
344
+
345
+ function isMissingPlaywrightImport(error: unknown): boolean {
346
+ if (!isRecord(error) || typeof error.message !== 'string') {
347
+ return false
348
+ }
349
+
350
+ return (
351
+ (error.code === 'ERR_MODULE_NOT_FOUND' || error.code === 'MODULE_NOT_FOUND') &&
352
+ (error.message.includes("Cannot find package 'playwright'") ||
353
+ error.message.includes("Cannot find module 'playwright'"))
354
+ )
355
+ }
356
+
357
+ function isRecord(value: unknown): value is Record<string, unknown> {
358
+ return typeof value === 'object' && value !== null
359
+ }
360
+
315
361
  async function resolveCwd(cwd: string): Promise<string> {
316
362
  try {
317
363
  return await fsp.realpath(cwd)
@@ -327,13 +373,13 @@ async function discoverTests(
327
373
  let files = await findFiles(config.glob.test, config.glob.exclude, cwd)
328
374
 
329
375
  if (files.length === 0) {
330
- console.log(`No test files found matching pattern: ${config.glob.test}`)
376
+ console.log(`No test files found matching pattern: ${config.glob.test.join(', ')}`)
331
377
  return null
332
378
  }
333
379
 
334
380
  let browserSet = new Set(await findFiles(config.glob.browser, config.glob.exclude, cwd))
335
381
  let e2eSet = new Set(await findFiles(config.glob.e2e, config.glob.exclude, cwd))
336
- let types = new Set(config.type.split(','))
382
+ let types = new Set(config.type)
337
383
  let browserFiles = types.has('browser') ? files.filter((f) => browserSet.has(f)) : []
338
384
  let e2eFiles = types.has('e2e') ? files.filter((file) => e2eSet.has(file)) : []
339
385
  let serverFiles = types.has('server')
@@ -342,7 +388,7 @@ async function discoverTests(
342
388
  let totalFiles = browserFiles.length + serverFiles.length + e2eFiles.length
343
389
 
344
390
  if (totalFiles === 0) {
345
- console.log(`No test files remain after filtering for type ${config.type}`)
391
+ console.log(`No test files remain after filtering for type ${config.type.join(', ')}`)
346
392
  return null
347
393
  }
348
394
 
@@ -358,8 +404,12 @@ async function discoverTests(
358
404
  }
359
405
  }
360
406
 
361
- async function findFiles(pattern: string, excludePattern: string, cwd: string): Promise<string[]> {
362
- let files: string[] = []
407
+ async function findFiles(
408
+ patterns: string[],
409
+ excludePatterns: string[],
410
+ cwd: string,
411
+ ): Promise<string[]> {
412
+ let files = new Set<string>()
363
413
 
364
414
  if (IS_BUN) {
365
415
  // Bun's `fs.promises.glob` follows symlinks and doesn't prune traversal
@@ -367,18 +417,31 @@ async function findFiles(pattern: string, excludePattern: string, cwd: string):
367
417
  // Use Bun's native Glob, which defaults to `followSymlinks: false`.
368
418
  // @ts-expect-error — bun module is only resolvable under the Bun runtime
369
419
  let { Glob } = await import('bun')
370
- let glob = new Glob(pattern)
371
- let excludeGlob = new Glob(excludePattern)
372
- for await (let file of glob.scan({ cwd, absolute: true })) {
373
- if (!excludeGlob.match(path.relative(cwd, file))) {
374
- files.push(file)
420
+ let excludeGlobs = excludePatterns.map((p) => new Glob(p))
421
+ for (let pattern of patterns) {
422
+ let glob = new Glob(pattern)
423
+ for await (let file of glob.scan({ cwd, absolute: true })) {
424
+ let rel = toPosix(path.relative(cwd, file))
425
+ if (!excludeGlobs.some((eg: { match: (s: string) => boolean }) => eg.match(rel))) {
426
+ files.add(toPosix(file))
427
+ }
375
428
  }
376
429
  }
377
- return files
430
+ return [...files]
378
431
  }
379
432
 
380
- for await (let file of fsp.glob(pattern, { cwd, exclude: [excludePattern] })) {
381
- files.push(path.resolve(cwd, file))
433
+ for (let pattern of patterns) {
434
+ for await (let file of fsp.glob(pattern, { cwd, exclude: excludePatterns })) {
435
+ files.add(toPosix(path.resolve(cwd, file)))
436
+ }
382
437
  }
383
- return files
438
+ return [...files]
439
+ }
440
+
441
+ // Normalize discovered paths so set membership across the test/browser/e2e
442
+ // `findFiles` calls is byte-stable on every platform. Node accepts forward
443
+ // slashes for filesystem operations on Windows, so downstream `fs.readFile`
444
+ // etc. work without further conversion.
445
+ function toPosix(p: string): string {
446
+ return path.sep === '/' ? p : p.replace(/\\/g, '/')
384
447
  }
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type { RemixTestConfig } from './lib/config.ts'
1
+ export type { RemixTestConfig, RemixTestPool } from './lib/config.ts'
2
2
  export {
3
3
  describe,
4
4
  it,
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,18 +158,21 @@ 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
+ }
174
+
175
+ export type RemixTestPool = 'forks' | 'threads'
163
176
 
164
177
  export interface RemixTestConfig {
165
178
  /**
@@ -172,17 +185,18 @@ export interface RemixTestConfig {
172
185
  open?: boolean
173
186
  }
174
187
  /**
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)
188
+ * Glob patterns to identify test files. Each field accepts a single pattern
189
+ * or an array of patterns; arrays are unioned during discovery.
190
+ * - `glob.test`: Glob pattern(s) for all test files (--glob.test)
191
+ * - `glob.browser`: Glob pattern(s) for the subset of browser test files (--glob.browser)
192
+ * - `glob.e2e`: Glob pattern(s) for the subset of e2e test files (--glob.e2e)
193
+ * - `glob.exclude`: Glob pattern(s) for paths to exclude from discovery (--glob.exclude)
180
194
  */
181
195
  glob?: {
182
- test?: string
183
- browser?: string
184
- e2e?: string
185
- exclude?: string
196
+ test?: string | string[]
197
+ browser?: string | string[]
198
+ e2e?: string | string[]
199
+ exclude?: string | string[]
186
200
  }
187
201
  /** Max number of concurrent test workers (--concurrency) */
188
202
  concurrency?: number | string
@@ -194,8 +208,8 @@ export interface RemixTestConfig {
194
208
  | boolean
195
209
  | {
196
210
  dir?: string
197
- include?: string[]
198
- exclude?: string[]
211
+ include?: string | string[]
212
+ exclude?: string | string[]
199
213
  statements?: number | string
200
214
  lines?: number | string
201
215
  branches?: number | string
@@ -211,12 +225,23 @@ export interface RemixTestConfig {
211
225
  * PlaywrightTestConfig object. CLI `--playwrightConfig` only accepts a file path.
212
226
  */
213
227
  playwrightConfig?: string | PlaywrightTestConfig
214
- /** Filter tests to a specific playwright project or comma-separated list of projects (--project) */
215
- project?: string
228
+ /**
229
+ * Pool used to run server and E2E test files. Forked child processes are the default,
230
+ * but worker threads are available for projects that prefer the previous behavior.
231
+ */
232
+ pool?: RemixTestPool
233
+ /**
234
+ * Filter tests to specific playwright project(s) (--project). Accepts a single
235
+ * project name or an array of names; `--project` may be repeated on the CLI.
236
+ */
237
+ project?: string | string[]
216
238
  /** Test reporter (--reporter) */
217
239
  reporter?: string
218
- /** Comma-separated list of test types to run (--type) */
219
- type?: string
240
+ /**
241
+ * Test type(s) to run (--type). Accepts a single type or an array of types;
242
+ * `--type` may be repeated on the CLI. Valid values: "server", "browser", "e2e".
243
+ */
244
+ type?: string | string[]
220
245
  /** Watch mode — re-run tests on file changes (--watch) */
221
246
  watch?: boolean
222
247
  }
@@ -239,16 +264,17 @@ export interface ResolvedRemixTestConfig {
239
264
  }
240
265
  | undefined
241
266
  glob: {
242
- test: string
243
- browser: string
244
- e2e: string
245
- exclude: string
267
+ test: string[]
268
+ browser: string[]
269
+ e2e: string[]
270
+ exclude: string[]
246
271
  }
247
272
  playwrightConfig: string | PlaywrightTestConfig | undefined
248
- project: string | undefined
273
+ project: string[] | undefined
249
274
  reporter: string
275
+ pool: RemixTestPool
250
276
  setup: string | undefined
251
- type: string
277
+ type: string[]
252
278
  watch: boolean
253
279
  }
254
280
 
@@ -261,10 +287,10 @@ export async function loadConfig(args: string[] = process.argv.slice(2), cwd = p
261
287
 
262
288
  export function getRemixTestHelpText(_target: NodeJS.WriteStream = process.stdout): string {
263
289
  let lines = [
264
- 'Usage: remix-test [glob] [options]',
290
+ 'Usage: remix-test [glob...] [options]',
265
291
  '',
266
292
  'Arguments:',
267
- ` glob Glob pattern for test files (default: "${defaultValues.glob.test}")`,
293
+ ` glob Glob pattern(s) for test files (default: "${defaultValues.glob.test.join(', ')}")`,
268
294
  '',
269
295
  'Options:',
270
296
  ]
@@ -284,6 +310,19 @@ function parseCliArgs(args: string[]) {
284
310
  return util.parseArgs({ args, options: cliOptions, allowPositionals: true })
285
311
  }
286
312
 
313
+ function toArray<T>(value: T | readonly T[]): T[] {
314
+ return Array.isArray(value) ? [...value] : [value as T]
315
+ }
316
+
317
+ function toCommaSeparatedArray(value: string | readonly string[]): string[] {
318
+ return toArray(value).flatMap((item) =>
319
+ item
320
+ .split(',')
321
+ .map((part) => part.trim())
322
+ .filter(Boolean),
323
+ )
324
+ }
325
+
287
326
  function resolveConfig(
288
327
  fileConfig: RemixTestConfig,
289
328
  { values: cliValues, positionals }: ReturnType<typeof parseCliArgs>,
@@ -291,14 +330,18 @@ function resolveConfig(
291
330
  let fileCoverage = typeof fileConfig.coverage === 'boolean' ? {} : fileConfig.coverage || {}
292
331
  return {
293
332
  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,
333
+ test: toArray(
334
+ positionals.length > 0
335
+ ? positionals
336
+ : (cliValues['glob.test'] ?? fileConfig.glob?.test ?? defaultValues.glob.test),
337
+ ),
338
+ browser: toArray(
339
+ cliValues['glob.browser'] ?? fileConfig.glob?.browser ?? defaultValues.glob.browser,
340
+ ),
341
+ e2e: toArray(cliValues['glob.e2e'] ?? fileConfig.glob?.e2e ?? defaultValues.glob.e2e),
342
+ exclude: toArray(
343
+ cliValues['glob.exclude'] ?? fileConfig.glob?.exclude ?? defaultValues.glob.exclude,
344
+ ),
302
345
  },
303
346
  browser: {
304
347
  echo: cliValues['browser.echo'] ?? fileConfig.browser?.echo ?? defaultValues.browser.echo,
@@ -311,14 +354,20 @@ function resolveConfig(
311
354
  cliValues.coverage === true || !!fileConfig.coverage
312
355
  ? {
313
356
  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,
357
+ include: (() => {
358
+ let raw =
359
+ cliValues['coverage.include'] ??
360
+ fileCoverage.include ??
361
+ defaultValues.coverage!.include
362
+ return raw === undefined ? undefined : toArray(raw)
363
+ })(),
364
+ exclude: (() => {
365
+ let raw =
366
+ cliValues['coverage.exclude'] ??
367
+ fileCoverage.exclude ??
368
+ defaultValues.coverage!.exclude
369
+ return raw === undefined ? undefined : toArray(raw)
370
+ })(),
322
371
  statements:
323
372
  cliValues['coverage.statements'] !== undefined
324
373
  ? Number(cliValues['coverage.statements'])
@@ -348,13 +397,25 @@ function resolveConfig(
348
397
  setup: cliValues.setup ?? fileConfig.setup ?? defaultValues.setup,
349
398
  playwrightConfig:
350
399
  cliValues.playwrightConfig ?? fileConfig.playwrightConfig ?? defaultValues.playwrightConfig,
351
- project: cliValues.project ?? fileConfig.project ?? defaultValues.project,
400
+ pool: resolvePool(cliValues.pool ?? fileConfig.pool ?? defaultValues.pool),
401
+ project: (() => {
402
+ let raw = cliValues.project ?? fileConfig.project ?? defaultValues.project
403
+ return raw === undefined ? undefined : toCommaSeparatedArray(raw)
404
+ })(),
352
405
  reporter: cliValues.reporter ?? fileConfig.reporter ?? defaultValues.reporter,
353
- type: cliValues.type ?? fileConfig.type ?? defaultValues.type,
406
+ type: toCommaSeparatedArray(cliValues.type ?? fileConfig.type ?? defaultValues.type),
354
407
  watch: cliValues.watch ?? fileConfig.watch ?? defaultValues.watch,
355
408
  }
356
409
  }
357
410
 
411
+ function resolvePool(value: string): RemixTestPool {
412
+ if (value === 'forks' || value === 'threads') {
413
+ return value
414
+ }
415
+
416
+ throw new Error(`Unsupported test pool "${value}". Supported pools are: forks, threads`)
417
+ }
418
+
358
419
  async function loadConfigFile(
359
420
  configPath: string | undefined,
360
421
  cwd: string,
@@ -366,11 +427,16 @@ async function loadConfigFile(
366
427
  for (let candidate of candidates) {
367
428
  try {
368
429
  await fsp.access(candidate)
369
- let mod = await importModule(candidate, import.meta)
370
- return mod.default ?? mod
371
430
  } catch {
372
- // not found or failed to load — try next
431
+ // not found — try the next candidate
432
+ continue
373
433
  }
434
+ // The file exists; let import errors propagate rather than silently
435
+ // falling through to defaults — that masking is what hid "Windows
436
+ // absolute paths aren't valid ESM specifiers" by classifying every
437
+ // browser test as a server test.
438
+ let mod = await importModule(candidate, import.meta)
439
+ return mod.default ?? mod
374
440
  }
375
441
 
376
442
  return {}
@@ -2,6 +2,13 @@ import { mock } from './mock.ts'
2
2
 
3
3
  export interface FakeTimers {
4
4
  advance(ms: number): void
5
+ /**
6
+ * Like `advance`, but yields to microtasks between each timer firing so
7
+ * Promise continuations (and any timers they schedule) can settle before
8
+ * the next firing is processed. Use this when a callback awaits work that
9
+ * itself depends on the fake clock.
10
+ */
11
+ advanceAsync(ms: number): Promise<void>
5
12
  restore(): void
6
13
  }
7
14
 
@@ -35,20 +42,38 @@ export function createFakeTimers(): FakeTimers {
35
42
  cancel as unknown as typeof clearInterval,
36
43
  )
37
44
 
45
+ function takeNext(targetTime: number) {
46
+ let next = pending.filter((t) => t.time <= targetTime).sort((a, b) => a.time - b.time)[0]
47
+ if (!next) return null
48
+ currentTime = next.time
49
+ pending = pending.filter((t) => t.id !== next.id)
50
+ // Requeue intervals before running the callback so that calling
51
+ // clearInterval(id) from inside the callback can cancel the next firing.
52
+ if (next.repeatMs !== undefined) {
53
+ pending.push({ ...next, time: next.time + Math.max(1, next.repeatMs) })
54
+ }
55
+ return next
56
+ }
57
+
38
58
  return {
39
59
  advance(ms: number) {
40
60
  let targetTime = currentTime + ms
41
61
  while (true) {
42
- let next = pending.filter((t) => t.time <= targetTime).sort((a, b) => a.time - b.time)[0]
62
+ let next = takeNext(targetTime)
63
+ if (!next) break
64
+ next.fn()
65
+ }
66
+ currentTime = targetTime
67
+ },
68
+ async advanceAsync(ms: number) {
69
+ let targetTime = currentTime + ms
70
+ while (true) {
71
+ let next = takeNext(targetTime)
43
72
  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
73
  next.fn()
74
+ // Drain microtasks so Promise continuations (and any timers they
75
+ // schedule) can settle before we look for the next firing.
76
+ await Promise.resolve()
52
77
  }
53
78
  currentTime = targetTime
54
79
  },
@@ -1,3 +1,5 @@
1
+ import * as path from 'node:path'
2
+ import { pathToFileURL } from 'node:url'
1
3
  import { tsImport } from 'tsx/esm/api'
2
4
  import { IS_BUN } from './runtime.ts'
3
5
 
@@ -17,13 +19,21 @@ function hasImportMetaResolve(meta: ImportMeta): meta is ImportMetaWithResolve {
17
19
  * @returns The imported module namespace.
18
20
  */
19
21
  export async function importModule(specifier: string, meta: ImportMeta): Promise<any> {
22
+ // Absolute Windows paths (`C:\foo\bar.ts`) aren't valid ESM specifiers — only
23
+ // `file:///C:/foo/bar.ts` URLs, relative specifiers, or POSIX absolute paths
24
+ // are. Convert any absolute filesystem path to its `file:` URL so loaders like
25
+ // `tsImport` and `import()` accept it on every platform. POSIX absolute paths
26
+ // happen to work as specifiers without conversion, but going through
27
+ // `pathToFileURL` is safe and platform-agnostic.
28
+ let resolvedSpecifier = path.isAbsolute(specifier) ? pathToFileURL(specifier).href : specifier
29
+
20
30
  if (IS_BUN) {
21
31
  if (!hasImportMetaResolve(meta)) {
22
32
  throw new Error('importModule() requires import.meta.resolve() in Bun')
23
33
  }
24
34
 
25
- return import(meta.resolve(specifier, meta.url))
35
+ return import(meta.resolve(resolvedSpecifier, meta.url))
26
36
  }
27
37
 
28
- return tsImport(specifier, meta.url)
38
+ return tsImport(resolvedSpecifier, meta.url)
29
39
  }
@@ -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}`)