@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/cli.ts CHANGED
@@ -1,210 +1,447 @@
1
- #!/usr/bin/env node
2
1
  import * as fsp from 'node:fs/promises'
2
+ import type * as http from 'node:http'
3
3
  import * as path from 'node:path'
4
- import { tsImport } from 'tsx/esm/api'
5
- import { runServerTests } from './lib/runner.ts'
4
+ import type * as browserTestRunner from './lib/runner-browser.ts'
5
+ import {
6
+ getRemixTestHelpText,
7
+ IS_RUNNING_FROM_SRC,
8
+ loadConfig,
9
+ type ResolvedRemixTestConfig,
10
+ } from './lib/config.ts'
11
+ import type * as playwrightSupport from './lib/playwright.ts'
12
+ import { generateCombinedCoverageReport } from './lib/coverage.ts'
6
13
  import { createReporter } from './lib/reporters/index.ts'
14
+ import { runServerTests } from './lib/runner.ts'
7
15
  import { createWatcher } from './lib/watcher.ts'
8
- import { loadPlaywrightConfig, resolveProjects } from './lib/playwright.ts'
9
- import { loadConfig, type ResolvedRemixTestConfig } from './lib/config.ts'
10
- import type { Counts } from './lib/utils.ts'
16
+ import { importModule } from './lib/import-module.ts'
17
+ import type { Counts } from './lib/reporters/results.ts'
18
+ import { IS_BUN } from './lib/runtime.ts'
19
+ import { isMainThread } from 'node:worker_threads'
20
+
21
+ export { getRemixTestHelpText }
22
+
23
+ const MISSING_PLAYWRIGHT_MESSAGE =
24
+ 'Playwright is required to run browser and E2E tests. Install it with `npm i -D playwright`.'
25
+
26
+ export interface RunRemixTestOptions {
27
+ argv?: string[]
28
+ cwd?: string
29
+ }
11
30
 
12
- const config = await loadConfig()
31
+ type RunBrowserTests = typeof browserTestRunner.runBrowserTests
13
32
 
14
- let hasExited = false
15
- let latestExitCode = 0
16
- let watcher: ReturnType<typeof createWatcher> | undefined
17
- let running = false
18
- let queued = false
19
- let rerunTimer: NodeJS.Timeout | undefined
33
+ interface DiscoveredTests {
34
+ files: string[]
35
+ serverFiles: string[]
36
+ browserFiles: string[]
37
+ e2eFiles: string[]
38
+ }
20
39
 
21
- process.on('SIGINT', () => cleanupAndExit(latestExitCode))
22
- process.on('SIGTERM', () => cleanupAndExit(latestExitCode))
40
+ export async function runRemixTest(options: RunRemixTestOptions = {}): Promise<number> {
41
+ let argv = options.argv ?? process.argv.slice(2)
42
+ let cwd = await resolveCwd(options.cwd ?? process.cwd())
43
+ let previousCwd = process.cwd()
23
44
 
24
- try {
25
- await executeRun()
45
+ if (!isMainThread) {
46
+ return await runRemixTestInCwd(argv, cwd)
47
+ }
26
48
 
27
- if (config.watch) {
28
- console.log('Watching for changes. Press Ctrl+C to stop.')
49
+ try {
50
+ process.chdir(cwd)
51
+ return await runRemixTestInCwd(argv, cwd)
52
+ } finally {
53
+ process.chdir(previousCwd)
29
54
  }
30
- } catch {
31
- cleanupAndExit(1)
32
55
  }
33
56
 
34
- async function executeRun() {
35
- if (hasExited) return
57
+ async function runRemixTestInCwd(argv: string[], cwd: string): Promise<number> {
58
+ if (argv.includes('--help') || argv.includes('-h')) {
59
+ console.log(getRemixTestHelpText())
60
+ return 0
61
+ }
36
62
 
37
- running = true
63
+ let config = await loadConfig(argv, cwd)
64
+ let hasExited = false
65
+ let latestExitCode = 0
66
+ let watcher: ReturnType<typeof createWatcher> | undefined
67
+ let running = false
68
+ let queued = false
69
+ let rerunTimer: NodeJS.Timeout | undefined
70
+ let browserServer: http.Server | undefined
71
+ let browserServerFilesKey: string | undefined
72
+ let browserPort: number | undefined
73
+ let resolveRun: ((exitCode: number) => void) | undefined
74
+
75
+ let runPromise = new Promise<number>((resolve) => {
76
+ resolveRun = resolve
77
+ })
78
+
79
+ let cleanupAndExit = (code: number) => {
80
+ if (hasExited) return
81
+ hasExited = true
82
+ watcher?.close()
83
+ browserServer?.close()
84
+ clearTimeout(rerunTimer)
85
+ process.off('SIGINT', handleInterrupt)
86
+ process.off('SIGTERM', handleInterrupt)
87
+ resolveRun?.(code)
88
+ }
38
89
 
39
- let globalTeardown: (() => Promise<void> | void) | undefined
90
+ let handleInterrupt = () => cleanupAndExit(latestExitCode)
91
+
92
+ let closeBrowserServer = async () => {
93
+ if (!browserServer) return
94
+ let server = browserServer
95
+ await new Promise<void>((resolve, reject) =>
96
+ server.close((error) => (error ? reject(error) : resolve())),
97
+ )
98
+ browserServer = undefined
99
+ browserServerFilesKey = undefined
100
+ browserPort = undefined
101
+ }
40
102
 
41
- try {
42
- if (config.setup) {
43
- let mod = await tsImport(path.resolve(process.cwd(), config.setup), {
44
- parentURL: import.meta.url,
45
- })
46
- let globalSetup: (() => Promise<void> | void) | undefined = mod.globalSetup
47
- globalTeardown = mod.globalTeardown
48
- await globalSetup?.()
49
- }
103
+ let queueRerun = (reason: string) => {
104
+ if (!config.watch || hasExited) return
50
105
 
51
- let { files, serverFiles, e2eFiles } = await discoverTests(config)
106
+ clearTimeout(rerunTimer)
52
107
 
53
- if (config.watch) {
54
- watcher ??= createWatcher((file) => queueRerun(file))
55
- watcher.update(files)
56
- }
108
+ rerunTimer = setTimeout(() => {
109
+ rerunTimer = undefined
110
+ if (running) {
111
+ queued = true
112
+ } else {
113
+ console.log(`\n↻ Change detected (${reason}), re-running tests...\n`)
114
+ void executeRun()
115
+ }
116
+ }, 100)
117
+ }
57
118
 
58
- let playwrightConfig =
59
- config.playwrightConfig == null || typeof config.playwrightConfig === 'string'
60
- ? await loadPlaywrightConfig(config.playwrightConfig)
61
- : config.playwrightConfig
119
+ let executeRun = async () => {
120
+ if (hasExited) return
62
121
 
63
- let reporter = createReporter(config.reporter)
64
- let startTime = performance.now()
122
+ running = true
65
123
 
66
- let counts: Counts = {
67
- passed: 0,
68
- failed: 0,
69
- skipped: 0,
70
- todo: 0,
71
- }
124
+ let globalTeardown: (() => Promise<void> | void) | undefined
72
125
 
73
- // Run server tests
74
- if (serverFiles.length > 0) {
75
- reporter.onSectionStart('\nRunning server tests:')
76
- let serverResult = await runServerTests(serverFiles, reporter, config.concurrency, 'server')
77
- counts.failed += serverResult.failed
78
- counts.passed += serverResult.passed
79
- counts.skipped += serverResult.skipped
80
- counts.todo += serverResult.todo
81
- }
126
+ try {
127
+ if (config.setup) {
128
+ let mod = await importModule(path.resolve(cwd, config.setup), import.meta)
129
+ let globalSetup: (() => Promise<void> | void) | undefined = mod.globalSetup
130
+ globalTeardown = mod.globalTeardown
131
+ await globalSetup?.()
132
+ }
82
133
 
83
- // Run e2e tests for all browsers configured by the user
84
- if (e2eFiles.length > 0) {
85
- let projects = resolveProjects(playwrightConfig)
86
- if (config.project) {
87
- let projectNames = config.project.split(',').map((p) => p.trim())
88
- projects = projects.filter((p) => p.name && projectNames.includes(p.name))
89
- if (projects.length === 0) {
90
- throw new Error(`No playwright projects found with name(s) "${config.project}"`)
91
- }
134
+ let discoveredTests = await discoverTests(config, cwd)
135
+ if (discoveredTests == null) {
136
+ latestExitCode = 1
137
+ cleanupAndExit(latestExitCode)
138
+ return
139
+ }
140
+
141
+ let { files, serverFiles, browserFiles, e2eFiles } = discoveredTests
142
+
143
+ if (config.watch) {
144
+ watcher ??= createWatcher((file) => queueRerun(file))
145
+ watcher.update(files)
146
+ }
147
+
148
+ let browserFilesKey = browserFiles.join('\0')
149
+ if (browserServer && browserFiles.length === 0) {
150
+ await closeBrowserServer()
151
+ } else if (
152
+ browserFiles.length > 0 &&
153
+ (!browserServer || browserServerFilesKey !== browserFilesKey)
154
+ ) {
155
+ await closeBrowserServer()
156
+ let { startServer } = IS_RUNNING_FROM_SRC
157
+ ? await importModule('./app/server.ts', import.meta)
158
+ : await import(`./app/server.js`)
159
+ let result = await startServer(browserFiles)
160
+ browserServer = result.server
161
+ browserServerFilesKey = browserFilesKey
162
+ browserPort = result.port
92
163
  }
93
164
 
94
- for (let project of projects) {
95
- reporter.onSectionStart(`\nRunning tests for project \`${project.name}\`:`)
165
+ let reporter = createReporter(config.reporter)
166
+ let startTime = performance.now()
96
167
 
97
- if (config.browser?.open) {
98
- if (project.playwrightUseOpts?.headless === true) {
99
- let label = project.name ? ` (project "${project.name}")` : ''
100
- console.warn(
101
- `Warning: browser.open is set but playwright headless is explicitly true${label} — ignoring browser.open`,
168
+ let counts: Counts = {
169
+ passed: 0,
170
+ failed: 0,
171
+ skipped: 0,
172
+ todo: 0,
173
+ }
174
+ let allCoverageMaps: Array<ReturnType<typeof Object.values>[number] | null | undefined> = []
175
+
176
+ if (serverFiles.length > 0) {
177
+ reporter.onSectionStart('\nRunning server tests:')
178
+ let serverResult = await runServerTests(
179
+ serverFiles,
180
+ reporter,
181
+ config.concurrency,
182
+ 'server',
183
+ {
184
+ coverage: config.coverage,
185
+ cwd,
186
+ pool: config.pool,
187
+ },
188
+ )
189
+ counts.failed += serverResult.failed
190
+ counts.passed += serverResult.passed
191
+ counts.skipped += serverResult.skipped
192
+ counts.todo += serverResult.todo
193
+ allCoverageMaps.push(serverResult.coverageMap)
194
+ }
195
+
196
+ // Run browser/e2e tests for all browsers configured by the user
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
205
+ let projects = resolveProjects(playwrightConfig)
206
+
207
+ if (config.project) {
208
+ let projectNames = new Set(config.project)
209
+ projects = projects.filter((project) => project.name && projectNames.has(project.name))
210
+ if (projects.length === 0) {
211
+ throw new Error(
212
+ `No playwright projects found with name(s) "${config.project.join(', ')}"`,
102
213
  )
103
- } else {
104
- project.playwrightUseOpts = { ...project.playwrightUseOpts, headless: false }
105
214
  }
106
215
  }
107
216
 
108
- let e2eResult =
109
- e2eFiles.length > 0
110
- ? await runServerTests(e2eFiles, reporter, config.concurrency, 'e2e', {
111
- open: config.browser?.open,
112
- playwrightUseOpts: project.playwrightUseOpts,
113
- projectName: project.name,
114
- })
115
- : null
116
-
117
- counts.passed += e2eResult?.passed ?? 0
118
- counts.failed += e2eResult?.failed ?? 0
119
- counts.skipped += e2eResult?.skipped ?? 0
120
- counts.todo += e2eResult?.todo ?? 0
217
+ let lastBrowserResult: Awaited<ReturnType<RunBrowserTests>> | null = null
218
+
219
+ for (let project of projects) {
220
+ reporter.onSectionStart(`\nRunning tests for project \`${project.name}\`:`)
221
+
222
+ if (config.browser?.open) {
223
+ if (project.playwrightUseOpts?.headless === true) {
224
+ let label = project.name ? ` (project "${project.name}")` : ''
225
+ console.warn(
226
+ `Warning: browser.open is set but playwright headless is explicitly true${label} — ignoring browser.open`,
227
+ )
228
+ } else {
229
+ project.playwrightUseOpts = { ...project.playwrightUseOpts, headless: false }
230
+ }
231
+ }
232
+
233
+ let [browserResult, e2eResult] = await Promise.all([
234
+ runBrowserTests != null
235
+ ? runBrowserTests({
236
+ baseUrl: `http://localhost:${browserPort}`,
237
+ console: config.browser?.echo,
238
+ coverage: !!config.coverage,
239
+ open: config.browser?.open,
240
+ playwrightUseOpts: project.playwrightUseOpts,
241
+ projectName: project.name,
242
+ reporter,
243
+ testFiles: browserFiles,
244
+ })
245
+ : null,
246
+ e2eFiles.length > 0
247
+ ? runServerTests(e2eFiles, reporter, config.concurrency, 'e2e', {
248
+ open: config.browser?.open,
249
+ playwrightUseOpts: project.playwrightUseOpts,
250
+ projectName: project.name,
251
+ coverage: config.coverage,
252
+ cwd,
253
+ pool: config.pool,
254
+ })
255
+ : null,
256
+ ])
257
+
258
+ counts.passed += (browserResult?.results.passed ?? 0) + (e2eResult?.passed ?? 0)
259
+ counts.failed += (browserResult?.results.failed ?? 0) + (e2eResult?.failed ?? 0)
260
+ counts.skipped += (browserResult?.results.skipped ?? 0) + (e2eResult?.skipped ?? 0)
261
+ counts.todo += (browserResult?.results.todo ?? 0) + (e2eResult?.todo ?? 0)
262
+ allCoverageMaps.push(browserResult?.coverageMap)
263
+ allCoverageMaps.push(e2eResult?.coverageMap)
264
+
265
+ if (browserResult) {
266
+ lastBrowserResult = browserResult
267
+ }
268
+ }
269
+
270
+ if (config.browser?.open && lastBrowserResult) {
271
+ console.log('\nBrowser is open. Press Ctrl+C to close.')
272
+ await Promise.race([
273
+ lastBrowserResult.disconnected,
274
+ new Promise<void>((resolve) => {
275
+ process.once('SIGINT', resolve)
276
+ process.once('SIGTERM', resolve)
277
+ }),
278
+ ])
279
+ await lastBrowserResult.close()
280
+ }
281
+ }
282
+
283
+ reporter.onSummary(counts, performance.now() - startTime)
284
+
285
+ let thresholdsPassed = true
286
+ if (config.coverage) {
287
+ thresholdsPassed = await generateCombinedCoverageReport(
288
+ allCoverageMaps,
289
+ cwd,
290
+ config.coverage,
291
+ )
292
+ }
293
+ latestExitCode = counts.failed > 0 || !thresholdsPassed ? 1 : 0
294
+ } catch (error) {
295
+ console.error('Error running tests:', error)
296
+ latestExitCode = 1
297
+ } finally {
298
+ await globalTeardown?.()
299
+ running = false
300
+ if (queued) {
301
+ queued = false
302
+ queueRerun('queued change')
303
+ } else if (!config.watch) {
304
+ cleanupAndExit(latestExitCode)
121
305
  }
122
306
  }
307
+ }
123
308
 
124
- reporter.onSummary(counts, performance.now() - startTime)
309
+ process.on('SIGINT', handleInterrupt)
310
+ process.on('SIGTERM', handleInterrupt)
125
311
 
126
- latestExitCode = counts.failed > 0 ? 1 : 0
127
- } catch (error) {
128
- console.error('Error running tests:', error)
129
- latestExitCode = 1
130
- } finally {
131
- await globalTeardown?.()
132
- running = false
133
- if (queued) {
134
- queued = false
135
- queueRerun('queued change')
136
- } else if (!config.watch) {
137
- cleanupAndExit(latestExitCode)
312
+ try {
313
+ await executeRun()
314
+
315
+ if (config.watch && !hasExited) {
316
+ console.log('Watching for changes. Press Ctrl+C to stop.')
138
317
  }
318
+ } catch {
319
+ cleanupAndExit(1)
139
320
  }
321
+
322
+ return await runPromise
140
323
  }
141
324
 
142
- async function discoverTests(config: ResolvedRemixTestConfig): Promise<{
143
- files: string[]
144
- serverFiles: string[]
145
- e2eFiles: string[]
146
- }> {
147
- async function findFiles(pattern: string) {
148
- let files: string[] = []
149
- let exclude = ['node_modules/**', '.git/**']
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
+ }
150
332
 
151
- for await (let file of fsp.glob(pattern, { cwd: process.cwd(), exclude })) {
152
- files.push(path.resolve(process.cwd(), file))
153
- }
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
+ }
154
344
 
155
- return files
345
+ function isMissingPlaywrightImport(error: unknown): boolean {
346
+ if (!isRecord(error) || typeof error.message !== 'string') {
347
+ return false
156
348
  }
157
349
 
158
- let files = await findFiles(config.glob.test)
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
+ }
159
356
 
160
- if (files.length === 0) {
161
- console.log(`No test files found matching pattern: ${config.glob.test}`)
162
- process.exit(1)
357
+ function isRecord(value: unknown): value is Record<string, unknown> {
358
+ return typeof value === 'object' && value !== null
359
+ }
360
+
361
+ async function resolveCwd(cwd: string): Promise<string> {
362
+ try {
363
+ return await fsp.realpath(cwd)
364
+ } catch {
365
+ return path.resolve(cwd)
163
366
  }
367
+ }
164
368
 
165
- let e2eSet = new Set(await findFiles(config.glob.e2e))
369
+ async function discoverTests(
370
+ config: ResolvedRemixTestConfig,
371
+ cwd: string,
372
+ ): Promise<DiscoveredTests | null> {
373
+ let files = await findFiles(config.glob.test, config.glob.exclude, cwd)
166
374
 
167
- let types = new Set(config.type.split(','))
168
- let e2eFiles = types.has('e2e') ? files.filter((f) => e2eSet.has(f)) : []
169
- let serverFiles = types.has('server') ? files.filter((f) => !e2eSet.has(f)) : []
375
+ if (files.length === 0) {
376
+ console.log(`No test files found matching pattern: ${config.glob.test.join(', ')}`)
377
+ return null
378
+ }
170
379
 
171
- let totalFiles = serverFiles.length + e2eFiles.length
380
+ let browserSet = new Set(await findFiles(config.glob.browser, config.glob.exclude, cwd))
381
+ let e2eSet = new Set(await findFiles(config.glob.e2e, config.glob.exclude, cwd))
382
+ let types = new Set(config.type)
383
+ let browserFiles = types.has('browser') ? files.filter((f) => browserSet.has(f)) : []
384
+ let e2eFiles = types.has('e2e') ? files.filter((file) => e2eSet.has(file)) : []
385
+ let serverFiles = types.has('server')
386
+ ? files.filter((file) => !browserSet.has(file) && !e2eSet.has(file))
387
+ : []
388
+ let totalFiles = browserFiles.length + serverFiles.length + e2eFiles.length
172
389
 
173
390
  if (totalFiles === 0) {
174
- console.log(`No test files remain after filtering for type ${config.type}`)
175
- process.exit(1)
391
+ console.log(`No test files remain after filtering for type ${config.type.join(', ')}`)
392
+ return null
176
393
  }
177
394
 
178
395
  console.log(
179
- `Found ${totalFiles} test file(s) (${serverFiles.length} server, ${e2eFiles.length} e2e)`,
396
+ `Found ${totalFiles} test file(s) (${serverFiles.length} server, ${browserFiles.length} browser, ${e2eFiles.length} e2e)`,
180
397
  )
181
398
 
182
399
  return {
183
400
  files,
184
401
  serverFiles,
402
+ browserFiles,
185
403
  e2eFiles,
186
404
  }
187
405
  }
188
406
 
189
- function queueRerun(reason: string) {
190
- if (!config.watch || hasExited) return
191
-
192
- clearTimeout(rerunTimer)
407
+ async function findFiles(
408
+ patterns: string[],
409
+ excludePatterns: string[],
410
+ cwd: string,
411
+ ): Promise<string[]> {
412
+ let files = new Set<string>()
413
+
414
+ if (IS_BUN) {
415
+ // Bun's `fs.promises.glob` follows symlinks and doesn't prune traversal
416
+ // via `exclude`, so it enters pnpm symlink cycles in `node_modules`.
417
+ // Use Bun's native Glob, which defaults to `followSymlinks: false`.
418
+ // @ts-expect-error — bun module is only resolvable under the Bun runtime
419
+ let { Glob } = await import('bun')
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
+ }
428
+ }
429
+ }
430
+ return [...files]
431
+ }
193
432
 
194
- rerunTimer = setTimeout(() => {
195
- rerunTimer = undefined
196
- if (running) {
197
- queued = true
198
- } else {
199
- console.log(`\n↻ Change detected (${reason}), re-running tests...\n`)
200
- void executeRun()
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)))
201
436
  }
202
- }, 100)
437
+ }
438
+ return [...files]
203
439
  }
204
440
 
205
- function cleanupAndExit(code: number) {
206
- if (hasExited) return
207
- hasExited = true
208
- watcher?.close()
209
- process.exit(code)
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, '/')
210
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,
@@ -13,3 +13,4 @@ export {
13
13
  } from './lib/framework.ts'
14
14
  export { mock } from './lib/mock.ts'
15
15
  export type { TestContext } from './lib/context.ts'
16
+ export type { FakeTimers } from './lib/fake-timers.ts'
@@ -0,0 +1,3 @@
1
+ import { createStyles } from '@remix-run/terminal'
2
+
3
+ export const colors = createStyles()