@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.
- package/LICENSE +21 -0
- package/README.md +430 -2
- package/dist/app/client/entry.d.ts +2 -0
- package/dist/app/client/entry.d.ts.map +1 -0
- package/dist/app/client/entry.js +324 -0
- package/dist/app/client/iframe.d.ts +2 -0
- package/dist/app/client/iframe.d.ts.map +1 -0
- package/dist/app/client/iframe.js +22 -0
- package/dist/app/server.d.ts +6 -0
- package/dist/app/server.d.ts.map +1 -0
- package/dist/app/server.js +303 -0
- package/dist/cli-entry.d.ts +3 -0
- package/dist/cli-entry.d.ts.map +1 -0
- package/dist/cli-entry.js +14 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +305 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/lib/colors.d.ts +2 -0
- package/dist/lib/colors.d.ts.map +1 -0
- package/dist/lib/colors.js +2 -0
- package/dist/lib/config.d.ts +91 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +255 -0
- package/dist/lib/context.d.ts +93 -0
- package/dist/lib/context.d.ts.map +1 -0
- package/dist/lib/context.js +65 -0
- package/dist/lib/coverage-loader.d.ts +16 -0
- package/dist/lib/coverage-loader.d.ts.map +1 -0
- package/dist/lib/coverage-loader.js +20 -0
- package/dist/lib/coverage.d.ts +28 -0
- package/dist/lib/coverage.d.ts.map +1 -0
- package/dist/lib/coverage.js +212 -0
- package/dist/lib/executor.d.ts +4 -0
- package/dist/lib/executor.d.ts.map +1 -0
- package/dist/lib/executor.js +128 -0
- package/dist/lib/fake-timers.d.ts +6 -0
- package/dist/lib/fake-timers.d.ts.map +1 -0
- package/dist/lib/fake-timers.js +45 -0
- package/dist/lib/framework.d.ts +107 -0
- package/dist/lib/framework.d.ts.map +1 -0
- package/dist/lib/framework.js +198 -0
- package/dist/lib/import-module.d.ts +2 -0
- package/dist/lib/import-module.d.ts.map +1 -0
- package/dist/lib/import-module.js +29 -0
- package/dist/lib/mock.d.ts +52 -0
- package/dist/lib/mock.d.ts.map +1 -0
- package/dist/lib/mock.js +61 -0
- package/dist/lib/normalize.d.ts +2 -0
- package/dist/lib/normalize.d.ts.map +1 -0
- package/dist/lib/normalize.js +18 -0
- package/dist/lib/playwright.d.ts +15 -0
- package/dist/lib/playwright.d.ts.map +1 -0
- package/dist/lib/playwright.js +81 -0
- package/dist/lib/reporters/dot.d.ts +9 -0
- package/dist/lib/reporters/dot.d.ts.map +1 -0
- package/dist/lib/reporters/dot.js +56 -0
- package/dist/lib/reporters/files.d.ts +9 -0
- package/dist/lib/reporters/files.d.ts.map +1 -0
- package/dist/lib/reporters/files.js +71 -0
- package/dist/lib/reporters/index.d.ts +13 -0
- package/dist/lib/reporters/index.d.ts.map +1 -0
- package/dist/lib/reporters/index.js +18 -0
- package/dist/lib/reporters/results.d.ts +30 -0
- package/dist/lib/reporters/results.d.ts.map +1 -0
- package/dist/lib/reporters/results.js +1 -0
- package/dist/lib/reporters/spec.d.ts +9 -0
- package/dist/lib/reporters/spec.d.ts.map +1 -0
- package/dist/lib/reporters/spec.js +153 -0
- package/dist/lib/reporters/tap.d.ts +9 -0
- package/dist/lib/reporters/tap.d.ts.map +1 -0
- package/dist/lib/reporters/tap.js +54 -0
- package/dist/lib/runner-browser.d.ts +21 -0
- package/dist/lib/runner-browser.d.ts.map +1 -0
- package/dist/lib/runner-browser.js +117 -0
- package/dist/lib/runner.d.ts +14 -0
- package/dist/lib/runner.d.ts.map +1 -0
- package/dist/lib/runner.js +118 -0
- package/dist/lib/runtime.d.ts +2 -0
- package/dist/lib/runtime.d.ts.map +1 -0
- package/dist/lib/runtime.js +2 -0
- package/dist/lib/ts-transform.d.ts +4 -0
- package/dist/lib/ts-transform.d.ts.map +1 -0
- package/dist/lib/ts-transform.js +29 -0
- package/dist/lib/watcher.d.ts +5 -0
- package/dist/lib/watcher.d.ts.map +1 -0
- package/dist/lib/watcher.js +39 -0
- package/dist/lib/worker-e2e.d.ts +2 -0
- package/dist/lib/worker-e2e.d.ts.map +1 -0
- package/dist/lib/worker-e2e.js +49 -0
- package/dist/lib/worker.d.ts +2 -0
- package/dist/lib/worker.d.ts.map +1 -0
- package/dist/lib/worker.js +57 -0
- package/dist/test/coverage/fixture.d.ts +5 -0
- package/dist/test/coverage/fixture.d.ts.map +1 -0
- package/dist/test/coverage/fixture.js +32 -0
- package/dist/test/coverage/test-browser.d.ts +2 -0
- package/dist/test/coverage/test-browser.d.ts.map +1 -0
- package/dist/test/coverage/test-browser.js +24 -0
- package/dist/test/coverage/test-e2e.d.ts +2 -0
- package/dist/test/coverage/test-e2e.d.ts.map +1 -0
- package/dist/test/coverage/test-e2e.js +60 -0
- package/dist/test/coverage/test-unit.d.ts +2 -0
- package/dist/test/coverage/test-unit.d.ts.map +1 -0
- package/dist/test/coverage/test-unit.js +27 -0
- package/dist/test/framework.test.browser.d.ts +2 -0
- package/dist/test/framework.test.browser.d.ts.map +1 -0
- package/dist/test/framework.test.browser.js +107 -0
- package/dist/test/framework.test.e2e.d.ts +2 -0
- package/dist/test/framework.test.e2e.d.ts.map +1 -0
- package/dist/test/framework.test.e2e.js +34 -0
- package/package.json +79 -5
- package/src/app/client/entry.ts +353 -0
- package/src/app/client/iframe.ts +18 -0
- package/src/app/server.ts +336 -0
- package/src/cli-entry.ts +15 -0
- package/src/cli.ts +384 -0
- package/src/index.ts +16 -0
- package/src/lib/colors.ts +3 -0
- package/src/lib/config.ts +377 -0
- package/src/lib/context.ts +168 -0
- package/src/lib/coverage-loader.ts +31 -0
- package/src/lib/coverage.ts +320 -0
- package/src/lib/executor.ts +145 -0
- package/src/lib/fake-timers.ts +64 -0
- package/src/lib/framework.ts +251 -0
- package/src/lib/import-module.ts +29 -0
- package/src/lib/mock.ts +89 -0
- package/src/lib/normalize.ts +22 -0
- package/src/lib/playwright.ts +100 -0
- package/src/lib/reporters/dot.ts +58 -0
- package/src/lib/reporters/files.ts +77 -0
- package/src/lib/reporters/index.ts +27 -0
- package/src/lib/reporters/results.ts +29 -0
- package/src/lib/reporters/spec.ts +174 -0
- package/src/lib/reporters/tap.ts +58 -0
- package/src/lib/runner-browser.ts +165 -0
- package/src/lib/runner.ts +189 -0
- package/src/lib/runtime.ts +2 -0
- package/src/lib/ts-transform.ts +36 -0
- package/src/lib/watcher.ts +46 -0
- package/src/lib/worker-e2e.ts +54 -0
- package/src/lib/worker.ts +50 -0
- package/src/test/coverage/fixture.ts +34 -0
- package/src/test/coverage/test-browser.ts +29 -0
- package/src/test/coverage/test-e2e.ts +70 -0
- package/src/test/coverage/test-unit.ts +32 -0
- 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, '&')
|
|
325
|
+
.replace(/</g, '<')
|
|
326
|
+
.replace(/>/g, '>')
|
|
327
|
+
.replace(/"/g, '"')
|
|
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
|
+
}
|