@remix-run/test 0.1.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 (159) hide show
  1. package/README.md +161 -50
  2. package/dist/app/client/entry.d.ts +2 -0
  3. package/dist/app/client/entry.d.ts.map +1 -0
  4. package/dist/app/client/entry.js +328 -0
  5. package/dist/app/client/iframe.d.ts +2 -0
  6. package/dist/app/client/iframe.d.ts.map +1 -0
  7. package/dist/app/client/iframe.js +22 -0
  8. package/dist/app/server.d.ts +6 -0
  9. package/dist/app/server.d.ts.map +1 -0
  10. package/dist/app/server.js +303 -0
  11. package/dist/cli-entry.d.ts +3 -0
  12. package/dist/cli-entry.d.ts.map +1 -0
  13. package/dist/cli-entry.js +14 -0
  14. package/dist/cli.d.ts +7 -2
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +319 -140
  17. package/dist/index.d.ts +2 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/lib/colors.d.ts +2 -0
  20. package/dist/lib/colors.d.ts.map +1 -0
  21. package/dist/lib/colors.js +2 -0
  22. package/dist/lib/config.d.ts +59 -14
  23. package/dist/lib/config.d.ts.map +1 -1
  24. package/dist/lib/config.js +181 -38
  25. package/dist/lib/context.d.ts +37 -13
  26. package/dist/lib/context.d.ts.map +1 -1
  27. package/dist/lib/context.js +19 -3
  28. package/dist/lib/coverage-loader.d.ts +16 -0
  29. package/dist/lib/coverage-loader.d.ts.map +1 -0
  30. package/dist/lib/coverage-loader.js +20 -0
  31. package/dist/lib/coverage.d.ts +28 -0
  32. package/dist/lib/coverage.d.ts.map +1 -0
  33. package/dist/lib/coverage.js +212 -0
  34. package/dist/lib/executor.d.ts +3 -26
  35. package/dist/lib/executor.d.ts.map +1 -1
  36. package/dist/lib/executor.js +11 -6
  37. package/dist/lib/fake-timers.d.ts +13 -0
  38. package/dist/lib/fake-timers.d.ts.map +1 -0
  39. package/dist/lib/fake-timers.js +64 -0
  40. package/dist/lib/import-module.d.ts +2 -0
  41. package/dist/lib/import-module.d.ts.map +1 -0
  42. package/dist/lib/import-module.js +38 -0
  43. package/dist/lib/normalize.d.ts +2 -0
  44. package/dist/lib/normalize.d.ts.map +1 -0
  45. package/dist/lib/{utils.js → normalize.js} +0 -9
  46. package/dist/lib/playwright.d.ts +1 -1
  47. package/dist/lib/playwright.d.ts.map +1 -1
  48. package/dist/lib/playwright.js +5 -8
  49. package/dist/lib/reporters/dot.d.ts +1 -2
  50. package/dist/lib/reporters/dot.d.ts.map +1 -1
  51. package/dist/lib/reporters/dot.js +12 -1
  52. package/dist/lib/reporters/files.d.ts +1 -2
  53. package/dist/lib/reporters/files.d.ts.map +1 -1
  54. package/dist/lib/reporters/files.js +12 -1
  55. package/dist/lib/reporters/index.d.ts +4 -5
  56. package/dist/lib/reporters/index.d.ts.map +1 -1
  57. package/dist/lib/reporters/index.js +3 -3
  58. package/dist/lib/reporters/results.d.ts +30 -0
  59. package/dist/lib/reporters/results.d.ts.map +1 -0
  60. package/dist/lib/reporters/results.js +1 -0
  61. package/dist/lib/reporters/spec.d.ts +1 -2
  62. package/dist/lib/reporters/spec.d.ts.map +1 -1
  63. package/dist/lib/reporters/spec.js +12 -1
  64. package/dist/lib/reporters/tap.d.ts +1 -2
  65. package/dist/lib/reporters/tap.d.ts.map +1 -1
  66. package/dist/lib/reporters/tap.js +11 -1
  67. package/dist/lib/runner-browser.d.ts +21 -0
  68. package/dist/lib/runner-browser.d.ts.map +1 -0
  69. package/dist/lib/runner-browser.js +123 -0
  70. package/dist/lib/runner.d.ts +24 -2
  71. package/dist/lib/runner.d.ts.map +1 -1
  72. package/dist/lib/runner.js +216 -38
  73. package/dist/lib/runtime.d.ts +2 -0
  74. package/dist/lib/runtime.d.ts.map +1 -0
  75. package/dist/lib/runtime.js +2 -0
  76. package/dist/lib/ts-transform.d.ts +4 -0
  77. package/dist/lib/ts-transform.d.ts.map +1 -0
  78. package/dist/lib/ts-transform.js +29 -0
  79. package/dist/lib/worker-e2e-file.d.ts +11 -0
  80. package/dist/lib/worker-e2e-file.d.ts.map +1 -0
  81. package/dist/lib/worker-e2e-file.js +69 -0
  82. package/dist/lib/worker-e2e.js +11 -46
  83. package/dist/lib/worker-process.d.ts +2 -0
  84. package/dist/lib/worker-process.d.ts.map +1 -0
  85. package/dist/lib/worker-process.js +55 -0
  86. package/dist/lib/worker-results.d.ts +3 -0
  87. package/dist/lib/worker-results.d.ts.map +1 -0
  88. package/dist/lib/worker-results.js +20 -0
  89. package/dist/lib/worker-server.d.ts +10 -0
  90. package/dist/lib/worker-server.d.ts.map +1 -0
  91. package/dist/lib/worker-server.js +113 -0
  92. package/dist/lib/worker.js +7 -28
  93. package/dist/test/coverage/fixture.d.ts +5 -0
  94. package/dist/test/coverage/fixture.d.ts.map +1 -0
  95. package/dist/test/coverage/fixture.js +32 -0
  96. package/dist/test/coverage/test-browser.d.ts +2 -0
  97. package/dist/test/coverage/test-browser.d.ts.map +1 -0
  98. package/dist/test/coverage/test-browser.js +24 -0
  99. package/dist/test/coverage/test-e2e.d.ts +2 -0
  100. package/dist/test/coverage/test-e2e.d.ts.map +1 -0
  101. package/dist/test/coverage/test-e2e.js +60 -0
  102. package/dist/test/coverage/test-unit.d.ts +2 -0
  103. package/dist/test/coverage/test-unit.d.ts.map +1 -0
  104. package/dist/test/coverage/test-unit.js +27 -0
  105. package/dist/test/framework.test.browser.d.ts +2 -0
  106. package/dist/test/framework.test.browser.d.ts.map +1 -0
  107. package/dist/test/framework.test.browser.js +107 -0
  108. package/dist/test/framework.test.e2e.d.ts.map +1 -0
  109. package/dist/test/framework.test.e2e.js +34 -0
  110. package/package.json +30 -9
  111. package/src/app/client/entry.ts +357 -0
  112. package/src/app/client/iframe.ts +18 -0
  113. package/src/app/server.ts +336 -0
  114. package/src/cli-entry.ts +15 -0
  115. package/src/cli.ts +382 -145
  116. package/src/index.ts +2 -1
  117. package/src/lib/colors.ts +3 -0
  118. package/src/lib/config.ts +266 -54
  119. package/src/lib/context.ts +59 -17
  120. package/src/lib/coverage-loader.ts +31 -0
  121. package/src/lib/coverage.ts +320 -0
  122. package/src/lib/executor.ts +18 -35
  123. package/src/lib/fake-timers.ts +89 -0
  124. package/src/lib/import-module.ts +39 -0
  125. package/src/lib/{utils.ts → normalize.ts} +0 -18
  126. package/src/lib/playwright.ts +5 -7
  127. package/src/lib/reporters/dot.ts +12 -2
  128. package/src/lib/reporters/files.ts +12 -2
  129. package/src/lib/reporters/index.ts +4 -5
  130. package/src/lib/reporters/results.ts +29 -0
  131. package/src/lib/reporters/spec.ts +12 -2
  132. package/src/lib/reporters/tap.ts +11 -2
  133. package/src/lib/runner-browser.ts +171 -0
  134. package/src/lib/runner.ts +308 -53
  135. package/src/lib/runtime.ts +2 -0
  136. package/src/lib/ts-transform.ts +36 -0
  137. package/src/lib/worker-e2e-file.ts +98 -0
  138. package/src/lib/worker-e2e.ts +14 -49
  139. package/src/lib/worker-process.ts +69 -0
  140. package/src/lib/worker-results.ts +22 -0
  141. package/src/lib/worker-server.ts +123 -0
  142. package/src/lib/worker.ts +8 -28
  143. package/src/test/coverage/fixture.ts +34 -0
  144. package/src/test/coverage/test-browser.ts +29 -0
  145. package/src/test/coverage/test-e2e.ts +70 -0
  146. package/src/test/coverage/test-unit.ts +32 -0
  147. package/tsconfig.json +3 -1
  148. package/dist/lib/e2e-server.d.ts +0 -11
  149. package/dist/lib/e2e-server.d.ts.map +0 -1
  150. package/dist/lib/e2e-server.js +0 -15
  151. package/dist/lib/framework.test.d.ts +0 -2
  152. package/dist/lib/framework.test.d.ts.map +0 -1
  153. package/dist/lib/framework.test.e2e.d.ts.map +0 -1
  154. package/dist/lib/framework.test.e2e.js +0 -29
  155. package/dist/lib/framework.test.js +0 -283
  156. package/dist/lib/utils.d.ts +0 -16
  157. package/dist/lib/utils.d.ts.map +0 -1
  158. package/src/lib/e2e-server.ts +0 -28
  159. /package/dist/{lib → test}/framework.test.e2e.d.ts +0 -0
package/src/lib/config.ts CHANGED
@@ -1,9 +1,34 @@
1
+ import * as fsp from 'node:fs/promises'
1
2
  import * as os from 'node:os'
2
3
  import * as path from 'node:path'
3
- import * as fsp from 'node:fs/promises'
4
+ import { fileURLToPath } from 'node:url'
4
5
  import * as util from 'node:util'
5
- import { tsImport } from 'tsx/esm/api'
6
6
  import type { PlaywrightTestConfig } from 'playwright/test'
7
+ import { importModule } from './import-module.ts'
8
+
9
+ export const IS_RUNNING_FROM_SRC = path.extname(new URL(import.meta.url).pathname) === '.ts'
10
+
11
+ /*
12
+ * The root directory for the test code. Coverage URLs are emitted as
13
+ * `/scripts/<rel-from-rootDir>` and resolved back via the same anchor.
14
+ *
15
+ * - In a published install: `process.cwd()`, since deps and user source all
16
+ * live under it.
17
+ * - In monorepo src mode: the monorepo root, computed by walking back from
18
+ * the resolved `@remix-run/test` source path. `process.cwd()` doesn't work
19
+ * here because workspace deps and node_modules live above the per-package
20
+ * cwd.
21
+ */
22
+ export function getBrowserTestRootDir(): string {
23
+ return IS_RUNNING_FROM_SRC
24
+ ? // Resolve to packages/test/src/index.ts and the pop 3 directories off to the repo root
25
+ path
26
+ .dirname(fileURLToPath(import.meta.resolve('@remix-run/test')))
27
+ .split(path.sep)
28
+ .slice(0, -3)
29
+ .join(path.sep)
30
+ : process.cwd()
31
+ }
7
32
 
8
33
  // prettier-ignore
9
34
  // Note: `description` is not a field used by parseArgs(), it's an additional field
@@ -17,13 +42,25 @@ const cliOptions = {
17
42
  type: 'boolean',
18
43
  description: 'Open browser window and keep open after tests finish',
19
44
  },
45
+ 'glob.browser': {
46
+ type: 'string',
47
+ multiple: true,
48
+ description: 'Glob pattern(s) for browser test files',
49
+ },
20
50
  'glob.e2e': {
21
51
  type: 'string',
22
- description: 'Glob pattern for E2E test files',
52
+ multiple: true,
53
+ description: 'Glob pattern(s) for E2E test files',
54
+ },
55
+ 'glob.exclude': {
56
+ type: 'string',
57
+ multiple: true,
58
+ description: 'Glob pattern(s) for paths to exclude from discovery',
23
59
  },
24
60
  'glob.test': {
25
61
  type: 'string',
26
- description: 'Glob pattern for all test files',
62
+ multiple: true,
63
+ description: 'Glob pattern(s) for all test files',
27
64
  },
28
65
  concurrency: {
29
66
  type: 'string',
@@ -34,6 +71,40 @@ const cliOptions = {
34
71
  type: 'string',
35
72
  description: 'Path to config file (default: remix-test.config.ts)',
36
73
  },
74
+ coverage: {
75
+ type: 'boolean',
76
+ description: 'Enable or disable coverage collection (default: false)',
77
+ },
78
+ 'coverage.dir': {
79
+ type: 'string',
80
+ description: 'Directory to output coverage reports (default: .coverage)',
81
+ },
82
+ 'coverage.include': {
83
+ type: 'string',
84
+ multiple: true,
85
+ description: 'Glob pattern(s) for files to include in coverage',
86
+ },
87
+ 'coverage.exclude': {
88
+ type: 'string',
89
+ multiple: true,
90
+ description: 'Glob pattern(s) for files to exclude from coverage',
91
+ },
92
+ 'coverage.branches': {
93
+ type: 'string',
94
+ description: 'Branches coverage threshold percentage',
95
+ },
96
+ 'coverage.functions': {
97
+ type: 'string',
98
+ description: 'Functions coverage threshold percentage',
99
+ },
100
+ 'coverage.lines': {
101
+ type: 'string',
102
+ description: 'Lines coverage threshold percentage',
103
+ },
104
+ 'coverage.statements': {
105
+ type: 'string',
106
+ description: 'Statements coverage threshold percentage',
107
+ },
37
108
  setup: {
38
109
  type: 'string',
39
110
  short: 's',
@@ -46,7 +117,12 @@ const cliOptions = {
46
117
  project: {
47
118
  type: 'string',
48
119
  short: 'p',
49
- 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)',
50
126
  },
51
127
  reporter: {
52
128
  type: 'string',
@@ -56,7 +132,8 @@ const cliOptions = {
56
132
  type: {
57
133
  type: 'string',
58
134
  short: 't',
59
- description: 'Comma-separated test types to run (default: server,e2e)',
135
+ multiple: true,
136
+ description: 'Test types to run (default: server, browser, e2e)',
60
137
  },
61
138
  watch: {
62
139
  type: 'boolean',
@@ -71,17 +148,31 @@ const defaultValues: ResolvedRemixTestConfig = {
71
148
  open: false,
72
149
  },
73
150
  concurrency: os.availableParallelism(),
151
+ coverage: {
152
+ dir: '.coverage',
153
+ include: undefined,
154
+ exclude: undefined,
155
+ statements: undefined,
156
+ lines: undefined,
157
+ branches: undefined,
158
+ functions: undefined,
159
+ },
74
160
  glob: {
75
- test: '**/*.test?(.e2e).{ts,tsx}',
76
- e2e: '**/*.test.e2e.{ts,tsx}',
161
+ test: ['**/*.test{,.e2e,.browser}.{ts,tsx}'],
162
+ browser: ['**/*.test.browser.{ts,tsx}'],
163
+ e2e: ['**/*.test.e2e.{ts,tsx}'],
164
+ exclude: ['node_modules/**'],
77
165
  },
78
- reporter: process.env.CI === 'true' ? 'dot' : 'spec',
79
- type: 'server,e2e',
80
- setup: undefined,
166
+ pool: 'forks',
81
167
  playwrightConfig: undefined,
82
168
  project: undefined,
169
+ reporter: process.env.CI === 'true' ? 'files' : 'spec',
170
+ setup: undefined,
171
+ type: ['server', 'browser', 'e2e'],
83
172
  watch: false,
84
- } as const
173
+ }
174
+
175
+ export type RemixTestPool = 'forks' | 'threads'
85
176
 
86
177
  export interface RemixTestConfig {
87
178
  /**
@@ -94,16 +185,36 @@ export interface RemixTestConfig {
94
185
  open?: boolean
95
186
  }
96
187
  /**
97
- * Glob patterns to identify test files
98
- * - `glob.test`: Glob pattern for all test files (--glob.test)
99
- * - `glob.e2e`: Glob pattern for the subset of e2e test files (--glob.e2e)
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)
100
194
  */
101
195
  glob?: {
102
- test?: string
103
- e2e?: string
196
+ test?: string | string[]
197
+ browser?: string | string[]
198
+ e2e?: string | string[]
199
+ exclude?: string | string[]
104
200
  }
105
201
  /** Max number of concurrent test workers (--concurrency) */
106
202
  concurrency?: number | string
203
+ /**
204
+ * Coverage configuration. `true` enables with defaults; an object enables with settings;
205
+ * `false` disables. CLI `--coverage` flag overrides the boolean aspect.
206
+ */
207
+ coverage?:
208
+ | boolean
209
+ | {
210
+ dir?: string
211
+ include?: string | string[]
212
+ exclude?: string | string[]
213
+ statements?: number | string
214
+ lines?: number | string
215
+ branches?: number | string
216
+ functions?: number | string
217
+ }
107
218
  /**
108
219
  * Path to a module that exports `globalSetup` and/or `globalTeardown` functions,
109
220
  * called once before and after the test run respectively. (--setup)
@@ -114,12 +225,23 @@ export interface RemixTestConfig {
114
225
  * PlaywrightTestConfig object. CLI `--playwrightConfig` only accepts a file path.
115
226
  */
116
227
  playwrightConfig?: string | PlaywrightTestConfig
117
- /** Filter tests to a specific playwright project or comma-separated list of projects (--project) */
118
- 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[]
119
238
  /** Test reporter (--reporter) */
120
239
  reporter?: string
121
- /** Comma-separated list of test types to run (--type) */
122
- 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[]
123
245
  /** Watch mode — re-run tests on file changes (--watch) */
124
246
  watch?: boolean
125
247
  }
@@ -130,36 +252,45 @@ export interface ResolvedRemixTestConfig {
130
252
  open?: boolean
131
253
  }
132
254
  concurrency: number
255
+ coverage:
256
+ | {
257
+ dir: string
258
+ include?: string[]
259
+ exclude?: string[]
260
+ statements?: number
261
+ lines?: number
262
+ branches?: number
263
+ functions?: number
264
+ }
265
+ | undefined
133
266
  glob: {
134
- test: string
135
- e2e: string
267
+ test: string[]
268
+ browser: string[]
269
+ e2e: string[]
270
+ exclude: string[]
136
271
  }
137
272
  playwrightConfig: string | PlaywrightTestConfig | undefined
138
- project: string | undefined
273
+ project: string[] | undefined
139
274
  reporter: string
275
+ pool: RemixTestPool
140
276
  setup: string | undefined
141
- type: string
277
+ type: string[]
142
278
  watch: boolean
143
279
  }
144
280
 
145
- export async function loadConfig() {
146
- if (process.argv.includes('--help') || process.argv.includes('-h')) {
147
- console.log(generateHelp())
148
- process.exit(0)
149
- }
150
-
151
- let parsed = parseCliArgs()
152
- let fileConfig = await loadConfigFile(parsed.values.config)
281
+ export async function loadConfig(args: string[] = process.argv.slice(2), cwd = process.cwd()) {
282
+ let parsed = parseCliArgs(args)
283
+ let fileConfig = await loadConfigFile(parsed.values.config, cwd)
153
284
  let config = resolveConfig(fileConfig, parsed)
154
285
  return config
155
286
  }
156
287
 
157
- function generateHelp(): string {
288
+ export function getRemixTestHelpText(_target: NodeJS.WriteStream = process.stdout): string {
158
289
  let lines = [
159
- 'Usage: remix-test [glob] [options]',
290
+ 'Usage: remix-test [glob...] [options]',
160
291
  '',
161
292
  'Arguments:',
162
- ` glob Glob pattern for test files (default: "${defaultValues.glob.test}")`,
293
+ ` glob Glob pattern(s) for test files (default: "${defaultValues.glob.test.join(', ')}")`,
163
294
  '',
164
295
  'Options:',
165
296
  ]
@@ -175,22 +306,42 @@ function generateHelp(): string {
175
306
  return lines.join('\n')
176
307
  }
177
308
 
178
- function parseCliArgs(args = process.argv.slice(2)) {
309
+ function parseCliArgs(args: string[]) {
179
310
  return util.parseArgs({ args, options: cliOptions, allowPositionals: true })
180
311
  }
181
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
+
182
326
  function resolveConfig(
183
327
  fileConfig: RemixTestConfig,
184
328
  { values: cliValues, positionals }: ReturnType<typeof parseCliArgs>,
185
329
  ): ResolvedRemixTestConfig {
330
+ let fileCoverage = typeof fileConfig.coverage === 'boolean' ? {} : fileConfig.coverage || {}
186
331
  return {
187
332
  glob: {
188
- test:
189
- positionals[0] ??
190
- cliValues['glob.test'] ??
191
- fileConfig.glob?.test ??
192
- defaultValues.glob.test,
193
- e2e: cliValues['glob.e2e'] ?? fileConfig.glob?.e2e ?? defaultValues.glob.e2e,
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
+ ),
194
345
  },
195
346
  browser: {
196
347
  echo: cliValues['browser.echo'] ?? fileConfig.browser?.echo ?? defaultValues.browser.echo,
@@ -199,32 +350,93 @@ function resolveConfig(
199
350
  concurrency: Number(
200
351
  cliValues.concurrency ?? fileConfig.concurrency ?? defaultValues.concurrency,
201
352
  ),
353
+ coverage:
354
+ cliValues.coverage === true || !!fileConfig.coverage
355
+ ? {
356
+ dir: cliValues['coverage.dir'] ?? fileCoverage.dir ?? defaultValues.coverage!.dir,
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
+ })(),
371
+ statements:
372
+ cliValues['coverage.statements'] !== undefined
373
+ ? Number(cliValues['coverage.statements'])
374
+ : fileCoverage.statements !== undefined
375
+ ? Number(fileCoverage.statements)
376
+ : undefined,
377
+ lines:
378
+ cliValues['coverage.lines'] !== undefined
379
+ ? Number(cliValues['coverage.lines'])
380
+ : fileCoverage.lines !== undefined
381
+ ? Number(fileCoverage.lines)
382
+ : undefined,
383
+ branches:
384
+ cliValues['coverage.branches'] !== undefined
385
+ ? Number(cliValues['coverage.branches'])
386
+ : fileCoverage.branches !== undefined
387
+ ? Number(fileCoverage.branches)
388
+ : undefined,
389
+ functions:
390
+ cliValues['coverage.functions'] !== undefined
391
+ ? Number(cliValues['coverage.functions'])
392
+ : fileCoverage.functions !== undefined
393
+ ? Number(fileCoverage.functions)
394
+ : undefined,
395
+ }
396
+ : undefined,
202
397
  setup: cliValues.setup ?? fileConfig.setup ?? defaultValues.setup,
203
398
  playwrightConfig:
204
399
  cliValues.playwrightConfig ?? fileConfig.playwrightConfig ?? defaultValues.playwrightConfig,
205
- 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
+ })(),
206
405
  reporter: cliValues.reporter ?? fileConfig.reporter ?? defaultValues.reporter,
207
- type: cliValues.type ?? fileConfig.type ?? defaultValues.type,
406
+ type: toCommaSeparatedArray(cliValues.type ?? fileConfig.type ?? defaultValues.type),
208
407
  watch: cliValues.watch ?? fileConfig.watch ?? defaultValues.watch,
209
408
  }
210
409
  }
211
410
 
212
- async function loadConfigFile(configPath?: string): Promise<RemixTestConfig> {
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
+
419
+ async function loadConfigFile(
420
+ configPath: string | undefined,
421
+ cwd: string,
422
+ ): Promise<RemixTestConfig> {
213
423
  let candidates = configPath
214
- ? [path.resolve(process.cwd(), configPath)]
215
- : [
216
- path.join(process.cwd(), 'remix-test.config.ts'),
217
- path.join(process.cwd(), 'remix-test.config.js'),
218
- ]
424
+ ? [path.resolve(cwd, configPath)]
425
+ : [path.join(cwd, 'remix-test.config.ts'), path.join(cwd, 'remix-test.config.js')]
219
426
 
220
427
  for (let candidate of candidates) {
221
428
  try {
222
429
  await fsp.access(candidate)
223
- let mod = await tsImport(candidate, { parentURL: import.meta.url })
224
- return mod.default ?? mod
225
430
  } catch {
226
- // not found or failed to load — try next
431
+ // not found — try the next candidate
432
+ continue
227
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
228
440
  }
229
441
 
230
442
  return {}
@@ -1,9 +1,19 @@
1
1
  import type { Browser, Page } from 'playwright'
2
- import { mock, type MockFunction, type MockCall, type MockContext } from './mock.ts'
3
-
4
- import type { CreateServerFunction } from './e2e-server.ts'
2
+ import type { V8CoverageEntry } from './coverage.ts'
3
+ import { createFakeTimers, type FakeTimers } from './fake-timers.ts'
4
+ import { mock, type MockCall, type MockContext, type MockFunction } from './mock.ts'
5
5
  import type { getPlaywrightPageOptions } from './playwright.ts'
6
6
 
7
+ /**
8
+ * The shape `t.serve()` consumes. Matches the result of `createTestServer`
9
+ * from `@remix-run/node-fetch-server/test`, but any object with a `baseUrl`
10
+ * and async `close()` works.
11
+ */
12
+ export interface TestServer {
13
+ baseUrl: string
14
+ close(): Promise<void>
15
+ }
16
+
7
17
  /**
8
18
  * Test Context providing utilities for testing via remix-test. The context is
9
19
  * passed as the first argument to the {@link test}/{@link it} functions.
@@ -58,20 +68,36 @@ export interface TestContext {
58
68
  }
59
69
 
60
70
  /**
61
- * Starts a test server with the provided request handler.
71
+ * Activates fake timers for testing time-dependent code.
72
+ *
73
+ * @returns {FakeTimers} A fake timers instance for controlling time
74
+ */
75
+ useFakeTimers(): FakeTimers
76
+
77
+ /**
78
+ * Wires a running test server up to a Playwright page so the test can drive
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.
62
82
  *
63
- * @param {(req: Request) => Promise<Response>} handler - Function handling incoming requests
64
- * @returns {Promise<Page>} A promise resolving to a page instance for the server
83
+ * @param server - The running server the page should target
84
+ * @returns A `Page` whose `baseURL` is set to `server.baseUrl`.
65
85
  */
66
- serve(handler: (req: Request) => Promise<Response>): Promise<Page>
86
+ serve(server: TestServer): Promise<Page>
87
+ }
88
+
89
+ export interface CreateTestContextOptions {
90
+ addE2ECoverageEntries: (value: { entries: V8CoverageEntry[]; baseUrl: string }) => void
91
+ browser: Browser
92
+ coverage: boolean
93
+ open: boolean
94
+ playwrightPageOptions: ReturnType<typeof getPlaywrightPageOptions>
67
95
  }
68
96
 
69
- export function createTestContext(options: {
70
- createServer?: CreateServerFunction
71
- browser?: Browser
72
- open?: boolean
73
- playwrightPageOptions?: ReturnType<typeof getPlaywrightPageOptions>
74
- }): { testContext: TestContext; cleanup(): Promise<void> } {
97
+ export function createTestContext(options?: CreateTestContextOptions): {
98
+ testContext: TestContext
99
+ cleanup(): Promise<void>
100
+ } {
75
101
  let cleanups: Array<() => void | Promise<void>> = []
76
102
 
77
103
  let testContext: TestContext = {
@@ -86,12 +112,16 @@ export function createTestContext(options: {
86
112
  after(fn) {
87
113
  cleanups.push(fn)
88
114
  },
89
- async serve(handler) {
90
- if (!options.createServer || !options.browser) {
115
+ useFakeTimers() {
116
+ let timers = createFakeTimers()
117
+ cleanups.push(timers.restore)
118
+ return timers
119
+ },
120
+ async serve(server) {
121
+ if (!options || !options.browser) {
91
122
  throw new Error('t.serve() is only available in E2E test suites')
92
123
  }
93
124
 
94
- let server = await options.createServer(handler)
95
125
  let page = await options.browser.newPage({
96
126
  ...options.playwrightPageOptions,
97
127
  baseURL: server.baseUrl,
@@ -103,6 +133,18 @@ export function createTestContext(options: {
103
133
  page.setDefaultTimeout(options.playwrightPageOptions.actionTimeout)
104
134
  }
105
135
 
136
+ let coverageEnabled = options.coverage && options.browser.browserType().name() === 'chromium'
137
+ if (coverageEnabled) {
138
+ await page.coverage.startJSCoverage({ resetOnNavigation: false })
139
+ cleanups.push(async () => {
140
+ let entries = await page.coverage.stopJSCoverage()
141
+ options.addE2ECoverageEntries?.({
142
+ entries: entries as unknown as V8CoverageEntry[],
143
+ baseUrl: server.baseUrl,
144
+ })
145
+ })
146
+ }
147
+
106
148
  cleanups.push(async () => {
107
149
  if (!options.open) {
108
150
  await page.close()
@@ -123,4 +165,4 @@ export function createTestContext(options: {
123
165
  }
124
166
  }
125
167
 
126
- export type { MockFunction, MockCall, MockContext }
168
+ export type { MockCall, MockContext, MockFunction }
@@ -0,0 +1,31 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { transformTypeScript } from './ts-transform.ts'
4
+
5
+ // Custom ESM loader hook for TypeScript files.
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
9
+ // cleanly to TypeScript source lines via the inline source map, giving
10
+ // accurate per-line coverage rather than collapsing multiple statements onto
11
+ // a single minified line.
12
+
13
+ export async function load(
14
+ url: string,
15
+ context: { format?: string },
16
+ nextLoad: (
17
+ url: string,
18
+ context: { format?: string },
19
+ ) => Promise<{ format: string; source: string }>,
20
+ ) {
21
+ let cleanUrl = url.includes('?') ? url.slice(0, url.indexOf('?')) : url
22
+ if (!cleanUrl.endsWith('.ts') && !cleanUrl.endsWith('.tsx')) {
23
+ return nextLoad(url, context)
24
+ }
25
+
26
+ let filePath = fileURLToPath(cleanUrl)
27
+ let source = await readFile(filePath, 'utf-8')
28
+ let { code } = await transformTypeScript(source, filePath)
29
+
30
+ return { format: 'module', source: code, shortCircuit: true }
31
+ }