@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
@@ -6,10 +6,17 @@ import type { Counts, TestResult, TestResults } from './results.ts'
6
6
 
7
7
  export class FilesReporter implements Reporter {
8
8
  #failures: { suiteName: string; name: string; error: TestResult['error'] }[] = []
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
  let filePath = results.tests[0]?.filePath
14
21
  let fileName = filePath ? path.relative(process.cwd(), filePath) : '(unknown)'
15
22
  let envLabel = env ? ` ${colors.dim(`[${env}]`)}` : ''
@@ -66,6 +73,8 @@ export class FilesReporter implements Reporter {
66
73
  let { passed, failed, skipped, todo } = counts
67
74
  let info = colors.cyan('ℹ')
68
75
  console.log()
76
+ console.log(`${info} files ${this.#files.size}`)
77
+ console.log(`${info} suites ${this.#suites.size}`)
69
78
  console.log(`${info} tests ${passed + failed + skipped + todo}`)
70
79
  console.log(`${info} pass ${passed}`)
71
80
  console.log(`${info} fail ${failed}`)
@@ -1,4 +1,4 @@
1
- import type { V8CoverageEntry } from '../coverage'
1
+ import type { V8CoverageEntry } from '../coverage.ts'
2
2
 
3
3
  export interface TestResult {
4
4
  name: string
@@ -5,12 +5,19 @@ import type { Counts, TestResult, TestResults } from './results.ts'
5
5
 
6
6
  export class SpecReporter implements Reporter {
7
7
  #failures: { suiteName: string; name: string; error: TestResult['error'] }[] = []
8
+ #files = new Set<string>()
9
+ #suites = new Set<string>()
8
10
 
9
11
  onSectionStart(label: string) {
10
12
  console.log(label)
11
13
  }
12
14
 
13
15
  onResult(results: TestResults, env?: string) {
16
+ for (let test of results.tests) {
17
+ if (test.filePath) this.#files.add(test.filePath)
18
+ if (test.suiteName) this.#suites.add(test.suiteName)
19
+ }
20
+
14
21
  let suiteMap = new Map<string, TestResult[]>()
15
22
  for (let test of results.tests) {
16
23
  let suite = test.suiteName || 'Global'
@@ -163,6 +170,8 @@ export class SpecReporter implements Reporter {
163
170
  let { passed, failed, skipped, todo } = counts
164
171
  let info = colors.cyan('ℹ')
165
172
  console.log()
173
+ console.log(`${info} files ${this.#files.size}`)
174
+ console.log(`${info} suites ${this.#suites.size}`)
166
175
  console.log(`${info} tests ${passed + failed + skipped + todo}`)
167
176
  console.log(`${info} pass ${passed}`)
168
177
  console.log(`${info} fail ${failed}`)
@@ -5,6 +5,8 @@ import type { Counts, TestResults } from './results.ts'
5
5
  export class TapReporter implements Reporter {
6
6
  #counter = 0
7
7
  #total = 0
8
+ #files = new Set<string>()
9
+ #suites = new Set<string>()
8
10
 
9
11
  onSectionStart(_label: string) {}
10
12
 
@@ -15,6 +17,11 @@ export class TapReporter implements Reporter {
15
17
 
16
18
  let envComment = env ? ` # ${env}` : ''
17
19
 
20
+ for (let test of results.tests) {
21
+ if (test.filePath) this.#files.add(test.filePath)
22
+ if (test.suiteName) this.#suites.add(test.suiteName)
23
+ }
24
+
18
25
  for (let test of results.tests) {
19
26
  this.#counter++
20
27
  this.#total++
@@ -48,6 +55,8 @@ export class TapReporter implements Reporter {
48
55
  onSummary(counts: Counts, durationMs: number) {
49
56
  let { passed, failed, skipped, todo } = counts
50
57
  console.log(`1..${this.#total}`)
58
+ console.log(`# files ${this.#files.size}`)
59
+ console.log(`# suites ${this.#suites.size}`)
51
60
  console.log(`# tests ${passed + failed + skipped + todo}`)
52
61
  console.log(`# pass ${passed}`)
53
62
  console.log(`# fail ${failed}`)
@@ -16,6 +16,8 @@ import {
16
16
  import type { Reporter } from './reporters/index.ts'
17
17
  import type { TestResults } from './reporters/results.ts'
18
18
 
19
+ const BROWSER_TEST_FILE_TIMEOUT_MS = 90_000
20
+
19
21
  // The harness reports each test result with `filePath` set to the
20
22
  // `/scripts/<rel>` URL the iframe loaded. Reporters expect a real filesystem
21
23
  // path so they can compute `path.relative(cwd, ...)` cleanly; otherwise they
@@ -61,6 +63,10 @@ export async function runBrowserTests(options: TestRunOptions): Promise<{
61
63
  getPlaywrightLaunchOptions(options.playwrightUseOpts),
62
64
  )
63
65
  page = await browser.newPage(getPlaywrightPageOptions(options.playwrightUseOpts))
66
+ // Cap individual browser operations, then separately watch for per-file
67
+ // progress so large suites can run longer than this without hiding hangs.
68
+ page.setDefaultTimeout(BROWSER_TEST_FILE_TIMEOUT_MS)
69
+ page.setDefaultNavigationTimeout(BROWSER_TEST_FILE_TIMEOUT_MS)
64
70
 
65
71
  if (options.console) {
66
72
  page.on('console', (msg) => console.log(`${colors.dim('[browser console]')} ${msg.text()}`))
@@ -78,6 +84,32 @@ export async function runBrowserTests(options: TestRunOptions): Promise<{
78
84
  let totalSkipped = 0
79
85
  let totalTodo = 0
80
86
  let rootDir = getBrowserTestRootDir()
87
+ let completedFiles = 0
88
+ let totalFiles = options.testFiles?.length ?? 0
89
+ let progressTimeoutId: ReturnType<typeof setTimeout> | undefined
90
+ let rejectProgressTimeout: (error: Error) => void = () => {}
91
+ let progressTimeoutPromise = new Promise<never>((_, reject) => {
92
+ rejectProgressTimeout = reject
93
+ })
94
+
95
+ function clearProgressTimeout() {
96
+ if (progressTimeoutId !== undefined) {
97
+ clearTimeout(progressTimeoutId)
98
+ progressTimeoutId = undefined
99
+ }
100
+ }
101
+
102
+ function resetProgressTimeout() {
103
+ clearProgressTimeout()
104
+ progressTimeoutId = setTimeout(() => {
105
+ let progress = totalFiles > 0 ? ` (${completedFiles}/${totalFiles} files completed)` : ''
106
+ rejectProgressTimeout(
107
+ new Error(
108
+ `Timed out waiting ${BROWSER_TEST_FILE_TIMEOUT_MS}ms for browser test progress${progress}`,
109
+ ),
110
+ )
111
+ }, BROWSER_TEST_FILE_TIMEOUT_MS)
112
+ }
81
113
 
82
114
  await page.route('**/file-results', async (route) => {
83
115
  let results = route.request().postDataJSON() as TestResults
@@ -89,6 +121,8 @@ export async function runBrowserTests(options: TestRunOptions): Promise<{
89
121
  totalFailed += results.failed
90
122
  totalSkipped += results.skipped
91
123
  totalTodo += results.todo
124
+ completedFiles++
125
+ resetProgressTimeout()
92
126
  await route.fulfill({ status: 200 })
93
127
  })
94
128
 
@@ -111,9 +145,19 @@ export async function runBrowserTests(options: TestRunOptions): Promise<{
111
145
 
112
146
  // Prevent unhandled rejection if we fail before setting up the listener
113
147
  errorPromise.catch(() => {})
148
+ progressTimeoutPromise.catch(() => {})
114
149
 
115
- await page.goto(options.baseUrl)
116
- await Promise.race([page.waitForFunction('window.__testsDone'), errorPromise])
150
+ resetProgressTimeout()
151
+ try {
152
+ await page.goto(options.baseUrl)
153
+ await Promise.race([
154
+ page.waitForFunction('window.__testsDone', undefined, { timeout: 0 }),
155
+ errorPromise,
156
+ progressTimeoutPromise,
157
+ ])
158
+ } finally {
159
+ clearProgressTimeout()
160
+ }
117
161
 
118
162
  if (coverageEnabled) {
119
163
  let entries = (await page.coverage.stopJSCoverage()) as unknown as V8CoverageEntry[]
package/src/lib/runner.ts CHANGED
@@ -1,8 +1,9 @@
1
+ import { fork, type ChildProcess } from 'node:child_process'
1
2
  import * as fsp from 'node:fs/promises'
2
3
  import * as path from 'node:path'
3
- import { pathToFileURL } from 'node:url'
4
+ import { fileURLToPath, pathToFileURL } from 'node:url'
4
5
  import { Worker } from 'node:worker_threads'
5
- import { IS_RUNNING_FROM_SRC } from './config.ts'
6
+ import { IS_RUNNING_FROM_SRC, type RemixTestPool } from './config.ts'
6
7
  import {
7
8
  collectCoverageMapFromPlaywright,
8
9
  collectServerCoverageMap,
@@ -19,6 +20,22 @@ import type { Counts, TestResults } from './reporters/results.ts'
19
20
  const ext = IS_RUNNING_FROM_SRC ? '.ts' : '.js'
20
21
  const workerUrl = new URL(`./worker${ext}`, import.meta.url)
21
22
  const workerE2EUrl = new URL(`./worker-e2e${ext}`, import.meta.url)
23
+ const workerProcessUrl = new URL(`./worker-process${ext}`, import.meta.url)
24
+ const DEFAULT_WORKER_SHUTDOWN_TIMEOUT_MS = 10_000
25
+
26
+ interface WorkerRun {
27
+ finished: Promise<void>
28
+ exited: Promise<number | null>
29
+ terminate(): Promise<boolean>
30
+ }
31
+
32
+ interface RunFileOptions {
33
+ cwd?: string
34
+ coverage?: CoverageConfig
35
+ open?: boolean
36
+ playwrightUseOpts?: PlaywrightUseOpts
37
+ pool?: RemixTestPool
38
+ }
22
39
 
23
40
  export async function runServerTests(
24
41
  files: string[],
@@ -31,12 +48,15 @@ export async function runServerTests(
31
48
  playwrightUseOpts?: PlaywrightUseOpts
32
49
  projectName?: string
33
50
  coverage?: CoverageConfig
51
+ workerShutdownTimeoutMs?: number
52
+ pool?: RemixTestPool
34
53
  } = {},
35
54
  ): Promise<Counts & { coverageMap: CoverageMap | null }> {
36
55
  let counts: Counts = { passed: 0, failed: 0, skipped: 0, todo: 0 }
37
56
  let coverageMap: CoverageMap | null = null
38
57
  let cwd = options.cwd ?? process.cwd()
39
58
  let envLabel = options.projectName ? `${type}:${options.projectName}` : type
59
+ let pool = options.pool ?? 'forks'
40
60
 
41
61
  function accumulate(results: TestResults, file: string) {
42
62
  reporter.onResult(
@@ -56,7 +76,7 @@ export async function runServerTests(
56
76
  files,
57
77
  concurrency,
58
78
  (file) =>
59
- runFileInWorker(
79
+ runFileInPool(
60
80
  file,
61
81
  type,
62
82
  (results) => {
@@ -67,10 +87,13 @@ export async function runServerTests(
67
87
  },
68
88
  {
69
89
  ...options,
90
+ pool,
70
91
  playwrightUseOpts: options.playwrightUseOpts,
71
92
  },
72
93
  ),
73
94
  () => counts.failed++,
95
+ !options.open,
96
+ options.workerShutdownTimeoutMs ?? DEFAULT_WORKER_SHUTDOWN_TIMEOUT_MS,
74
97
  )
75
98
 
76
99
  if (options.coverage && allBrowserCoverageEntries.length > 0) {
@@ -92,8 +115,14 @@ export async function runServerTests(
92
115
  await runInConcurrentWorkers(
93
116
  files,
94
117
  concurrency,
95
- (file) => runFileInWorker(file, type, (results) => accumulate(results, file), options),
118
+ (file) =>
119
+ runFileInPool(file, type, (results) => accumulate(results, file), {
120
+ ...options,
121
+ pool,
122
+ }),
96
123
  () => counts.failed++,
124
+ true,
125
+ options.workerShutdownTimeoutMs ?? DEFAULT_WORKER_SHUTDOWN_TIMEOUT_MS,
97
126
  )
98
127
 
99
128
  if (coverageDataDir) {
@@ -109,8 +138,10 @@ export async function runServerTests(
109
138
  async function runInConcurrentWorkers(
110
139
  files: string[],
111
140
  concurrency: number,
112
- runFile: (file: string) => Promise<void>,
141
+ runFile: (file: string) => WorkerRun,
113
142
  onError: () => void,
143
+ terminateWhenFinished: boolean,
144
+ workerShutdownTimeoutMs: number,
114
145
  ): Promise<void> {
115
146
  let index = 0
116
147
  let active = 0
@@ -122,22 +153,42 @@ async function runInConcurrentWorkers(
122
153
  index++
123
154
  active++
124
155
 
125
- runFile(file).then(
126
- () => {
127
- active--
128
- if (index < files.length) {
129
- dispatch()
130
- } else if (active === 0) {
131
- resolve()
156
+ let run = runFile(file)
157
+
158
+ function complete() {
159
+ active--
160
+ if (index < files.length) {
161
+ dispatch()
162
+ } else if (active === 0) {
163
+ resolve()
164
+ }
165
+ }
166
+
167
+ run.finished.then(
168
+ async () => {
169
+ try {
170
+ if (terminateWhenFinished) {
171
+ let exited = await waitForWorkerExit(run.exited, workerShutdownTimeoutMs)
172
+ if (!exited) {
173
+ let terminated = await run.terminate()
174
+ if (!terminated) {
175
+ onError()
176
+ }
177
+ }
178
+ }
179
+ } finally {
180
+ complete()
132
181
  }
133
182
  },
134
- (err) => {
135
- console.error(`Error running ${file}:`, err.message)
136
- console.error(err)
137
- onError()
138
- active--
139
- if (active === 0 && index >= files.length) resolve()
140
- else dispatch()
183
+ async (err) => {
184
+ try {
185
+ console.error(`Error running ${file}:`, err instanceof Error ? err.message : err)
186
+ console.error(err)
187
+ onError()
188
+ await run.terminate()
189
+ } finally {
190
+ complete()
191
+ }
141
192
  },
142
193
  )
143
194
  }
@@ -149,41 +200,193 @@ async function runInConcurrentWorkers(
149
200
  })
150
201
  }
151
202
 
152
- function runFileInWorker(
203
+ function waitForWorkerExit(exited: Promise<number | null>, timeoutMs: number): Promise<boolean> {
204
+ return new Promise((resolve) => {
205
+ let timeout = setTimeout(() => resolve(false), timeoutMs)
206
+ exited.then(() => {
207
+ clearTimeout(timeout)
208
+ resolve(true)
209
+ })
210
+ })
211
+ }
212
+
213
+ function runFileInPool(
153
214
  file: string,
154
215
  type: 'server' | 'e2e',
155
216
  onResults: (results: TestResults) => void,
156
- options: {
157
- cwd?: string
158
- coverage?: CoverageConfig
159
- open?: boolean
160
- playwrightUseOpts?: PlaywrightUseOpts
161
- } = {},
162
- ): Promise<void> {
163
- return new Promise((resolve, reject) => {
164
- let worker =
165
- type === 'e2e'
166
- ? new Worker(workerE2EUrl, {
167
- workerData: {
168
- file: pathToFileURL(file).href,
169
- type,
170
- coverage: options.coverage,
171
- open: options.open,
172
- playwrightUseOpts: options.playwrightUseOpts,
173
- },
174
- })
175
- : new Worker(workerUrl, {
176
- workerData: {
177
- file: pathToFileURL(file).href,
178
- type,
179
- coverage: options.coverage,
180
- },
181
- })
182
- worker.once('message', (msg: TestResults) => onResults(msg))
217
+ options: RunFileOptions,
218
+ ): WorkerRun {
219
+ return options.pool === 'threads'
220
+ ? runFileInWorker(file, type, onResults, options)
221
+ : runFileInProcess(file, type, onResults, options)
222
+ }
223
+
224
+ export function runFileInWorker(
225
+ file: string,
226
+ type: 'server' | 'e2e',
227
+ onResults: (results: TestResults) => void,
228
+ options: RunFileOptions = {},
229
+ ): WorkerRun {
230
+ let receivedResults = false
231
+ let worker =
232
+ type === 'e2e'
233
+ ? new Worker(workerE2EUrl, {
234
+ workerData: {
235
+ file: pathToFileURL(file).href,
236
+ type,
237
+ coverage: options.coverage,
238
+ open: options.open,
239
+ playwrightUseOpts: options.playwrightUseOpts,
240
+ },
241
+ })
242
+ : new Worker(workerUrl, {
243
+ workerData: {
244
+ file: pathToFileURL(file).href,
245
+ type,
246
+ coverage: options.coverage,
247
+ },
248
+ })
249
+
250
+ let exited = new Promise<number>((resolve) => {
251
+ worker.once('exit', (code) => resolve(code))
252
+ })
253
+
254
+ let finished = new Promise<void>((resolve, reject) => {
255
+ worker.once('message', (msg: TestResults) => {
256
+ receivedResults = true
257
+ try {
258
+ onResults(msg)
259
+ } catch (error) {
260
+ reject(error)
261
+ return
262
+ }
263
+ if (!options.open) {
264
+ resolve()
265
+ }
266
+ })
183
267
  worker.once('error', reject)
184
- worker.once('exit', (code) => {
185
- if (code !== 0) reject(new Error(`Worker exited with code ${code}`))
186
- else resolve()
268
+ exited.then((code) => {
269
+ if (receivedResults || code === 0) {
270
+ resolve()
271
+ } else {
272
+ reject(new Error(`Worker exited with code ${code}`))
273
+ }
274
+ })
275
+ })
276
+
277
+ return {
278
+ finished,
279
+ exited,
280
+ async terminate() {
281
+ try {
282
+ await worker.terminate()
283
+ return true
284
+ } catch (err) {
285
+ console.error(
286
+ `Error terminating worker for ${file}:`,
287
+ err instanceof Error ? err.message : err,
288
+ )
289
+ console.error(err)
290
+ return false
291
+ }
292
+ },
293
+ }
294
+ }
295
+
296
+ function runFileInProcess(
297
+ file: string,
298
+ type: 'server' | 'e2e',
299
+ onResults: (results: TestResults) => void,
300
+ options: RunFileOptions = {},
301
+ ): WorkerRun {
302
+ let receivedResults = false
303
+ let child = fork(fileURLToPath(workerProcessUrl), [], {
304
+ serialization: 'advanced',
305
+ stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
306
+ })
307
+
308
+ let exited = new Promise<number | null>((resolve) => {
309
+ child.once('exit', (code) => resolve(code))
310
+ })
311
+
312
+ let finished = new Promise<void>((resolve, reject) => {
313
+ child.once('message', (msg: unknown) => {
314
+ if (!isTestResults(msg)) {
315
+ reject(new Error('Test worker process sent invalid results'))
316
+ return
317
+ }
318
+
319
+ receivedResults = true
320
+ try {
321
+ onResults(msg)
322
+ } catch (error) {
323
+ reject(error)
324
+ return
325
+ }
326
+ if (!options.open) {
327
+ resolve()
328
+ }
187
329
  })
330
+ child.once('error', reject)
331
+ exited.then((code) => {
332
+ if (receivedResults || code === 0) {
333
+ resolve()
334
+ } else {
335
+ reject(new Error(`Worker process exited with code ${code}`))
336
+ }
337
+ })
338
+ child.send(
339
+ {
340
+ file: pathToFileURL(file).href,
341
+ type,
342
+ coverage: options.coverage,
343
+ open: options.open,
344
+ playwrightUseOpts: options.playwrightUseOpts,
345
+ },
346
+ (error) => {
347
+ if (error) {
348
+ reject(error)
349
+ }
350
+ },
351
+ )
188
352
  })
353
+
354
+ return {
355
+ finished,
356
+ exited,
357
+ terminate: () => terminateChildProcess(child, exited),
358
+ }
359
+ }
360
+
361
+ async function terminateChildProcess(
362
+ child: ChildProcess,
363
+ exited: Promise<number | null>,
364
+ ): Promise<boolean> {
365
+ if (child.exitCode !== null || child.signalCode !== null) {
366
+ return true
367
+ }
368
+
369
+ if (!child.kill()) {
370
+ return false
371
+ }
372
+
373
+ return await waitForWorkerExit(exited, 5_000)
374
+ }
375
+
376
+ function isTestResults(value: unknown): value is TestResults {
377
+ if (!isRecord(value)) {
378
+ return false
379
+ }
380
+
381
+ return (
382
+ typeof value.passed === 'number' &&
383
+ typeof value.failed === 'number' &&
384
+ typeof value.skipped === 'number' &&
385
+ typeof value.todo === 'number' &&
386
+ Array.isArray(value.tests)
387
+ )
388
+ }
389
+
390
+ function isRecord(value: unknown): value is Record<string, unknown> {
391
+ return typeof value === 'object' && value !== null
189
392
  }
@@ -1,4 +1,4 @@
1
- import { transform, type TsconfigRaw } from 'esbuild'
1
+ import { transform } from 'esbuild'
2
2
  import { getTsconfig, type TsConfigResult } from 'get-tsconfig'
3
3
  import * as path from 'node:path'
4
4
 
@@ -0,0 +1,98 @@
1
+ import { runTests } from './executor.ts'
2
+ import { importModule } from './import-module.ts'
3
+ import {
4
+ getBrowserLauncher,
5
+ getPlaywrightLaunchOptions,
6
+ getPlaywrightPageOptions,
7
+ type PlaywrightUseOpts,
8
+ } from './playwright.ts'
9
+ import type { CoverageConfig } from './coverage.ts'
10
+ import type { TestResults } from './reporters/results.ts'
11
+ import { createFailedResults } from './worker-results.ts'
12
+ import { isRecord, parseCoverageConfig } from './worker-server.ts'
13
+
14
+ export interface E2ETestWorkerData {
15
+ file: string
16
+ coverage?: CoverageConfig
17
+ open?: boolean
18
+ playwrightUseOpts?: PlaywrightUseOpts
19
+ }
20
+
21
+ export async function runE2ETestFile(
22
+ value: unknown,
23
+ onOpenResults?: (results: TestResults) => void | Promise<void>,
24
+ ): Promise<TestResults | undefined> {
25
+ try {
26
+ let workerData = parseE2ETestWorkerData(value)
27
+
28
+ await importModule(workerData.file, import.meta)
29
+
30
+ let launcher = await getBrowserLauncher(workerData.playwrightUseOpts)
31
+ let opts = getPlaywrightLaunchOptions(workerData.playwrightUseOpts)
32
+ let browser = await launcher.launch(opts)
33
+ let browserClosed = false
34
+
35
+ try {
36
+ let results = await runTests({
37
+ browser,
38
+ open: workerData.open ?? false,
39
+ playwrightPageOptions: getPlaywrightPageOptions(workerData.playwrightUseOpts),
40
+ coverage: !!workerData.coverage,
41
+ })
42
+
43
+ if (workerData.open) {
44
+ await onOpenResults?.(results)
45
+ console.log('\nBrowser is open. Press Ctrl+C to close.')
46
+ await new Promise<void>((resolve) => browser.on('disconnected', () => resolve()))
47
+ return undefined
48
+ }
49
+
50
+ await browser.close()
51
+ browserClosed = true
52
+ return results
53
+ } finally {
54
+ if (!browserClosed) {
55
+ await browser.close()
56
+ }
57
+ }
58
+ } catch (error) {
59
+ return createFailedResults(error)
60
+ }
61
+ }
62
+
63
+ function parseE2ETestWorkerData(value: unknown): E2ETestWorkerData {
64
+ if (!isRecord(value) || typeof value.file !== 'string') {
65
+ throw new Error('Invalid E2E test worker data')
66
+ }
67
+
68
+ return {
69
+ file: value.file,
70
+ coverage: parseCoverageConfig(value.coverage),
71
+ open: parseBoolean(value.open, 'open'),
72
+ playwrightUseOpts: parsePlaywrightUseOpts(value.playwrightUseOpts),
73
+ }
74
+ }
75
+
76
+ function parseBoolean(value: unknown, name: string): boolean | undefined {
77
+ if (value === undefined) {
78
+ return undefined
79
+ }
80
+
81
+ if (typeof value !== 'boolean') {
82
+ throw new Error(`Invalid E2E test worker ${name}`)
83
+ }
84
+
85
+ return value
86
+ }
87
+
88
+ function parsePlaywrightUseOpts(value: unknown): PlaywrightUseOpts | undefined {
89
+ if (value === undefined) {
90
+ return undefined
91
+ }
92
+
93
+ if (!isRecord(value)) {
94
+ throw new Error('Invalid E2E test worker playwright options')
95
+ }
96
+
97
+ return value as PlaywrightUseOpts
98
+ }