@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,353 @@
1
+ import { normalizeLine } from '../../lib/normalize.ts'
2
+ import type { TestResult, TestResults } from '../../lib/reporters/results.ts'
3
+
4
+ interface TestsSetup {
5
+ testPaths: string[]
6
+ baseDir: string
7
+ }
8
+
9
+ type FileResults = TestResults & { tests: Array<TestResult & { filePath: string }> }
10
+
11
+ const STYLES = `
12
+ .rt-container {
13
+ font-family: monospace;
14
+ padding: 16px;
15
+ max-width: 900px;
16
+ }
17
+ .rt-summary {
18
+ margin-bottom: 16px;
19
+ line-height: 1.6;
20
+ }
21
+ .rt-summary-row {
22
+ display: block;
23
+ }
24
+ .rt-info {
25
+ color: #0ea5e9;
26
+ }
27
+ .rt-indent {
28
+ margin-left: 16px;
29
+ margin-top: 4px;
30
+ }
31
+ .rt-suite-details {
32
+ margin-bottom: 8px;
33
+ }
34
+ .rt-suite-summary {
35
+ cursor: pointer;
36
+ padding: 2px 0;
37
+ user-select: none;
38
+ }
39
+ .rt-suite-icon {
40
+ margin-left: 6px;
41
+ }
42
+ .rt-test-item {
43
+ padding: 3px 18px;
44
+ }
45
+ .rt-test-duration {
46
+ color: #999;
47
+ font-size: 0.85em;
48
+ }
49
+ .rt-error-pre {
50
+ margin: 4px 0 4px 16px;
51
+ padding: 8px 12px;
52
+ font-size: 12px;
53
+ color: #dc2626;
54
+ background: #fff5f5;
55
+ border-left: 3px solid #dc2626;
56
+ white-space: pre-wrap;
57
+ word-break: break-word;
58
+ }
59
+ .rt-error-stack {
60
+ color: #999;
61
+ margin-top: 6px;
62
+ }
63
+ .rt-button {
64
+ margin-top: 8px;
65
+ padding: 6px 12px;
66
+ cursor: pointer;
67
+ }
68
+ .rt-stack-link {
69
+ color: inherit;
70
+ text-decoration: underline;
71
+ text-decoration-color: #aaa;
72
+ }
73
+ .rt-passed {
74
+ color: #16a34a;
75
+ }
76
+ .rt-failed {
77
+ color: #dc2626;
78
+ }
79
+ .rt-muted {
80
+ color: #666;
81
+ }
82
+ .rt-todo {
83
+ color: #a16207;
84
+ }
85
+ `
86
+
87
+ const styleEl = document.createElement('style')
88
+ styleEl.textContent = STYLES
89
+ document.head.appendChild(styleEl)
90
+
91
+ const setupEl = document.getElementById('test-setup')
92
+ if (!setupEl?.textContent) {
93
+ throw new Error('Test runner: missing #test-setup payload')
94
+ }
95
+ const setup = JSON.parse(setupEl.textContent) as TestsSetup
96
+ const root = document.getElementById('test-root')
97
+ if (!root) {
98
+ throw new Error('Test runner: missing #test-root mount point')
99
+ }
100
+ mountTests(root, setup)
101
+
102
+ function mountTests(host: HTMLElement, setup: TestsSetup): void {
103
+ let startTime = performance.now()
104
+ let totals = { passed: 0, failed: 0, skipped: 0, todo: 0 }
105
+
106
+ let container = el('div', { id: 'test-status', className: 'rt-container' })
107
+ host.appendChild(container)
108
+
109
+ let summary = el('div', { className: 'rt-summary' })
110
+ container.appendChild(summary)
111
+
112
+ let testsRow = summaryRow()
113
+ let passRow = summaryRow()
114
+ let failRow = summaryRow()
115
+ let skippedRow = summaryRow()
116
+ let todoRow = summaryRow()
117
+ let durationRow = summaryRow()
118
+ summary.append(testsRow.el, passRow.el, failRow.el)
119
+
120
+ let suitesContainer = el('div')
121
+ container.appendChild(suitesContainer)
122
+
123
+ function renderSummary(done: boolean) {
124
+ let total = totals.passed + totals.failed + totals.skipped + totals.todo
125
+ testsRow.text(`tests ${total}`)
126
+ passRow.text(`pass ${totals.passed}`)
127
+ failRow.text(`fail ${totals.failed}`)
128
+ if (totals.skipped > 0) {
129
+ if (!skippedRow.el.parentNode) summary.appendChild(skippedRow.el)
130
+ skippedRow.text(`skipped ${totals.skipped}`)
131
+ }
132
+ if (totals.todo > 0) {
133
+ if (!todoRow.el.parentNode) summary.appendChild(todoRow.el)
134
+ todoRow.text(`todo ${totals.todo}`)
135
+ }
136
+ if (done) {
137
+ if (!durationRow.el.parentNode) summary.appendChild(durationRow.el)
138
+ durationRow.text(`duration_ms ${(performance.now() - startTime).toFixed(5)}`)
139
+ }
140
+ }
141
+
142
+ function appendFileSuites(fileResults: FileResults) {
143
+ let suiteMap = new Map<string, TestResult[]>()
144
+ for (let test of fileResults.tests) {
145
+ let suite = test.suiteName || 'Tests'
146
+ if (!suiteMap.has(suite)) suiteMap.set(suite, [])
147
+ suiteMap.get(suite)!.push(test)
148
+ }
149
+ for (let [suiteName, tests] of suiteMap) {
150
+ suitesContainer.appendChild(buildSuite(suiteName, tests, setup.baseDir))
151
+ }
152
+ }
153
+
154
+ function appendRerunButton() {
155
+ let button = el('button', { className: 'rt-button', textContent: 'Re-run' })
156
+ button.type = 'button'
157
+ button.addEventListener('click', () => window.location.reload())
158
+ container.appendChild(button)
159
+ }
160
+
161
+ renderSummary(false)
162
+
163
+ void (async () => {
164
+ for (let testFile of setup.testPaths) {
165
+ let fileResults = await runInIframe(testFile)
166
+ await fetch('/file-results', {
167
+ method: 'POST',
168
+ headers: { 'Content-Type': 'application/json' },
169
+ body: JSON.stringify(fileResults),
170
+ })
171
+ totals.passed += fileResults.passed
172
+ totals.failed += fileResults.failed
173
+ totals.skipped += fileResults.skipped
174
+ totals.todo += fileResults.todo
175
+ appendFileSuites(fileResults)
176
+ renderSummary(false)
177
+ }
178
+ renderSummary(true)
179
+ appendRerunButton()
180
+ ;(window as unknown as { __testsDone?: boolean }).__testsDone = true
181
+ })()
182
+ }
183
+
184
+ function runInIframe(testFile: string): Promise<FileResults> {
185
+ return new Promise((resolve) => {
186
+ let iframe = document.createElement('iframe')
187
+ iframe.src = `/iframe?file=${encodeURIComponent(testFile)}`
188
+ document.body.appendChild(iframe)
189
+
190
+ function onMessage(event: MessageEvent) {
191
+ if (event.source !== iframe.contentWindow) return
192
+ window.removeEventListener('message', onMessage)
193
+ // Hide instead of remove so when coverage is enabled the iframe remains attached
194
+ // so V8 retains its scripts and Playwright can collect coverage at run end.
195
+ iframe.style.display = 'none'
196
+ if (event.data.type === 'test-results') {
197
+ let { passed, failed, skipped, todo, tests } = event.data.results as TestResults
198
+ resolve({
199
+ passed,
200
+ failed,
201
+ skipped,
202
+ todo,
203
+ tests: tests.map((t) => ({ ...t, filePath: testFile })),
204
+ })
205
+ } else {
206
+ let { message, stack } = event.data.error
207
+ resolve({
208
+ passed: 0,
209
+ failed: 1,
210
+ skipped: 0,
211
+ todo: 0,
212
+ tests: [
213
+ {
214
+ name: '',
215
+ suiteName: testFile,
216
+ filePath: testFile,
217
+ status: 'failed',
218
+ error: { message, stack },
219
+ duration: 0,
220
+ },
221
+ ],
222
+ })
223
+ }
224
+ }
225
+
226
+ window.addEventListener('message', onMessage)
227
+ })
228
+ }
229
+
230
+ function buildSuite(suiteName: string, tests: TestResult[], baseDir: string): HTMLElement {
231
+ let suiteFailed = tests.some((t) => t.status === 'failed')
232
+ let suiteAllSkipped = tests.every((t) => t.status === 'skipped')
233
+ let suiteAllTodo = tests.every((t) => t.status === 'todo')
234
+ let stateClass = suiteFailed
235
+ ? 'rt-failed'
236
+ : suiteAllSkipped
237
+ ? 'rt-muted'
238
+ : suiteAllTodo
239
+ ? 'rt-todo'
240
+ : 'rt-passed'
241
+ let icon = suiteFailed ? '✗' : suiteAllSkipped ? '↓' : suiteAllTodo ? '…' : '✓'
242
+ let suffix = suiteAllSkipped ? ' # skipped' : suiteAllTodo ? ' # todo' : ''
243
+
244
+ let details = el('details', { className: 'rt-suite-details' })
245
+ if (suiteFailed) details.open = true
246
+
247
+ let summary = el('summary', { className: `rt-suite-summary ${stateClass}` })
248
+ summary.appendChild(
249
+ el('span', { className: 'rt-suite-icon', textContent: `${icon} ${suiteName}${suffix}` }),
250
+ )
251
+ details.appendChild(summary)
252
+
253
+ let body = el('div', { className: 'rt-indent' })
254
+ for (let test of tests) {
255
+ let item = buildTestItem(test, baseDir)
256
+ if (item) body.appendChild(el('div', { className: 'rt-test-item' }, item))
257
+ }
258
+ details.appendChild(body)
259
+ return details
260
+ }
261
+
262
+ function buildTestItem(test: TestResult, baseDir: string): HTMLElement | null {
263
+ if (test.status === 'passed') {
264
+ let row = el('div', { className: 'rt-passed' })
265
+ row.append(`✓ ${test.name} `)
266
+ row.appendChild(
267
+ el('span', {
268
+ className: 'rt-test-duration',
269
+ textContent: `(${test.duration.toFixed(2)}ms)`,
270
+ }),
271
+ )
272
+ return row
273
+ }
274
+ if (test.status === 'failed') {
275
+ let row = el('div', { className: 'rt-failed' })
276
+ row.append(`✗ ${test.name} `)
277
+ row.appendChild(
278
+ el('span', {
279
+ className: 'rt-test-duration',
280
+ textContent: `(${test.duration.toFixed(2)}ms)`,
281
+ }),
282
+ )
283
+ if (test.error) {
284
+ let pre = el('pre', { className: 'rt-error-pre' })
285
+ pre.append(test.error.message)
286
+ if (test.error.stack) {
287
+ let stackDiv = el('div', { className: 'rt-error-stack' })
288
+ stackDiv.appendChild(buildStack(test.error.stack, baseDir))
289
+ pre.appendChild(stackDiv)
290
+ }
291
+ row.appendChild(pre)
292
+ }
293
+ return row
294
+ }
295
+ if (test.status === 'skipped' && test.name) {
296
+ return el('div', { className: 'rt-muted', textContent: `↓ ${test.name} # skipped` })
297
+ }
298
+ if (test.status === 'todo' && test.name) {
299
+ return el('div', { className: 'rt-todo', textContent: `… ${test.name} # todo` })
300
+ }
301
+ return null
302
+ }
303
+
304
+ function buildStack(stack: string, baseDir: string): DocumentFragment {
305
+ let frameLocRe = /([^():\s][^():]*\.[jt]sx?):(\d+):(\d+)/
306
+ let frag = document.createDocumentFragment()
307
+ for (let raw of stack.split('\n')) {
308
+ let isTestModule = raw.includes('/@test/')
309
+ let line = normalizeLine(raw)
310
+ let match = isTestModule ? frameLocRe.exec(line) : null
311
+ let div = document.createElement('div')
312
+ if (match) {
313
+ let [full, file, row, col] = match
314
+ let abs = `${baseDir}/${file}`
315
+ let href = `vscode://file/${abs}:${row}:${col}`
316
+ div.append(line.slice(0, match.index))
317
+ let a = el('a', { className: 'rt-stack-link', textContent: full })
318
+ a.href = href
319
+ div.appendChild(a)
320
+ div.append(line.slice(match.index + full.length))
321
+ } else {
322
+ div.textContent = line
323
+ }
324
+ frag.appendChild(div)
325
+ }
326
+ return frag
327
+ }
328
+
329
+ function summaryRow() {
330
+ let row = el('span', { className: 'rt-summary-row' })
331
+ let icon = el('span', { className: 'rt-info', textContent: 'ℹ' })
332
+ let textNode = document.createTextNode('')
333
+ row.append(icon, ' ', textNode)
334
+ return {
335
+ el: row,
336
+ text(s: string) {
337
+ textNode.data = ' ' + s
338
+ },
339
+ }
340
+ }
341
+
342
+ function el<K extends keyof HTMLElementTagNameMap>(
343
+ tag: K,
344
+ props?: { id?: string; className?: string; textContent?: string },
345
+ ...children: Array<Node | string>
346
+ ): HTMLElementTagNameMap[K] {
347
+ let node = document.createElement(tag)
348
+ if (props?.id) node.id = props.id
349
+ if (props?.className) node.className = props.className
350
+ if (props?.textContent != null) node.textContent = props.textContent
351
+ if (children.length) node.append(...children)
352
+ return node
353
+ }
@@ -0,0 +1,18 @@
1
+ import { runTests } from '../../lib/executor.ts'
2
+
3
+ const params = new URLSearchParams(location.search)
4
+ const testFile = params.get('file')!
5
+
6
+ try {
7
+ await import(testFile)
8
+ let results = await runTests()
9
+ window.parent.postMessage({ type: 'test-results', results }, '*')
10
+ } catch (error: any) {
11
+ window.parent.postMessage(
12
+ {
13
+ type: 'test-error',
14
+ error: { message: error?.message ?? String(error), stack: error?.stack },
15
+ },
16
+ '*',
17
+ )
18
+ }
@@ -0,0 +1,336 @@
1
+ import { init as initEsModuleLexer, parse as parseEsModule } from 'es-module-lexer'
2
+ import MagicString from 'magic-string'
3
+ import * as fsp from 'node:fs/promises'
4
+ import * as http from 'node:http'
5
+ import { createRequire } from 'node:module'
6
+ import * as path from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { SourceMapConsumer, SourceMapGenerator } from 'source-map-js'
9
+ import { getBrowserTestRootDir, IS_RUNNING_FROM_SRC } from '../lib/config.ts'
10
+ import { transformTypeScript } from '../lib/ts-transform.ts'
11
+
12
+ export async function startServer(
13
+ browserFiles: string[],
14
+ ): Promise<{ server: http.Server; port: number }> {
15
+ let handle = createRequestHandler(browserFiles)
16
+ let port = 44101
17
+
18
+ let lastError: unknown
19
+ for (let i = 0; i < 5; i++) {
20
+ try {
21
+ let server = http.createServer((req, res) => {
22
+ handle(req, res).catch((error) => {
23
+ console.error(`[remix-test] Unhandled error for ${req.url}:`, error)
24
+ if (!res.headersSent) {
25
+ res.writeHead(500, { 'Content-Type': 'text/plain' })
26
+ }
27
+ if (!res.writableEnded) res.end()
28
+ })
29
+ })
30
+ await new Promise<void>((resolve, reject) => {
31
+ server.once('error', reject)
32
+ server.listen(port, () => {
33
+ server.removeListener('error', reject)
34
+ console.log(`Test server running on http://localhost:${port}`)
35
+ resolve()
36
+ })
37
+ })
38
+ return { server, port }
39
+ } catch (error: any) {
40
+ if (error.code !== 'EADDRINUSE') throw error
41
+ lastError = error
42
+ console.log(`Port ${port} is in use, trying another port...`)
43
+ port += 1
44
+ }
45
+ }
46
+
47
+ throw lastError
48
+ }
49
+
50
+ function createRequestHandler(
51
+ browserFiles: string[],
52
+ ): (req: http.IncomingMessage, res: http.ServerResponse) => Promise<void> {
53
+ let rootDir = getBrowserTestRootDir()
54
+ let srcDir = IS_RUNNING_FROM_SRC
55
+ ? // Up one directory from src/app/
56
+ path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
57
+ : // Directory of the published index.js file
58
+ path.dirname(fileURLToPath(import.meta.resolve('@remix-run/test')))
59
+ let clientDir = path.join(srcDir, 'app', 'client')
60
+ let scriptExt = IS_RUNNING_FROM_SRC ? 'ts' : 'js'
61
+ let entryUrl = filePathToUrl(path.join(clientDir, `entry.${scriptExt}`), rootDir)
62
+ let iframeUrl = filePathToUrl(path.join(clientDir, `iframe.${scriptExt}`), rootDir)
63
+ if (!entryUrl || !iframeUrl) {
64
+ throw new Error(`Harness scripts in ${clientDir} are outside rootDir ${rootDir}`)
65
+ }
66
+
67
+ let testPaths = browserFiles.map((f) => filePathToUrl(f, rootDir)!)
68
+
69
+ return async (req, res) => {
70
+ let url = new URL(req.url ?? '/', 'http://localhost')
71
+
72
+ if (req.method !== 'GET') {
73
+ sendText(res, 405, 'Method Not Allowed')
74
+ return
75
+ }
76
+
77
+ if (url.pathname === '/') {
78
+ let setupJson = JSON.stringify({ testPaths, baseDir: process.cwd() })
79
+ let body =
80
+ `<script type="application/json" id="test-setup">` +
81
+ `${escapeJsonForScript(setupJson)}` +
82
+ `</script>` +
83
+ `<div id="test-root"></div>` +
84
+ `<script type="module" src="${entryUrl}"></script>`
85
+ sendHtml(res, 'Tests', body)
86
+ return
87
+ }
88
+
89
+ if (url.pathname === '/iframe') {
90
+ let test = decodeURIComponent(url.searchParams.get('file') || '')
91
+ sendHtml(res, `Test: ${test}`, `<script type="module" src="${iframeUrl}"></script>`)
92
+ return
93
+ }
94
+
95
+ if (url.pathname.startsWith('/scripts/')) {
96
+ let filePath = urlPathToFilePath(url.pathname, rootDir)
97
+ if (filePath) {
98
+ try {
99
+ await serveScript(res, filePath, url.pathname, rootDir)
100
+ return
101
+ } catch (error) {
102
+ console.error(`[remix-test] Error serving ${url.pathname}:`, error)
103
+ sendText(res, 500, String(error))
104
+ return
105
+ }
106
+ }
107
+ }
108
+
109
+ sendText(res, 404, 'Not found')
110
+ }
111
+ }
112
+
113
+ function filePathToUrl(filePath: string, rootDir: string): string | null {
114
+ let rel = path.relative(rootDir, filePath)
115
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return null
116
+ return '/scripts/' + rel.split(path.sep).join('/')
117
+ }
118
+
119
+ // `/scripts/<rel>` → `<rootDir>/<rel>` (URL space mirrors the filesystem
120
+ // rooted at rootDir, with `..` segments rejected so requests can't escape).
121
+ function urlPathToFilePath(urlPath: string, rootDir: string): string | null {
122
+ if (!urlPath.startsWith('/scripts/')) return null
123
+ let relative = urlPath.slice('/scripts/'.length)
124
+ if (!relative) return null
125
+ let filePath = path.resolve(rootDir, relative)
126
+ if (filePath !== rootDir && !filePath.startsWith(rootDir + path.sep)) return null
127
+ return filePath
128
+ }
129
+
130
+ const TS_EXTS = new Set(['.ts', '.tsx', '.mts', '.cts'])
131
+ const JS_EXTS = new Set(['.js', '.mjs', '.cjs', '.jsx'])
132
+
133
+ async function serveScript(
134
+ res: http.ServerResponse,
135
+ filePath: string,
136
+ urlPath: string,
137
+ rootDir: string,
138
+ ): Promise<void> {
139
+ let ext = path.extname(filePath)
140
+ let isTs = TS_EXTS.has(ext)
141
+ let isJs = JS_EXTS.has(ext)
142
+ if (!isTs && !isJs) {
143
+ sendText(res, 400, `Unsupported script extension "${ext}" for ${urlPath}`)
144
+ return
145
+ }
146
+
147
+ let source = await fsp.readFile(filePath, 'utf-8')
148
+ let code: string
149
+ if (isTs) {
150
+ try {
151
+ let result = await transformTypeScript(source, filePath)
152
+ code = result.code
153
+ } catch (error) {
154
+ let msg = error instanceof Error ? error.message : String(error)
155
+ console.error(`[remix-test] Failed to transform ${urlPath}: ${msg}`)
156
+ sendText(res, 500, msg)
157
+ return
158
+ }
159
+ } else {
160
+ code = source
161
+ }
162
+
163
+ try {
164
+ code = await rewriteImports(code, filePath, rootDir)
165
+ } catch (error) {
166
+ let msg = error instanceof Error ? error.message : String(error)
167
+ console.error(`[remix-test] Failed to rewrite imports for ${urlPath}: ${msg}`)
168
+ sendText(res, 500, msg)
169
+ return
170
+ }
171
+
172
+ res.writeHead(200, { 'Content-Type': 'application/javascript' })
173
+ res.end(code)
174
+ }
175
+
176
+ // Rewrite every import/export specifier in the (already-transformed) source so
177
+ // it points at an absolute `/scripts/<rel>` URL the harness can serve. Bare
178
+ // specifiers go through Node's resolver; relative specifiers resolve against
179
+ // the importer file's directory. This keeps the URL space === filesystem
180
+ // layout so harness scripts (under clientDir) and source files (anywhere
181
+ // under rootDir) can import each other without URL-relative confusion.
182
+ //
183
+ // Uses es-module-lexer (purpose-built ESM scanner) + magic-string so that the
184
+ // edits compose cleanly with the inline TS→JS source map from
185
+ // transformTypeScript: the resulting inline map is a true rewrittenJS → TS
186
+ // map, not just the original TS → JS map slapped on top of mutated bytes.
187
+ async function rewriteImports(
188
+ code: string,
189
+ importerFile: string,
190
+ rootDir: string,
191
+ ): Promise<string> {
192
+ await initEsModuleLexer
193
+
194
+ let { code: codeNoMap, map: tsToJsMap } = extractInlineSourceMap(code)
195
+ let [imports] = parseEsModule(codeNoMap)
196
+ let s = new MagicString(codeNoMap)
197
+ let edited = false
198
+
199
+ for (let imp of imports) {
200
+ // n is the parsed specifier value (with escapes resolved); undefined when
201
+ // the dynamic import argument isn't a static string literal.
202
+ if (imp.n == null) continue
203
+ let url = resolveSpecifier(imp.n, importerFile, rootDir)
204
+ if (url == null || url === imp.n) continue
205
+ s.overwrite(imp.s, imp.e, url)
206
+ edited = true
207
+ }
208
+
209
+ if (!edited) return code
210
+
211
+ let rewrittenCode = s.toString()
212
+ let rewriteMap = JSON.parse(s.generateMap({ hires: true }).toString())
213
+
214
+ let finalMap = tsToJsMap ? composeSourceMaps(rewriteMap, tsToJsMap) : rewriteMap
215
+ let mapJson = JSON.stringify(finalMap)
216
+ let mapBase64 = Buffer.from(mapJson).toString('base64')
217
+ return `${rewrittenCode}\n//# sourceMappingURL=data:application/json;base64,${mapBase64}`
218
+ }
219
+
220
+ // Strip the trailing `//# sourceMappingURL=data:application/json;base64,...`
221
+ // comment and decode the embedded JSON.
222
+ function extractInlineSourceMap(code: string): { code: string; map: unknown | null } {
223
+ let re =
224
+ /\n?\/\/# sourceMappingURL=data:application\/json(?:;charset=[^;,]+)?[;,]base64,([A-Za-z0-9+/=]+)\s*$/
225
+ let match = code.match(re)
226
+ if (!match || match.index == null) return { code, map: null }
227
+ try {
228
+ let decoded = Buffer.from(match[1], 'base64').toString('utf-8')
229
+ return { code: code.slice(0, match.index), map: JSON.parse(decoded) }
230
+ } catch {
231
+ return { code, map: null }
232
+ }
233
+ }
234
+
235
+ // Compose two source maps so positions in `secondMap`'s generated code map all
236
+ // the way back to `firstMap`'s original sources. `secondMap`'s "original" must
237
+ // be in the same coordinate space as `firstMap`'s "generated" — i.e. for our
238
+ // case the input to magic-string is the output of transformTypeScript.
239
+ function composeSourceMaps(secondMap: unknown, firstMap: unknown): unknown {
240
+ let secondConsumer = new SourceMapConsumer(secondMap as any)
241
+ let firstConsumer = new SourceMapConsumer(firstMap as any)
242
+ let gen = new SourceMapGenerator()
243
+
244
+ secondConsumer.eachMapping((mapping) => {
245
+ if (mapping.originalLine == null || mapping.originalColumn == null) return
246
+ let original = firstConsumer.originalPositionFor({
247
+ line: mapping.originalLine,
248
+ column: mapping.originalColumn,
249
+ })
250
+ if (original.line == null || original.column == null || original.source == null) return
251
+ gen.addMapping({
252
+ generated: { line: mapping.generatedLine, column: mapping.generatedColumn },
253
+ original: { line: original.line, column: original.column },
254
+ source: original.source,
255
+ name: original.name ?? mapping.name ?? undefined,
256
+ })
257
+ })
258
+
259
+ for (let source of firstConsumer.sources) {
260
+ let content = firstConsumer.sourceContentFor(source, true)
261
+ if (content !== null) gen.setSourceContent(source, content)
262
+ }
263
+
264
+ return gen.toJSON()
265
+ }
266
+
267
+ function resolveSpecifier(spec: string, importerFile: string, rootDir: string): string | null {
268
+ if (
269
+ spec.startsWith('node:') ||
270
+ spec.startsWith('http:') ||
271
+ spec.startsWith('https:') ||
272
+ spec.startsWith('data:') ||
273
+ spec.startsWith('/scripts/')
274
+ ) {
275
+ return null
276
+ }
277
+
278
+ let resolvedPath: string
279
+ if (spec.startsWith('.') || spec.startsWith('/')) {
280
+ resolvedPath = path.resolve(path.dirname(importerFile), spec)
281
+ } else {
282
+ // Bare specifiers must be resolved from the importer's filesystem
283
+ // location, not this module's. `import.meta.resolve(spec, parent)` looks
284
+ // like the right tool but its `parent` argument is gated behind
285
+ // `--experimental-import-meta-resolve` through at least Node 24 —
286
+ // without the flag, the parent argument is silently ignored and
287
+ // resolution happens from `import.meta.url` of the calling module. That
288
+ // made bare specifiers only resolvable when they were direct deps of
289
+ // `@remix-run/test` itself (so `remix/assert` failed even when the
290
+ // importing package depended on `remix`). `createRequire` walks
291
+ // node_modules from the importer's actual location and has been stable
292
+ // since Node 12 with no flags.
293
+ try {
294
+ resolvedPath = createRequire(importerFile).resolve(spec)
295
+ } catch {
296
+ return null
297
+ }
298
+ }
299
+
300
+ return filePathToUrl(resolvedPath, rootDir)
301
+ }
302
+
303
+ function sendHtml(res: http.ServerResponse, title: string, body: string): void {
304
+ let doc =
305
+ `<!DOCTYPE html>` +
306
+ `<html>` +
307
+ `<head>` +
308
+ `<meta charset="utf-8">` +
309
+ `<title>${escapeHtml(title)}</title>` +
310
+ `</head>` +
311
+ `<body>${body}</body>` +
312
+ `</html>`
313
+ res.writeHead(200, { 'Content-Type': 'text/html' })
314
+ res.end(doc)
315
+ }
316
+
317
+ function sendText(res: http.ServerResponse, status: number, body: string): void {
318
+ res.writeHead(status, { 'Content-Type': 'text/plain' })
319
+ res.end(body)
320
+ }
321
+
322
+ function escapeHtml(s: string): string {
323
+ return s
324
+ .replace(/&/g, '&amp;')
325
+ .replace(/</g, '&lt;')
326
+ .replace(/>/g, '&gt;')
327
+ .replace(/"/g, '&quot;')
328
+ }
329
+
330
+ // Prevent the embedded JSON from terminating the surrounding <script> element
331
+ // or being interpreted as HTML. Only `</` needs escaping inside an
332
+ // `application/json` block; the leading `<` is preserved as `<` in the
333
+ // emitted JSON so JSON.parse round-trips it unchanged.
334
+ function escapeJsonForScript(json: string): string {
335
+ return json.replace(/</g, '\\u003c')
336
+ }