@remix-run/test 0.0.0 → 0.2.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 (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +430 -2
  3. package/dist/app/client/entry.d.ts +2 -0
  4. package/dist/app/client/entry.d.ts.map +1 -0
  5. package/dist/app/client/entry.js +324 -0
  6. package/dist/app/client/iframe.d.ts +2 -0
  7. package/dist/app/client/iframe.d.ts.map +1 -0
  8. package/dist/app/client/iframe.js +22 -0
  9. package/dist/app/server.d.ts +6 -0
  10. package/dist/app/server.d.ts.map +1 -0
  11. package/dist/app/server.js +303 -0
  12. package/dist/cli-entry.d.ts +3 -0
  13. package/dist/cli-entry.d.ts.map +1 -0
  14. package/dist/cli-entry.js +14 -0
  15. package/dist/cli.d.ts +8 -0
  16. package/dist/cli.d.ts.map +1 -0
  17. package/dist/cli.js +305 -0
  18. package/dist/index.d.ts +6 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +2 -0
  21. package/dist/lib/colors.d.ts +2 -0
  22. package/dist/lib/colors.d.ts.map +1 -0
  23. package/dist/lib/colors.js +2 -0
  24. package/dist/lib/config.d.ts +91 -0
  25. package/dist/lib/config.d.ts.map +1 -0
  26. package/dist/lib/config.js +255 -0
  27. package/dist/lib/context.d.ts +93 -0
  28. package/dist/lib/context.d.ts.map +1 -0
  29. package/dist/lib/context.js +65 -0
  30. package/dist/lib/coverage-loader.d.ts +16 -0
  31. package/dist/lib/coverage-loader.d.ts.map +1 -0
  32. package/dist/lib/coverage-loader.js +20 -0
  33. package/dist/lib/coverage.d.ts +28 -0
  34. package/dist/lib/coverage.d.ts.map +1 -0
  35. package/dist/lib/coverage.js +212 -0
  36. package/dist/lib/executor.d.ts +4 -0
  37. package/dist/lib/executor.d.ts.map +1 -0
  38. package/dist/lib/executor.js +128 -0
  39. package/dist/lib/fake-timers.d.ts +6 -0
  40. package/dist/lib/fake-timers.d.ts.map +1 -0
  41. package/dist/lib/fake-timers.js +45 -0
  42. package/dist/lib/framework.d.ts +107 -0
  43. package/dist/lib/framework.d.ts.map +1 -0
  44. package/dist/lib/framework.js +198 -0
  45. package/dist/lib/import-module.d.ts +2 -0
  46. package/dist/lib/import-module.d.ts.map +1 -0
  47. package/dist/lib/import-module.js +29 -0
  48. package/dist/lib/mock.d.ts +52 -0
  49. package/dist/lib/mock.d.ts.map +1 -0
  50. package/dist/lib/mock.js +61 -0
  51. package/dist/lib/normalize.d.ts +2 -0
  52. package/dist/lib/normalize.d.ts.map +1 -0
  53. package/dist/lib/normalize.js +18 -0
  54. package/dist/lib/playwright.d.ts +15 -0
  55. package/dist/lib/playwright.d.ts.map +1 -0
  56. package/dist/lib/playwright.js +81 -0
  57. package/dist/lib/reporters/dot.d.ts +9 -0
  58. package/dist/lib/reporters/dot.d.ts.map +1 -0
  59. package/dist/lib/reporters/dot.js +56 -0
  60. package/dist/lib/reporters/files.d.ts +9 -0
  61. package/dist/lib/reporters/files.d.ts.map +1 -0
  62. package/dist/lib/reporters/files.js +71 -0
  63. package/dist/lib/reporters/index.d.ts +13 -0
  64. package/dist/lib/reporters/index.d.ts.map +1 -0
  65. package/dist/lib/reporters/index.js +18 -0
  66. package/dist/lib/reporters/results.d.ts +30 -0
  67. package/dist/lib/reporters/results.d.ts.map +1 -0
  68. package/dist/lib/reporters/results.js +1 -0
  69. package/dist/lib/reporters/spec.d.ts +9 -0
  70. package/dist/lib/reporters/spec.d.ts.map +1 -0
  71. package/dist/lib/reporters/spec.js +153 -0
  72. package/dist/lib/reporters/tap.d.ts +9 -0
  73. package/dist/lib/reporters/tap.d.ts.map +1 -0
  74. package/dist/lib/reporters/tap.js +54 -0
  75. package/dist/lib/runner-browser.d.ts +21 -0
  76. package/dist/lib/runner-browser.d.ts.map +1 -0
  77. package/dist/lib/runner-browser.js +117 -0
  78. package/dist/lib/runner.d.ts +14 -0
  79. package/dist/lib/runner.d.ts.map +1 -0
  80. package/dist/lib/runner.js +118 -0
  81. package/dist/lib/runtime.d.ts +2 -0
  82. package/dist/lib/runtime.d.ts.map +1 -0
  83. package/dist/lib/runtime.js +2 -0
  84. package/dist/lib/ts-transform.d.ts +4 -0
  85. package/dist/lib/ts-transform.d.ts.map +1 -0
  86. package/dist/lib/ts-transform.js +29 -0
  87. package/dist/lib/watcher.d.ts +5 -0
  88. package/dist/lib/watcher.d.ts.map +1 -0
  89. package/dist/lib/watcher.js +39 -0
  90. package/dist/lib/worker-e2e.d.ts +2 -0
  91. package/dist/lib/worker-e2e.d.ts.map +1 -0
  92. package/dist/lib/worker-e2e.js +49 -0
  93. package/dist/lib/worker.d.ts +2 -0
  94. package/dist/lib/worker.d.ts.map +1 -0
  95. package/dist/lib/worker.js +57 -0
  96. package/dist/test/coverage/fixture.d.ts +5 -0
  97. package/dist/test/coverage/fixture.d.ts.map +1 -0
  98. package/dist/test/coverage/fixture.js +32 -0
  99. package/dist/test/coverage/test-browser.d.ts +2 -0
  100. package/dist/test/coverage/test-browser.d.ts.map +1 -0
  101. package/dist/test/coverage/test-browser.js +24 -0
  102. package/dist/test/coverage/test-e2e.d.ts +2 -0
  103. package/dist/test/coverage/test-e2e.d.ts.map +1 -0
  104. package/dist/test/coverage/test-e2e.js +60 -0
  105. package/dist/test/coverage/test-unit.d.ts +2 -0
  106. package/dist/test/coverage/test-unit.d.ts.map +1 -0
  107. package/dist/test/coverage/test-unit.js +27 -0
  108. package/dist/test/framework.test.browser.d.ts +2 -0
  109. package/dist/test/framework.test.browser.d.ts.map +1 -0
  110. package/dist/test/framework.test.browser.js +107 -0
  111. package/dist/test/framework.test.e2e.d.ts +2 -0
  112. package/dist/test/framework.test.e2e.d.ts.map +1 -0
  113. package/dist/test/framework.test.e2e.js +34 -0
  114. package/package.json +79 -5
  115. package/src/app/client/entry.ts +353 -0
  116. package/src/app/client/iframe.ts +18 -0
  117. package/src/app/server.ts +336 -0
  118. package/src/cli-entry.ts +15 -0
  119. package/src/cli.ts +384 -0
  120. package/src/index.ts +16 -0
  121. package/src/lib/colors.ts +3 -0
  122. package/src/lib/config.ts +377 -0
  123. package/src/lib/context.ts +168 -0
  124. package/src/lib/coverage-loader.ts +31 -0
  125. package/src/lib/coverage.ts +320 -0
  126. package/src/lib/executor.ts +145 -0
  127. package/src/lib/fake-timers.ts +64 -0
  128. package/src/lib/framework.ts +251 -0
  129. package/src/lib/import-module.ts +29 -0
  130. package/src/lib/mock.ts +89 -0
  131. package/src/lib/normalize.ts +22 -0
  132. package/src/lib/playwright.ts +100 -0
  133. package/src/lib/reporters/dot.ts +58 -0
  134. package/src/lib/reporters/files.ts +77 -0
  135. package/src/lib/reporters/index.ts +27 -0
  136. package/src/lib/reporters/results.ts +29 -0
  137. package/src/lib/reporters/spec.ts +174 -0
  138. package/src/lib/reporters/tap.ts +58 -0
  139. package/src/lib/runner-browser.ts +165 -0
  140. package/src/lib/runner.ts +189 -0
  141. package/src/lib/runtime.ts +2 -0
  142. package/src/lib/ts-transform.ts +36 -0
  143. package/src/lib/watcher.ts +46 -0
  144. package/src/lib/worker-e2e.ts +54 -0
  145. package/src/lib/worker.ts +50 -0
  146. package/src/test/coverage/fixture.ts +34 -0
  147. package/src/test/coverage/test-browser.ts +29 -0
  148. package/src/test/coverage/test-e2e.ts +70 -0
  149. package/src/test/coverage/test-unit.ts +32 -0
  150. package/tsconfig.json +16 -0
@@ -0,0 +1,174 @@
1
+ import { colors } from '../colors.ts'
2
+ import { normalizeLine } from '../normalize.ts'
3
+ import type { Reporter } from './index.ts'
4
+ import type { Counts, TestResult, TestResults } from './results.ts'
5
+
6
+ export class SpecReporter implements Reporter {
7
+ #failures: { suiteName: string; name: string; error: TestResult['error'] }[] = []
8
+
9
+ onSectionStart(label: string) {
10
+ console.log(label)
11
+ }
12
+
13
+ onResult(results: TestResults, env?: string) {
14
+ let suiteMap = new Map<string, TestResult[]>()
15
+ for (let test of results.tests) {
16
+ let suite = test.suiteName || 'Global'
17
+ if (!suiteMap.has(suite)) suiteMap.set(suite, [])
18
+ suiteMap.get(suite)!.push(test)
19
+ }
20
+
21
+ let envLabel = env ? ` ${colors.dim(`[${env}]`)}` : ''
22
+ let lastParts: string[] = []
23
+
24
+ // Pre-compute aggregate test results for each path prefix so non-leaf
25
+ // suite headings can be colored the same way as leaf headings.
26
+ let prefixTests = new Map<string, TestResult[]>()
27
+ for (let [suiteName, tests] of suiteMap) {
28
+ let parts = suiteName.split(' > ')
29
+ for (let i = 0; i < parts.length; i++) {
30
+ let prefix = parts.slice(0, i + 1).join(' > ')
31
+ if (!prefixTests.has(prefix)) prefixTests.set(prefix, [])
32
+ prefixTests.get(prefix)!.push(...tests)
33
+ }
34
+ }
35
+
36
+ for (let [suiteName, suiteTests] of suiteMap) {
37
+ let parts = suiteName.split(' > ')
38
+
39
+ // Find where this path diverges from the last rendered path
40
+ let commonLen = 0
41
+ while (
42
+ commonLen < lastParts.length &&
43
+ commonLen < parts.length &&
44
+ lastParts[commonLen] === parts[commonLen]
45
+ ) {
46
+ commonLen++
47
+ }
48
+
49
+ // Print each new path component
50
+ for (let i = commonLen; i < parts.length; i++) {
51
+ let indent = ' '.repeat(i)
52
+ let isLeaf = i === parts.length - 1
53
+
54
+ if (isLeaf) {
55
+ let totalDuration = suiteTests.reduce((sum, t) => sum + t.duration, 0)
56
+ let suiteHasFailed = suiteTests.some((t) => t.status === 'failed')
57
+ let suiteAllSkipped = suiteTests.every((t) => t.status === 'skipped')
58
+ let suiteAllTodo = suiteTests.every((t) => t.status === 'todo')
59
+ let label = suiteHasFailed
60
+ ? colors.red(parts[i])
61
+ : suiteAllSkipped
62
+ ? colors.dim(parts[i])
63
+ : suiteAllTodo
64
+ ? colors.yellow(parts[i])
65
+ : colors.green(parts[i])
66
+ let suiteComment = suiteAllSkipped
67
+ ? colors.dim(' # skipped')
68
+ : suiteAllTodo
69
+ ? colors.yellow(' # todo')
70
+ : ''
71
+ let duration = suiteComment ? '' : ` (${totalDuration.toFixed(2)}ms)`
72
+ let label2 = envLabel
73
+ console.log(`${indent}${colors.dim('▶')} ${label}${duration}${suiteComment}${label2}`)
74
+ } else {
75
+ let prefix = parts.slice(0, i + 1).join(' > ')
76
+ let prefixTestList = prefixTests.get(prefix) ?? []
77
+ let prefixHasFailed = prefixTestList.some((t) => t.status === 'failed')
78
+ let prefixAllSkipped =
79
+ prefixTestList.length > 0 && prefixTestList.every((t) => t.status === 'skipped')
80
+ let prefixAllTodo =
81
+ prefixTestList.length > 0 && prefixTestList.every((t) => t.status === 'todo')
82
+ let nameColor = prefixHasFailed
83
+ ? colors.red
84
+ : prefixAllSkipped
85
+ ? colors.dim
86
+ : prefixAllTodo
87
+ ? colors.yellow
88
+ : colors.green
89
+ let prefixDuration = prefixTestList.reduce((sum, t) => sum + t.duration, 0)
90
+ let prefixComment = prefixAllSkipped
91
+ ? colors.dim(' # skipped')
92
+ : prefixAllTodo
93
+ ? colors.yellow(' # todo')
94
+ : ''
95
+ let prefixDurationStr = prefixComment ? '' : ` (${prefixDuration.toFixed(2)}ms)`
96
+ console.log(
97
+ `${indent}${colors.dim('▶')} ${nameColor(parts[i])}${prefixDurationStr}${prefixComment}${envLabel}`,
98
+ )
99
+ }
100
+ }
101
+
102
+ lastParts = parts
103
+
104
+ // Print tests indented to the suite's depth
105
+ let testIndent = ' '.repeat(parts.length)
106
+ for (let test of suiteTests) {
107
+ if (test.status === 'passed') {
108
+ console.log(
109
+ `${testIndent}${colors.green('✓')} ${test.name} (${test.duration.toFixed(2)}ms)`,
110
+ )
111
+ } else if (test.status === 'failed') {
112
+ console.log(
113
+ `${testIndent}${colors.red('✗')} ${test.name} (${test.duration.toFixed(2)}ms)`,
114
+ )
115
+ if (test.error) {
116
+ console.log(`${testIndent} ${colors.red(`Error: ${test.error.message}`)}`)
117
+ if (test.error.stack) {
118
+ let stack = test.error.stack
119
+ .split('\n')
120
+ .map((line) => normalizeLine(line))
121
+ .join('\n')
122
+ console.log(
123
+ `${testIndent} ${stack.split('\n').slice(1, 5).join(`\n${testIndent} `)}`,
124
+ )
125
+ }
126
+ }
127
+ this.#failures.push({ suiteName: test.suiteName, name: test.name, error: test.error })
128
+ } else if (test.status === 'skipped') {
129
+ if (test.name)
130
+ console.log(`${testIndent}${colors.dim('↓')} ${colors.dim(`${test.name} # skipped`)}`)
131
+ } else if (test.status === 'todo') {
132
+ if (test.name)
133
+ console.log(
134
+ `${testIndent}${colors.yellow('…')} ${colors.yellow(`${test.name} # todo`)}`,
135
+ )
136
+ }
137
+ }
138
+ }
139
+ }
140
+
141
+ onSummary(counts: Counts, durationMs: number) {
142
+ if (this.#failures.length > 0) {
143
+ console.log()
144
+ console.log(colors.red('Failed tests:'))
145
+ for (let i = 0; i < this.#failures.length; i++) {
146
+ let { suiteName, name, error } = this.#failures[i]
147
+ let fullName = name ? `${suiteName} > ${name}` : suiteName
148
+ console.log(`\n ${colors.red(`${i + 1})`)} ${fullName}`)
149
+ if (error) {
150
+ console.log(` ${colors.red(error.message)}`)
151
+ if (error.stack) {
152
+ let frames = error.stack
153
+ .split('\n')
154
+ .slice(1, 4)
155
+ .map((l) => ` ${normalizeLine(l).trim()}`)
156
+ .join('\n')
157
+ console.log(frames)
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ let { passed, failed, skipped, todo } = counts
164
+ let info = colors.cyan('ℹ')
165
+ console.log()
166
+ console.log(`${info} tests ${passed + failed + skipped + todo}`)
167
+ console.log(`${info} pass ${passed}`)
168
+ console.log(`${info} fail ${failed}`)
169
+ if (skipped > 0) console.log(`${info} skipped ${skipped}`)
170
+ if (todo > 0) console.log(`${info} todo ${todo}`)
171
+ console.log(`${info} duration_ms ${durationMs.toFixed(5)}`)
172
+ console.log()
173
+ }
174
+ }
@@ -0,0 +1,58 @@
1
+ import { normalizeLine } from '../normalize.ts'
2
+ import type { Reporter } from './index.ts'
3
+ import type { Counts, TestResults } from './results.ts'
4
+
5
+ export class TapReporter implements Reporter {
6
+ #counter = 0
7
+ #total = 0
8
+
9
+ onSectionStart(_label: string) {}
10
+
11
+ onResult(results: TestResults, env?: string) {
12
+ if (this.#counter === 0) {
13
+ console.log('TAP version 14')
14
+ }
15
+
16
+ let envComment = env ? ` # ${env}` : ''
17
+
18
+ for (let test of results.tests) {
19
+ this.#counter++
20
+ this.#total++
21
+ let fullName = test.name
22
+ ? `${test.suiteName} > ${test.name}${envComment}`
23
+ : `${test.suiteName}${envComment}`
24
+
25
+ if (test.status === 'passed') {
26
+ console.log(`ok ${this.#counter} - ${fullName}`)
27
+ } else if (test.status === 'skipped') {
28
+ console.log(`ok ${this.#counter} - ${fullName} # SKIP`)
29
+ } else if (test.status === 'todo') {
30
+ console.log(`ok ${this.#counter} - ${fullName} # TODO`)
31
+ } else {
32
+ console.log(`not ok ${this.#counter} - ${fullName}`)
33
+ console.log(' ---')
34
+ console.log(` message: ${test.error?.message ?? 'unknown error'}`)
35
+ if (test.error?.stack) {
36
+ let frames = test.error.stack
37
+ .split('\n')
38
+ .slice(1, 4)
39
+ .map((l) => normalizeLine(l).trim())
40
+ .join('\n ')
41
+ console.log(` stack: |\n ${frames}`)
42
+ }
43
+ console.log(' ...')
44
+ }
45
+ }
46
+ }
47
+
48
+ onSummary(counts: Counts, durationMs: number) {
49
+ let { passed, failed, skipped, todo } = counts
50
+ console.log(`1..${this.#total}`)
51
+ console.log(`# tests ${passed + failed + skipped + todo}`)
52
+ console.log(`# pass ${passed}`)
53
+ console.log(`# fail ${failed}`)
54
+ if (skipped > 0) console.log(`# skipped ${skipped}`)
55
+ if (todo > 0) console.log(`# todo ${todo}`)
56
+ console.log(`# duration_ms ${durationMs.toFixed(5)}`)
57
+ }
58
+ }
@@ -0,0 +1,165 @@
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
+
65
+ if (options.console) {
66
+ page.on('console', (msg) => console.log(`${colors.dim('[browser console]')} ${msg.text()}`))
67
+ }
68
+
69
+ // Playwright's JS coverage is Chromium-only. Start before navigation so
70
+ // the harness scripts and test modules are instrumented from first parse.
71
+ let coverageEnabled = options.coverage && browser.browserType().name() === 'chromium'
72
+ if (coverageEnabled) {
73
+ await page.coverage.startJSCoverage({ resetOnNavigation: false })
74
+ }
75
+
76
+ let totalPassed = 0
77
+ let totalFailed = 0
78
+ let totalSkipped = 0
79
+ let totalTodo = 0
80
+ let rootDir = getBrowserTestRootDir()
81
+
82
+ await page.route('**/file-results', async (route) => {
83
+ let results = route.request().postDataJSON() as TestResults
84
+ for (let test of results.tests) {
85
+ if (test.filePath) test.filePath = urlPathToFilePath(test.filePath, rootDir)
86
+ }
87
+ options.reporter.onResult(results, envLabel)
88
+ totalPassed += results.passed
89
+ totalFailed += results.failed
90
+ totalSkipped += results.skipped
91
+ totalTodo += results.todo
92
+ await route.fulfill({ status: 200 })
93
+ })
94
+
95
+ // Fail the tests if any /scripts/ request fails (harness scripts, test
96
+ // modules, or their transitive imports — all served via the same prefix).
97
+ let errorPromise = new Promise((_, reject) => {
98
+ let isScriptRequest = (request: Request) =>
99
+ new URL(request.url()).pathname.startsWith('/scripts/')
100
+ page!.on('response', (response) => {
101
+ if (!response.ok() && isScriptRequest(response.request())) {
102
+ reject(new Error(`Failed to load script: ${response.request().url()}`))
103
+ }
104
+ })
105
+ page!.on('requestfailed', (request) => {
106
+ if (isScriptRequest(request)) {
107
+ reject(new Error(`Failed to load script: ${request.url()}`))
108
+ }
109
+ })
110
+ })
111
+
112
+ // Prevent unhandled rejection if we fail before setting up the listener
113
+ errorPromise.catch(() => {})
114
+
115
+ await page.goto(options.baseUrl)
116
+ await Promise.race([page.waitForFunction('window.__testsDone'), errorPromise])
117
+
118
+ if (coverageEnabled) {
119
+ let entries = (await page.coverage.stopJSCoverage()) as unknown as V8CoverageEntry[]
120
+ if (entries.length > 0) {
121
+ coverageMap = await collectCoverageMapFromPlaywright(
122
+ entries,
123
+ getBrowserTestRootDir(),
124
+ new Set(options.testFiles ?? []),
125
+ async (urlPath) =>
126
+ urlPath.startsWith('/scripts/') ? urlPath.slice('/scripts/'.length) : null,
127
+ )
128
+ }
129
+ }
130
+
131
+ results = {
132
+ passed: totalPassed,
133
+ failed: totalFailed,
134
+ skipped: totalSkipped,
135
+ todo: totalTodo,
136
+ tests: [],
137
+ }
138
+ } catch (error) {
139
+ console.error('Browser tests failed to run:', error)
140
+ results = {
141
+ passed: 0,
142
+ failed: 1,
143
+ skipped: 0,
144
+ todo: 0,
145
+ tests: [],
146
+ }
147
+ }
148
+
149
+ if (options.open) {
150
+ return {
151
+ results,
152
+ coverageMap,
153
+ close,
154
+ disconnected: new Promise((r) => browser!.on('disconnected', () => r())),
155
+ }
156
+ } else {
157
+ await close()
158
+ return {
159
+ results,
160
+ coverageMap,
161
+ close,
162
+ disconnected: Promise.resolve(),
163
+ }
164
+ }
165
+ }
@@ -0,0 +1,189 @@
1
+ import * as fsp from 'node:fs/promises'
2
+ import * as path from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+ import { Worker } from 'node:worker_threads'
5
+ import { IS_RUNNING_FROM_SRC } from './config.ts'
6
+ import {
7
+ collectCoverageMapFromPlaywright,
8
+ collectServerCoverageMap,
9
+ type CoverageConfig,
10
+ type CoverageMap,
11
+ type V8CoverageEntry,
12
+ } from './coverage.ts'
13
+ import { type PlaywrightUseOpts } from './playwright.ts'
14
+ import type { Reporter } from './reporters/index.ts'
15
+ import type { Counts, TestResults } from './reporters/results.ts'
16
+
17
+ // Ensure we load the right file whether we're running in the monorepo (TS) or
18
+ // from a published package (JS)
19
+ const ext = IS_RUNNING_FROM_SRC ? '.ts' : '.js'
20
+ const workerUrl = new URL(`./worker${ext}`, import.meta.url)
21
+ const workerE2EUrl = new URL(`./worker-e2e${ext}`, import.meta.url)
22
+
23
+ export async function runServerTests(
24
+ files: string[],
25
+ reporter: Reporter,
26
+ concurrency: number,
27
+ type: 'server' | 'e2e',
28
+ options: {
29
+ cwd?: string
30
+ open?: boolean
31
+ playwrightUseOpts?: PlaywrightUseOpts
32
+ projectName?: string
33
+ coverage?: CoverageConfig
34
+ } = {},
35
+ ): Promise<Counts & { coverageMap: CoverageMap | null }> {
36
+ let counts: Counts = { passed: 0, failed: 0, skipped: 0, todo: 0 }
37
+ let coverageMap: CoverageMap | null = null
38
+ let cwd = options.cwd ?? process.cwd()
39
+ let envLabel = options.projectName ? `${type}:${options.projectName}` : type
40
+
41
+ function accumulate(results: TestResults, file: string) {
42
+ reporter.onResult(
43
+ { ...results, tests: results.tests.map((t) => ({ ...t, filePath: file })) },
44
+ envLabel,
45
+ )
46
+ counts.passed += results.passed
47
+ counts.failed += results.failed
48
+ counts.skipped += results.skipped
49
+ counts.todo += results.todo
50
+ }
51
+
52
+ if (type === 'e2e') {
53
+ let allBrowserCoverageEntries: Array<{ entries: V8CoverageEntry[]; baseUrl: string }> = []
54
+
55
+ await runInConcurrentWorkers(
56
+ files,
57
+ concurrency,
58
+ (file) =>
59
+ runFileInWorker(
60
+ file,
61
+ type,
62
+ (results) => {
63
+ accumulate(results, file)
64
+ if (results.e2eBrowserCoverageEntries) {
65
+ allBrowserCoverageEntries.push(...results.e2eBrowserCoverageEntries)
66
+ }
67
+ },
68
+ {
69
+ ...options,
70
+ playwrightUseOpts: options.playwrightUseOpts,
71
+ },
72
+ ),
73
+ () => counts.failed++,
74
+ )
75
+
76
+ if (options.coverage && allBrowserCoverageEntries.length > 0) {
77
+ coverageMap = await collectCoverageMapFromPlaywright(
78
+ allBrowserCoverageEntries.flatMap((e) => e.entries),
79
+ cwd,
80
+ new Set(files),
81
+ async (urlPath) => (urlPath.startsWith('/') ? urlPath.slice(1) : urlPath),
82
+ )
83
+ }
84
+ } else {
85
+ let coverageDataDir: string | undefined
86
+ if (options.coverage) {
87
+ coverageDataDir = path.resolve(cwd, options.coverage.dir)
88
+ await fsp.mkdir(coverageDataDir, { recursive: true })
89
+ process.env.NODE_V8_COVERAGE = coverageDataDir
90
+ }
91
+
92
+ await runInConcurrentWorkers(
93
+ files,
94
+ concurrency,
95
+ (file) => runFileInWorker(file, type, (results) => accumulate(results, file), options),
96
+ () => counts.failed++,
97
+ )
98
+
99
+ if (coverageDataDir) {
100
+ delete process.env.NODE_V8_COVERAGE
101
+ let serverMap = await collectServerCoverageMap(coverageDataDir, cwd, new Set(files))
102
+ coverageMap = serverMap
103
+ }
104
+ }
105
+
106
+ return { ...counts, coverageMap }
107
+ }
108
+
109
+ async function runInConcurrentWorkers(
110
+ files: string[],
111
+ concurrency: number,
112
+ runFile: (file: string) => Promise<void>,
113
+ onError: () => void,
114
+ ): Promise<void> {
115
+ let index = 0
116
+ let active = 0
117
+
118
+ await new Promise<void>((resolve) => {
119
+ function dispatch() {
120
+ while (active < concurrency && index < files.length) {
121
+ let file = files[index]
122
+ index++
123
+ active++
124
+
125
+ runFile(file).then(
126
+ () => {
127
+ active--
128
+ if (index < files.length) {
129
+ dispatch()
130
+ } else if (active === 0) {
131
+ resolve()
132
+ }
133
+ },
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()
141
+ },
142
+ )
143
+ }
144
+
145
+ if (index >= files.length && active === 0) resolve()
146
+ }
147
+
148
+ dispatch()
149
+ })
150
+ }
151
+
152
+ function runFileInWorker(
153
+ file: string,
154
+ type: 'server' | 'e2e',
155
+ 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))
183
+ worker.once('error', reject)
184
+ worker.once('exit', (code) => {
185
+ if (code !== 0) reject(new Error(`Worker exited with code ${code}`))
186
+ else resolve()
187
+ })
188
+ })
189
+ }
@@ -0,0 +1,2 @@
1
+ // https://bun.com/docs/guides/util/detect-bun
2
+ export const IS_BUN = typeof process.versions.bun === 'string'
@@ -0,0 +1,36 @@
1
+ import { transform, type TsconfigRaw } from 'esbuild'
2
+ import { getTsconfig, type TsConfigResult } from 'get-tsconfig'
3
+ import * as path from 'node:path'
4
+
5
+ const tsconfigCache = new Map<string, TsConfigResult | null>()
6
+
7
+ /*
8
+ * Transform a TypeScript file to JavaScript using esbuild with an inline
9
+ * source map and no minification. Used by the coverage ESM loader hook (so V8
10
+ * instruments readable JS), the coverage collector (so byte offsets can be
11
+ * re-derived and mapped back to TypeScript lines), and the browser harness
12
+ * server (so the bytes V8 sees in the browser match what the collector
13
+ * re-derives). Identical inputs must produce identical outputs across all
14
+ * call sites or coverage offsets won't line up.
15
+ *
16
+ * Compiler options (notably JSX) are taken from the nearest `tsconfig.json`
17
+ * walking up from the file's directory, so each project picks up its own
18
+ * `jsxImportSource` etc. Discovery results are cached by directory.
19
+ */
20
+ export async function transformTypeScript(
21
+ source: string,
22
+ filePath: string,
23
+ ): Promise<{ code: string }> {
24
+ let loader: 'ts' | 'tsx' = filePath.endsWith('.tsx') ? 'tsx' : 'ts'
25
+
26
+ let tsConfig = getTsconfig(path.dirname(filePath), 'tsconfig.json', tsconfigCache)
27
+
28
+ let result = await transform(source, {
29
+ loader,
30
+ sourcemap: 'inline',
31
+ sourcesContent: true,
32
+ sourcefile: filePath,
33
+ tsconfigRaw: { compilerOptions: tsConfig?.config.compilerOptions ?? {} },
34
+ })
35
+ return { code: result.code }
36
+ }
@@ -0,0 +1,46 @@
1
+ import * as fs from 'node:fs'
2
+
3
+ function getFileModTime(file: string): number {
4
+ try {
5
+ return fs.statSync(file).mtimeMs
6
+ } catch {
7
+ return 0
8
+ }
9
+ }
10
+
11
+ export function createWatcher(onChange: (file: string) => void) {
12
+ let watchers = new Set<fs.FSWatcher>()
13
+ let fileModTimes = new Map<string, number>()
14
+
15
+ function update(files: string[]) {
16
+ for (let watcher of watchers) {
17
+ watcher.close()
18
+ }
19
+ watchers.clear()
20
+
21
+ for (let file of files) {
22
+ fileModTimes.set(file, getFileModTime(file))
23
+ watchers.add(
24
+ fs.watch(file, () => {
25
+ // macOS FSEvents can fire multiple callbacks per save (e.g. write +
26
+ // metadata flush). Guard with mtime so only a real content change
27
+ // triggers a rerun instead of every duplicate event.
28
+ let mtime = getFileModTime(file)
29
+ if (mtime !== fileModTimes.get(file)) {
30
+ fileModTimes.set(file, mtime)
31
+ onChange(file)
32
+ }
33
+ }),
34
+ )
35
+ }
36
+ }
37
+
38
+ function close() {
39
+ for (let watcher of watchers) {
40
+ watcher.close()
41
+ }
42
+ watchers.clear()
43
+ }
44
+
45
+ return { update, close }
46
+ }