@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,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,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
|
+
}
|