@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
@@ -1,15 +1,23 @@
1
- import { colors, normalizeLine, type Counts } from '../utils.ts'
2
- import type { TestResult, TestResults } from '../executor.ts'
1
+ import { colors } from '../colors.ts'
2
+ import { normalizeLine } from '../normalize.ts'
3
3
  import type { Reporter } from './index.ts'
4
+ import type { Counts, TestResult, TestResults } from './results.ts'
4
5
 
5
6
  export class SpecReporter implements Reporter {
6
7
  #failures: { suiteName: string; name: string; error: TestResult['error'] }[] = []
8
+ #files = new Set<string>()
9
+ #suites = new Set<string>()
7
10
 
8
11
  onSectionStart(label: string) {
9
12
  console.log(label)
10
13
  }
11
14
 
12
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
+
13
21
  let suiteMap = new Map<string, TestResult[]>()
14
22
  for (let test of results.tests) {
15
23
  let suite = test.suiteName || 'Global'
@@ -162,6 +170,8 @@ export class SpecReporter implements Reporter {
162
170
  let { passed, failed, skipped, todo } = counts
163
171
  let info = colors.cyan('ℹ')
164
172
  console.log()
173
+ console.log(`${info} files ${this.#files.size}`)
174
+ console.log(`${info} suites ${this.#suites.size}`)
165
175
  console.log(`${info} tests ${passed + failed + skipped + todo}`)
166
176
  console.log(`${info} pass ${passed}`)
167
177
  console.log(`${info} fail ${failed}`)
@@ -1,10 +1,12 @@
1
- import { normalizeLine, type Counts } from '../utils.ts'
2
- import type { TestResults } from '../executor.ts'
1
+ import { normalizeLine } from '../normalize.ts'
3
2
  import type { Reporter } from './index.ts'
3
+ import type { Counts, TestResults } from './results.ts'
4
4
 
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}`)
@@ -0,0 +1,171 @@
1
+ import * as path from 'node:path'
2
+ import type { Browser, Page, Request } from 'playwright'
3
+ import { colors } from './colors.ts'
4
+ import { getBrowserTestRootDir } from './config.ts'
5
+ import {
6
+ collectCoverageMapFromPlaywright,
7
+ type CoverageMap,
8
+ type V8CoverageEntry,
9
+ } from './coverage.ts'
10
+ import {
11
+ getBrowserLauncher,
12
+ getPlaywrightLaunchOptions,
13
+ getPlaywrightPageOptions,
14
+ type PlaywrightUseOpts,
15
+ } from './playwright.ts'
16
+ import type { Reporter } from './reporters/index.ts'
17
+ import type { TestResults } from './reporters/results.ts'
18
+
19
+ // The harness reports each test result with `filePath` set to the
20
+ // `/scripts/<rel>` URL the iframe loaded. Reporters expect a real filesystem
21
+ // path so they can compute `path.relative(cwd, ...)` cleanly; otherwise they
22
+ // produce noisy `../../../scripts/...` strings.
23
+ function urlPathToFilePath(urlPath: string, rootDir: string): string {
24
+ if (!urlPath.startsWith('/scripts/')) return urlPath
25
+ return path.resolve(rootDir, urlPath.slice('/scripts/'.length))
26
+ }
27
+
28
+ export interface TestRunOptions {
29
+ baseUrl: string
30
+ console?: boolean
31
+ coverage?: boolean
32
+ open?: boolean
33
+ playwrightUseOpts?: PlaywrightUseOpts
34
+ projectName?: string
35
+ reporter: Reporter
36
+ // Test file paths so coverage collection can skip them when mapping V8
37
+ // entries back to filesystem files.
38
+ testFiles?: string[]
39
+ }
40
+
41
+ export async function runBrowserTests(options: TestRunOptions): Promise<{
42
+ results: TestResults
43
+ coverageMap: CoverageMap | null
44
+ close: () => Promise<void>
45
+ disconnected: Promise<void>
46
+ }> {
47
+ let envLabel = options.projectName ? `browser:${options.projectName}` : 'browser'
48
+ let browser: Browser | undefined
49
+ let page: Page | undefined
50
+ let close = async () => {
51
+ await page?.close()
52
+ await browser?.close()
53
+ browser = undefined
54
+ page = undefined
55
+ }
56
+ let results: TestResults
57
+ let coverageMap: CoverageMap | null = null
58
+
59
+ try {
60
+ browser = await getBrowserLauncher(options.playwrightUseOpts).launch(
61
+ getPlaywrightLaunchOptions(options.playwrightUseOpts),
62
+ )
63
+ page = await browser.newPage(getPlaywrightPageOptions(options.playwrightUseOpts))
64
+ // Cap how long we'll wait for a browser-test file to signal completion.
65
+ // Playwright's default is 30s; bumping to 60s buys headroom for slower
66
+ // suites without letting a hung test hide forever. Plumb this through
67
+ // config later if anyone needs to tune it.
68
+ page.setDefaultTimeout(90_000)
69
+ page.setDefaultNavigationTimeout(90_000)
70
+
71
+ if (options.console) {
72
+ page.on('console', (msg) => console.log(`${colors.dim('[browser console]')} ${msg.text()}`))
73
+ }
74
+
75
+ // Playwright's JS coverage is Chromium-only. Start before navigation so
76
+ // the harness scripts and test modules are instrumented from first parse.
77
+ let coverageEnabled = options.coverage && browser.browserType().name() === 'chromium'
78
+ if (coverageEnabled) {
79
+ await page.coverage.startJSCoverage({ resetOnNavigation: false })
80
+ }
81
+
82
+ let totalPassed = 0
83
+ let totalFailed = 0
84
+ let totalSkipped = 0
85
+ let totalTodo = 0
86
+ let rootDir = getBrowserTestRootDir()
87
+
88
+ await page.route('**/file-results', async (route) => {
89
+ let results = route.request().postDataJSON() as TestResults
90
+ for (let test of results.tests) {
91
+ if (test.filePath) test.filePath = urlPathToFilePath(test.filePath, rootDir)
92
+ }
93
+ options.reporter.onResult(results, envLabel)
94
+ totalPassed += results.passed
95
+ totalFailed += results.failed
96
+ totalSkipped += results.skipped
97
+ totalTodo += results.todo
98
+ await route.fulfill({ status: 200 })
99
+ })
100
+
101
+ // Fail the tests if any /scripts/ request fails (harness scripts, test
102
+ // modules, or their transitive imports — all served via the same prefix).
103
+ let errorPromise = new Promise((_, reject) => {
104
+ let isScriptRequest = (request: Request) =>
105
+ new URL(request.url()).pathname.startsWith('/scripts/')
106
+ page!.on('response', (response) => {
107
+ if (!response.ok() && isScriptRequest(response.request())) {
108
+ reject(new Error(`Failed to load script: ${response.request().url()}`))
109
+ }
110
+ })
111
+ page!.on('requestfailed', (request) => {
112
+ if (isScriptRequest(request)) {
113
+ reject(new Error(`Failed to load script: ${request.url()}`))
114
+ }
115
+ })
116
+ })
117
+
118
+ // Prevent unhandled rejection if we fail before setting up the listener
119
+ errorPromise.catch(() => {})
120
+
121
+ await page.goto(options.baseUrl)
122
+ await Promise.race([page.waitForFunction('window.__testsDone'), errorPromise])
123
+
124
+ if (coverageEnabled) {
125
+ let entries = (await page.coverage.stopJSCoverage()) as unknown as V8CoverageEntry[]
126
+ if (entries.length > 0) {
127
+ coverageMap = await collectCoverageMapFromPlaywright(
128
+ entries,
129
+ getBrowserTestRootDir(),
130
+ new Set(options.testFiles ?? []),
131
+ async (urlPath) =>
132
+ urlPath.startsWith('/scripts/') ? urlPath.slice('/scripts/'.length) : null,
133
+ )
134
+ }
135
+ }
136
+
137
+ results = {
138
+ passed: totalPassed,
139
+ failed: totalFailed,
140
+ skipped: totalSkipped,
141
+ todo: totalTodo,
142
+ tests: [],
143
+ }
144
+ } catch (error) {
145
+ console.error('Browser tests failed to run:', error)
146
+ results = {
147
+ passed: 0,
148
+ failed: 1,
149
+ skipped: 0,
150
+ todo: 0,
151
+ tests: [],
152
+ }
153
+ }
154
+
155
+ if (options.open) {
156
+ return {
157
+ results,
158
+ coverageMap,
159
+ close,
160
+ disconnected: new Promise((r) => browser!.on('disconnected', () => r())),
161
+ }
162
+ } else {
163
+ await close()
164
+ return {
165
+ results,
166
+ coverageMap,
167
+ close,
168
+ disconnected: Promise.resolve(),
169
+ }
170
+ }
171
+ }
package/src/lib/runner.ts CHANGED
@@ -1,14 +1,41 @@
1
+ import { fork, type ChildProcess } from 'node:child_process'
2
+ import * as fsp from 'node:fs/promises'
1
3
  import * as path from 'node:path'
2
- import { pathToFileURL } from 'node:url'
4
+ import { fileURLToPath, pathToFileURL } from 'node:url'
3
5
  import { Worker } from 'node:worker_threads'
4
- import type { TestResults } from './executor.ts'
6
+ import { IS_RUNNING_FROM_SRC, type RemixTestPool } from './config.ts'
7
+ import {
8
+ collectCoverageMapFromPlaywright,
9
+ collectServerCoverageMap,
10
+ type CoverageConfig,
11
+ type CoverageMap,
12
+ type V8CoverageEntry,
13
+ } from './coverage.ts'
5
14
  import { type PlaywrightUseOpts } from './playwright.ts'
6
15
  import type { Reporter } from './reporters/index.ts'
7
- import type { Counts } from './utils.ts'
16
+ import type { Counts, TestResults } from './reporters/results.ts'
8
17
 
9
- const ext = path.extname(import.meta.url)
18
+ // Ensure we load the right file whether we're running in the monorepo (TS) or
19
+ // from a published package (JS)
20
+ const ext = IS_RUNNING_FROM_SRC ? '.ts' : '.js'
10
21
  const workerUrl = new URL(`./worker${ext}`, import.meta.url)
11
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
+ }
12
39
 
13
40
  export async function runServerTests(
14
41
  files: string[],
@@ -16,13 +43,20 @@ export async function runServerTests(
16
43
  concurrency: number,
17
44
  type: 'server' | 'e2e',
18
45
  options: {
46
+ cwd?: string
19
47
  open?: boolean
20
48
  playwrightUseOpts?: PlaywrightUseOpts
21
49
  projectName?: string
50
+ coverage?: CoverageConfig
51
+ workerShutdownTimeoutMs?: number
52
+ pool?: RemixTestPool
22
53
  } = {},
23
- ): Promise<Counts> {
54
+ ): Promise<Counts & { coverageMap: CoverageMap | null }> {
24
55
  let counts: Counts = { passed: 0, failed: 0, skipped: 0, todo: 0 }
56
+ let coverageMap: CoverageMap | null = null
57
+ let cwd = options.cwd ?? process.cwd()
25
58
  let envLabel = options.projectName ? `${type}:${options.projectName}` : type
59
+ let pool = options.pool ?? 'forks'
26
60
 
27
61
  function accumulate(results: TestResults, file: string) {
28
62
  reporter.onResult(
@@ -36,33 +70,78 @@ export async function runServerTests(
36
70
  }
37
71
 
38
72
  if (type === 'e2e') {
73
+ let allBrowserCoverageEntries: Array<{ entries: V8CoverageEntry[]; baseUrl: string }> = []
74
+
39
75
  await runInConcurrentWorkers(
40
76
  files,
41
77
  concurrency,
42
78
  (file) =>
43
- runFileInWorker(file, type, (results) => accumulate(results, file), {
44
- ...options,
45
- playwrightUseOpts: options.playwrightUseOpts,
46
- }),
79
+ runFileInPool(
80
+ file,
81
+ type,
82
+ (results) => {
83
+ accumulate(results, file)
84
+ if (results.e2eBrowserCoverageEntries) {
85
+ allBrowserCoverageEntries.push(...results.e2eBrowserCoverageEntries)
86
+ }
87
+ },
88
+ {
89
+ ...options,
90
+ pool,
91
+ playwrightUseOpts: options.playwrightUseOpts,
92
+ },
93
+ ),
47
94
  () => counts.failed++,
95
+ !options.open,
96
+ options.workerShutdownTimeoutMs ?? DEFAULT_WORKER_SHUTDOWN_TIMEOUT_MS,
48
97
  )
98
+
99
+ if (options.coverage && allBrowserCoverageEntries.length > 0) {
100
+ coverageMap = await collectCoverageMapFromPlaywright(
101
+ allBrowserCoverageEntries.flatMap((e) => e.entries),
102
+ cwd,
103
+ new Set(files),
104
+ async (urlPath) => (urlPath.startsWith('/') ? urlPath.slice(1) : urlPath),
105
+ )
106
+ }
49
107
  } else {
108
+ let coverageDataDir: string | undefined
109
+ if (options.coverage) {
110
+ coverageDataDir = path.resolve(cwd, options.coverage.dir)
111
+ await fsp.mkdir(coverageDataDir, { recursive: true })
112
+ process.env.NODE_V8_COVERAGE = coverageDataDir
113
+ }
114
+
50
115
  await runInConcurrentWorkers(
51
116
  files,
52
117
  concurrency,
53
- (file) => runFileInWorker(file, type, (results) => accumulate(results, file)),
118
+ (file) =>
119
+ runFileInPool(file, type, (results) => accumulate(results, file), {
120
+ ...options,
121
+ pool,
122
+ }),
54
123
  () => counts.failed++,
124
+ true,
125
+ options.workerShutdownTimeoutMs ?? DEFAULT_WORKER_SHUTDOWN_TIMEOUT_MS,
55
126
  )
127
+
128
+ if (coverageDataDir) {
129
+ delete process.env.NODE_V8_COVERAGE
130
+ let serverMap = await collectServerCoverageMap(coverageDataDir, cwd, new Set(files))
131
+ coverageMap = serverMap
132
+ }
56
133
  }
57
134
 
58
- return { ...counts }
135
+ return { ...counts, coverageMap }
59
136
  }
60
137
 
61
138
  async function runInConcurrentWorkers(
62
139
  files: string[],
63
140
  concurrency: number,
64
- runFile: (file: string) => Promise<void>,
141
+ runFile: (file: string) => WorkerRun,
65
142
  onError: () => void,
143
+ terminateWhenFinished: boolean,
144
+ workerShutdownTimeoutMs: number,
66
145
  ): Promise<void> {
67
146
  let index = 0
68
147
  let active = 0
@@ -74,22 +153,42 @@ async function runInConcurrentWorkers(
74
153
  index++
75
154
  active++
76
155
 
77
- runFile(file).then(
78
- () => {
79
- active--
80
- if (index < files.length) {
81
- dispatch()
82
- } else if (active === 0) {
83
- 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()
84
181
  }
85
182
  },
86
- (err) => {
87
- console.error(`Error running ${file}:`, err.message)
88
- console.error(err)
89
- onError()
90
- active--
91
- if (active === 0 && index >= files.length) resolve()
92
- 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
+ }
93
192
  },
94
193
  )
95
194
  }
@@ -101,37 +200,193 @@ async function runInConcurrentWorkers(
101
200
  })
102
201
  }
103
202
 
104
- 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(
105
214
  file: string,
106
215
  type: 'server' | 'e2e',
107
216
  onResults: (results: TestResults) => void,
108
- options: {
109
- open?: boolean
110
- playwrightUseOpts?: PlaywrightUseOpts
111
- } = {},
112
- ): Promise<void> {
113
- return new Promise((resolve, reject) => {
114
- let worker =
115
- type === 'e2e'
116
- ? new Worker(workerE2EUrl, {
117
- workerData: {
118
- file: pathToFileURL(file).href,
119
- type,
120
- open: options.open,
121
- playwrightUseOpts: options.playwrightUseOpts,
122
- },
123
- })
124
- : new Worker(workerUrl, {
125
- workerData: {
126
- file: pathToFileURL(file).href,
127
- type,
128
- },
129
- })
130
- 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
+ })
131
267
  worker.once('error', reject)
132
- worker.once('exit', (code) => {
133
- if (code !== 0) reject(new Error(`Worker exited with code ${code}`))
134
- 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
+ }
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
+ }
135
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
+ )
136
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
137
392
  }
@@ -0,0 +1,2 @@
1
+ // https://bun.com/docs/guides/util/detect-bun
2
+ export const IS_BUN = typeof process.versions.bun === 'string'