@remix-run/test 0.1.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 (143) hide show
  1. package/README.md +140 -35
  2. package/dist/app/client/entry.d.ts +2 -0
  3. package/dist/app/client/entry.d.ts.map +1 -0
  4. package/dist/app/client/entry.js +324 -0
  5. package/dist/app/client/iframe.d.ts +2 -0
  6. package/dist/app/client/iframe.d.ts.map +1 -0
  7. package/dist/app/client/iframe.js +22 -0
  8. package/dist/app/server.d.ts +6 -0
  9. package/dist/app/server.d.ts.map +1 -0
  10. package/dist/app/server.js +303 -0
  11. package/dist/cli-entry.d.ts +3 -0
  12. package/dist/cli-entry.d.ts.map +1 -0
  13. package/dist/cli-entry.js +14 -0
  14. package/dist/cli.d.ts +7 -2
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +273 -139
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/lib/colors.d.ts +2 -0
  20. package/dist/lib/colors.d.ts.map +1 -0
  21. package/dist/lib/colors.js +2 -0
  22. package/dist/lib/config.d.ts +32 -1
  23. package/dist/lib/config.d.ts.map +1 -1
  24. package/dist/lib/config.js +125 -22
  25. package/dist/lib/context.d.ts +37 -13
  26. package/dist/lib/context.d.ts.map +1 -1
  27. package/dist/lib/context.js +19 -3
  28. package/dist/lib/coverage-loader.d.ts +16 -0
  29. package/dist/lib/coverage-loader.d.ts.map +1 -0
  30. package/dist/lib/coverage-loader.js +20 -0
  31. package/dist/lib/coverage.d.ts +28 -0
  32. package/dist/lib/coverage.d.ts.map +1 -0
  33. package/dist/lib/coverage.js +212 -0
  34. package/dist/lib/executor.d.ts +3 -26
  35. package/dist/lib/executor.d.ts.map +1 -1
  36. package/dist/lib/executor.js +11 -6
  37. package/dist/lib/fake-timers.d.ts +6 -0
  38. package/dist/lib/fake-timers.d.ts.map +1 -0
  39. package/dist/lib/fake-timers.js +45 -0
  40. package/dist/lib/import-module.d.ts +2 -0
  41. package/dist/lib/import-module.d.ts.map +1 -0
  42. package/dist/lib/import-module.js +29 -0
  43. package/dist/lib/normalize.d.ts +2 -0
  44. package/dist/lib/normalize.d.ts.map +1 -0
  45. package/dist/lib/{utils.js → normalize.js} +0 -9
  46. package/dist/lib/playwright.d.ts +1 -1
  47. package/dist/lib/playwright.d.ts.map +1 -1
  48. package/dist/lib/playwright.js +5 -8
  49. package/dist/lib/reporters/dot.d.ts +1 -2
  50. package/dist/lib/reporters/dot.d.ts.map +1 -1
  51. package/dist/lib/reporters/dot.js +2 -1
  52. package/dist/lib/reporters/files.d.ts +1 -2
  53. package/dist/lib/reporters/files.d.ts.map +1 -1
  54. package/dist/lib/reporters/files.js +2 -1
  55. package/dist/lib/reporters/index.d.ts +4 -5
  56. package/dist/lib/reporters/index.d.ts.map +1 -1
  57. package/dist/lib/reporters/index.js +3 -3
  58. package/dist/lib/reporters/results.d.ts +30 -0
  59. package/dist/lib/reporters/results.d.ts.map +1 -0
  60. package/dist/lib/reporters/results.js +1 -0
  61. package/dist/lib/reporters/spec.d.ts +1 -2
  62. package/dist/lib/reporters/spec.d.ts.map +1 -1
  63. package/dist/lib/reporters/spec.js +2 -1
  64. package/dist/lib/reporters/tap.d.ts +1 -2
  65. package/dist/lib/reporters/tap.d.ts.map +1 -1
  66. package/dist/lib/reporters/tap.js +1 -1
  67. package/dist/lib/runner-browser.d.ts +21 -0
  68. package/dist/lib/runner-browser.d.ts.map +1 -0
  69. package/dist/lib/runner-browser.js +117 -0
  70. package/dist/lib/runner.d.ts +7 -2
  71. package/dist/lib/runner.d.ts.map +1 -1
  72. package/dist/lib/runner.js +33 -4
  73. package/dist/lib/runtime.d.ts +2 -0
  74. package/dist/lib/runtime.d.ts.map +1 -0
  75. package/dist/lib/runtime.js +2 -0
  76. package/dist/lib/ts-transform.d.ts +4 -0
  77. package/dist/lib/ts-transform.d.ts.map +1 -0
  78. package/dist/lib/ts-transform.js +29 -0
  79. package/dist/lib/worker-e2e.js +5 -4
  80. package/dist/lib/worker.js +31 -3
  81. package/dist/test/coverage/fixture.d.ts +5 -0
  82. package/dist/test/coverage/fixture.d.ts.map +1 -0
  83. package/dist/test/coverage/fixture.js +32 -0
  84. package/dist/test/coverage/test-browser.d.ts +2 -0
  85. package/dist/test/coverage/test-browser.d.ts.map +1 -0
  86. package/dist/test/coverage/test-browser.js +24 -0
  87. package/dist/test/coverage/test-e2e.d.ts +2 -0
  88. package/dist/test/coverage/test-e2e.d.ts.map +1 -0
  89. package/dist/test/coverage/test-e2e.js +60 -0
  90. package/dist/test/coverage/test-unit.d.ts +2 -0
  91. package/dist/test/coverage/test-unit.d.ts.map +1 -0
  92. package/dist/test/coverage/test-unit.js +27 -0
  93. package/dist/test/framework.test.browser.d.ts +2 -0
  94. package/dist/test/framework.test.browser.d.ts.map +1 -0
  95. package/dist/test/framework.test.browser.js +107 -0
  96. package/dist/test/framework.test.e2e.d.ts.map +1 -0
  97. package/dist/test/framework.test.e2e.js +34 -0
  98. package/package.json +30 -9
  99. package/src/app/client/entry.ts +353 -0
  100. package/src/app/client/iframe.ts +18 -0
  101. package/src/app/server.ts +336 -0
  102. package/src/cli-entry.ts +15 -0
  103. package/src/cli.ts +322 -148
  104. package/src/index.ts +1 -0
  105. package/src/lib/colors.ts +3 -0
  106. package/src/lib/config.ts +169 -23
  107. package/src/lib/context.ts +59 -17
  108. package/src/lib/coverage-loader.ts +31 -0
  109. package/src/lib/coverage.ts +320 -0
  110. package/src/lib/executor.ts +18 -35
  111. package/src/lib/fake-timers.ts +64 -0
  112. package/src/lib/import-module.ts +29 -0
  113. package/src/lib/{utils.ts → normalize.ts} +0 -18
  114. package/src/lib/playwright.ts +5 -7
  115. package/src/lib/reporters/dot.ts +3 -2
  116. package/src/lib/reporters/files.ts +3 -2
  117. package/src/lib/reporters/index.ts +4 -5
  118. package/src/lib/reporters/results.ts +29 -0
  119. package/src/lib/reporters/spec.ts +3 -2
  120. package/src/lib/reporters/tap.ts +2 -2
  121. package/src/lib/runner-browser.ts +165 -0
  122. package/src/lib/runner.ts +62 -10
  123. package/src/lib/runtime.ts +2 -0
  124. package/src/lib/ts-transform.ts +36 -0
  125. package/src/lib/worker-e2e.ts +7 -5
  126. package/src/lib/worker.ts +24 -4
  127. package/src/test/coverage/fixture.ts +34 -0
  128. package/src/test/coverage/test-browser.ts +29 -0
  129. package/src/test/coverage/test-e2e.ts +70 -0
  130. package/src/test/coverage/test-unit.ts +32 -0
  131. package/tsconfig.json +3 -1
  132. package/dist/lib/e2e-server.d.ts +0 -11
  133. package/dist/lib/e2e-server.d.ts.map +0 -1
  134. package/dist/lib/e2e-server.js +0 -15
  135. package/dist/lib/framework.test.d.ts +0 -2
  136. package/dist/lib/framework.test.d.ts.map +0 -1
  137. package/dist/lib/framework.test.e2e.d.ts.map +0 -1
  138. package/dist/lib/framework.test.e2e.js +0 -29
  139. package/dist/lib/framework.test.js +0 -283
  140. package/dist/lib/utils.d.ts +0 -16
  141. package/dist/lib/utils.d.ts.map +0 -1
  142. package/src/lib/e2e-server.ts +0 -28
  143. /package/dist/{lib → test}/framework.test.e2e.d.ts +0 -0
@@ -0,0 +1,320 @@
1
+ import type { createCoverageMap as CreateCoverageMap } from 'istanbul-lib-coverage'
2
+ import type { createContext as CreateContext } from 'istanbul-lib-report'
3
+ import type IstanbulReports from 'istanbul-reports'
4
+ import * as fsp from 'node:fs/promises'
5
+ import { createRequire } from 'node:module'
6
+ import * as path from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { colors } from './colors.ts'
9
+ import { transformTypeScript } from './ts-transform.ts'
10
+
11
+ // Istanbul packages are loaded lazily so that FORCE_COLOR can be set based on
12
+ // the actual TTY state before supports-color caches its detection result.
13
+ let _istanbul:
14
+ | {
15
+ V8ToIstanbul: any
16
+ createCoverageMap: typeof CreateCoverageMap
17
+ createContext: typeof CreateContext
18
+ reports: typeof IstanbulReports
19
+ }
20
+ | undefined
21
+
22
+ function getIstanbul() {
23
+ if (!_istanbul) {
24
+ process.env.FORCE_COLOR ??= process.stdout.isTTY ? '1' : '0'
25
+ let require = createRequire(import.meta.url)
26
+ _istanbul = {
27
+ V8ToIstanbul: require('v8-to-istanbul'),
28
+ createCoverageMap: (
29
+ require('istanbul-lib-coverage') as { createCoverageMap: typeof CreateCoverageMap }
30
+ ).createCoverageMap,
31
+ createContext: (require('istanbul-lib-report') as { createContext: typeof CreateContext })
32
+ .createContext,
33
+ reports: require('istanbul-reports') as typeof IstanbulReports,
34
+ }
35
+ }
36
+ return _istanbul
37
+ }
38
+
39
+ export interface CoverageConfig {
40
+ dir: string
41
+ include?: string[]
42
+ exclude?: string[]
43
+ statements?: number
44
+ lines?: number
45
+ branches?: number
46
+ functions?: number
47
+ }
48
+
49
+ export interface V8CoverageEntry {
50
+ url: string
51
+ source?: string
52
+ functions: Array<{
53
+ functionName: string
54
+ isBlockCoverage: boolean
55
+ ranges: Array<{ startOffset: number; endOffset: number; count: number }>
56
+ }>
57
+ }
58
+
59
+ export type CoverageMap = ReturnType<typeof CreateCoverageMap>
60
+
61
+ function matchesGlobs(filePath: string, globs: string[]): boolean {
62
+ return globs.some((glob) => path.matchesGlob(filePath, glob))
63
+ }
64
+
65
+ function filterCoverageMap(
66
+ coverageMap: CoverageMap,
67
+ cwd: string,
68
+ config: CoverageConfig,
69
+ ): CoverageMap {
70
+ let filtered = getIstanbul().createCoverageMap({})
71
+ for (let filePath of coverageMap.files()) {
72
+ let relative = path.relative(cwd, filePath)
73
+
74
+ if (config.include && config.include.length > 0) {
75
+ if (!matchesGlobs(relative, config.include)) continue
76
+ }
77
+ if (config.exclude && config.exclude.length > 0) {
78
+ if (matchesGlobs(relative, config.exclude)) continue
79
+ }
80
+ let fc = coverageMap.fileCoverageFor(filePath) as any
81
+ filtered.addFileCoverage({ ...fc.toJSON(), path: relative })
82
+ }
83
+ return filtered
84
+ }
85
+
86
+ function checkThresholds(coverageMap: CoverageMap, config: CoverageConfig): boolean {
87
+ let { statements, lines, branches, functions } = config
88
+ if (
89
+ statements === undefined &&
90
+ lines === undefined &&
91
+ branches === undefined &&
92
+ functions === undefined
93
+ )
94
+ return true
95
+
96
+ let summary = coverageMap.getCoverageSummary()
97
+ let passed = true
98
+
99
+ if (statements !== undefined) {
100
+ let pct = summary.statements.pct
101
+ if (pct < statements) {
102
+ console.error(
103
+ colors.red(
104
+ `\nError: Coverage threshold not met (statements ${pct.toFixed(2)}% < ${statements}%)`,
105
+ ),
106
+ )
107
+ passed = false
108
+ }
109
+ }
110
+ if (lines !== undefined) {
111
+ let pct = summary.lines.pct
112
+ if (pct < lines) {
113
+ console.error(
114
+ colors.red(`\nError: Coverage threshold not met (lines ${pct.toFixed(2)}% < ${lines}%)`),
115
+ )
116
+ passed = false
117
+ }
118
+ }
119
+ if (branches !== undefined) {
120
+ let pct = summary.branches.pct
121
+ if (pct < branches) {
122
+ console.error(
123
+ colors.red(
124
+ `\nError: Coverage threshold not met (branches ${pct.toFixed(2)}% < ${branches}%)`,
125
+ ),
126
+ )
127
+ passed = false
128
+ }
129
+ }
130
+ if (functions !== undefined) {
131
+ let pct = summary.functions.pct
132
+ if (pct < functions) {
133
+ console.error(
134
+ colors.red(
135
+ `\nError: Coverage threshold not met (functions ${pct.toFixed(2)}% < ${functions}%)`,
136
+ ),
137
+ )
138
+ passed = false
139
+ }
140
+ }
141
+
142
+ return passed
143
+ }
144
+
145
+ async function writeIstanbulReports(coverageMap: CoverageMap, cwd: string, outDir: string) {
146
+ await fsp.mkdir(outDir, { recursive: true })
147
+ let { createContext, reports } = getIstanbul()
148
+ let ctx = createContext({ coverageMap, dir: outDir } as any)
149
+ console.log('\nCoverage report:')
150
+ reports.create('text').execute(ctx)
151
+ reports.create('lcovonly').execute(ctx)
152
+ console.log(`\nLCOV coverage written to ${path.relative(cwd, path.join(outDir, 'lcov.info'))}`)
153
+ }
154
+
155
+ // Convert a single V8 coverage entry to Istanbul format and merge it into the
156
+ // coverage map.
157
+ //
158
+ // V8 reports byte offsets against the JS bytes it actually instrumented. When
159
+ // the entry already carries that source (Playwright's `coverage.stopJSCoverage`
160
+ // returns it on each entry, including the inline source map), we hand it
161
+ // straight to v8-to-istanbul so the offsets line up exactly. The server path
162
+ // uses Node's `NODE_V8_COVERAGE` JSON, which doesn't include source — there we
163
+ // re-derive by re-running our esbuild transform on the original TS file.
164
+ async function addV8EntryToCoverageMap(
165
+ coverageMap: CoverageMap,
166
+ filePath: string,
167
+ functions: V8CoverageEntry['functions'],
168
+ source: string,
169
+ ): Promise<boolean> {
170
+ let { V8ToIstanbul } = getIstanbul()
171
+ let converter = new V8ToIstanbul(filePath, undefined, { source })
172
+ await converter.load()
173
+ converter.applyCoverage(functions)
174
+ coverageMap.merge(converter.toIstanbul())
175
+ return true
176
+ }
177
+
178
+ function shouldExcludeFromCoverage(
179
+ filePath: string,
180
+ rootDir: string,
181
+ testFiles: Set<string>,
182
+ ): boolean {
183
+ return (
184
+ !filePath.startsWith(rootDir + path.sep) ||
185
+ filePath.includes(`${path.sep}node_modules${path.sep}`) ||
186
+ testFiles.has(filePath)
187
+ )
188
+ }
189
+
190
+ export async function collectServerCoverageMap(
191
+ coverageDataDir: string,
192
+ cwd: string,
193
+ testFiles: Set<string>,
194
+ ): Promise<CoverageMap | null> {
195
+ let { createCoverageMap } = getIstanbul()
196
+ let coverageMap = createCoverageMap({})
197
+ let converted = 0
198
+
199
+ let files: string[]
200
+ try {
201
+ files = (await fsp.readdir(coverageDataDir)).filter(
202
+ (f) => f.startsWith('coverage-') && f.endsWith('.json'),
203
+ )
204
+ } catch {
205
+ return null
206
+ }
207
+
208
+ for (let file of files) {
209
+ let data = JSON.parse(await fsp.readFile(path.join(coverageDataDir, file), 'utf-8'))
210
+ let scriptCoverages: Array<{ url: string; functions: any[] }> = data.result ?? []
211
+
212
+ for (let entry of scriptCoverages) {
213
+ if (!entry.url.startsWith('file://')) continue
214
+
215
+ let filePath: string
216
+ try {
217
+ filePath = fileURLToPath(entry.url)
218
+ } catch {
219
+ continue
220
+ }
221
+
222
+ if (
223
+ !filePath ||
224
+ !['.ts', '.tsx'].includes(path.extname(filePath)) ||
225
+ shouldExcludeFromCoverage(filePath, cwd, testFiles)
226
+ ) {
227
+ continue
228
+ }
229
+
230
+ try {
231
+ // For server unit tests, we transform the TS with a module loader and V8 tracks
232
+ // coverage using byte offsets from the transformed JS. Re-transform with the
233
+ // same `esbuild` call here so offsets align, then pass the result with its
234
+ // inline source map to v8-to-istanbul.
235
+ let tsSource = await fsp.readFile(filePath, 'utf-8')
236
+ let { code } = await transformTypeScript(tsSource, filePath)
237
+ let success = await addV8EntryToCoverageMap(coverageMap, filePath, entry.functions, code)
238
+ if (success) converted++
239
+ } catch (e) {
240
+ // Skip files that can't be converted
241
+ }
242
+ }
243
+ }
244
+
245
+ // Clean up raw V8 coverage JSON files now that we've processed them
246
+ //await Promise.all(files.map((f) => fsp.rm(path.join(coverageDataDir, f), { force: true })))
247
+
248
+ return converted > 0 ? coverageMap : null
249
+ }
250
+
251
+ export async function collectCoverageMapFromPlaywright(
252
+ entries: V8CoverageEntry[],
253
+ rootDir: string,
254
+ testFiles: Set<string>,
255
+ resolveRelativePath: (url: string) => Promise<string | null>,
256
+ ): Promise<CoverageMap | null> {
257
+ let { createCoverageMap } = getIstanbul()
258
+ let coverageMap = createCoverageMap({})
259
+ let converted = 0
260
+
261
+ for (let entry of entries) {
262
+ let filePath: string
263
+ try {
264
+ let relativePath = await resolveRelativePath(new URL(entry.url).pathname)
265
+ if (!relativePath) continue
266
+
267
+ // Ignore entries outside the root dir, entries in node_modules, and test files
268
+ filePath = path.resolve(rootDir, relativePath)
269
+ if (shouldExcludeFromCoverage(filePath, rootDir, testFiles)) {
270
+ continue
271
+ }
272
+
273
+ // Ensure file exists
274
+ await fsp.access(filePath)
275
+ } catch {
276
+ continue
277
+ }
278
+
279
+ if (!entry.source) {
280
+ throw new Error(
281
+ `Entry for ${entry.url} is missing source, cannot convert coverage. Ensure the browser launched with Playwright's JS coverage enabled.`,
282
+ )
283
+ }
284
+
285
+ try {
286
+ let success = await addV8EntryToCoverageMap(
287
+ coverageMap,
288
+ filePath,
289
+ entry.functions,
290
+ entry.source,
291
+ )
292
+ if (success) converted++
293
+ } catch {
294
+ // Skip files that can't be converted
295
+ }
296
+ }
297
+
298
+ return converted > 0 ? coverageMap : null
299
+ }
300
+
301
+ export async function generateCombinedCoverageReport(
302
+ maps: (CoverageMap | null | undefined)[],
303
+ cwd: string,
304
+ config: CoverageConfig,
305
+ ): Promise<boolean> {
306
+ let { createCoverageMap } = getIstanbul()
307
+ let combined = createCoverageMap({})
308
+ for (let map of maps) {
309
+ if (map) combined.merge(map)
310
+ }
311
+
312
+ if (combined.files().length === 0) {
313
+ console.log('No coverage data collected.')
314
+ return true
315
+ }
316
+
317
+ let filtered = filterCoverageMap(combined, cwd, config)
318
+ await writeIstanbulReports(filtered, cwd, config.dir)
319
+ return checkThresholds(filtered, config)
320
+ }
@@ -1,34 +1,12 @@
1
- import type { Browser, BrowserContextOptions } from 'playwright'
2
- import { createTestContext } from './context.ts'
3
- import type { CreateServerFunction } from './e2e-server.ts'
4
-
5
- export interface TestResult {
6
- name: string
7
- suiteName: string
8
- filePath?: string
9
- status: 'passed' | 'failed' | 'skipped' | 'todo'
10
- error?: {
11
- message: string
12
- stack?: string
13
- }
14
- duration: number
15
- }
16
-
17
- export interface TestResults {
18
- passed: number
19
- failed: number
20
- skipped: number
21
- todo: number
22
- tests: TestResult[]
23
- }
1
+ import { createTestContext, type CreateTestContextOptions } from './context.ts'
2
+ import type { V8CoverageEntry } from './coverage.ts'
3
+ import type { TestResult, TestResults } from './reporters/results.ts'
24
4
 
25
- export async function runTests(options?: {
26
- createServer?: CreateServerFunction
27
- browser?: Browser
28
- open?: boolean
29
- playwrightPageOptions?: BrowserContextOptions
30
- }): Promise<TestResults> {
5
+ export async function runTests(
6
+ options?: Omit<CreateTestContextOptions, 'addE2ECoverageEntries'>,
7
+ ): Promise<TestResults> {
31
8
  let suites = (globalThis as any).__testSuites || []
9
+ let e2eCoverageEntries: Array<{ entries: V8CoverageEntry[]; baseUrl: string }> = []
32
10
  let results: TestResults = {
33
11
  passed: 0,
34
12
  failed: 0,
@@ -107,12 +85,13 @@ export async function runTests(options?: {
107
85
  duration: 0,
108
86
  }
109
87
 
110
- let { testContext, cleanup } = createTestContext({
111
- createServer: options?.createServer,
112
- browser: options?.browser,
113
- open: options?.open,
114
- playwrightPageOptions: options?.playwrightPageOptions,
115
- })
88
+ let contextOpts: CreateTestContextOptions | undefined = options
89
+ ? {
90
+ ...options,
91
+ addE2ECoverageEntries: (e) => e2eCoverageEntries.push(e),
92
+ }
93
+ : undefined
94
+ let { testContext, cleanup } = createTestContext(contextOpts)
116
95
 
117
96
  try {
118
97
  if (suite.beforeEach) {
@@ -158,5 +137,9 @@ export async function runTests(options?: {
158
137
  // for the next test file (which reuses the same cached module instance)
159
138
  suites.length = 0
160
139
 
140
+ if (e2eCoverageEntries.length > 0) {
141
+ results.e2eBrowserCoverageEntries = e2eCoverageEntries
142
+ }
143
+
161
144
  return results
162
145
  }
@@ -0,0 +1,64 @@
1
+ import { mock } from './mock.ts'
2
+
3
+ export interface FakeTimers {
4
+ advance(ms: number): void
5
+ restore(): void
6
+ }
7
+
8
+ export function createFakeTimers(): FakeTimers {
9
+ let currentTime = 0
10
+ let nextId = 1
11
+ let pending: Array<{ id: number; fn: () => void; time: number; repeatMs?: number }> = []
12
+
13
+ function schedule(fn: () => void, delay: number, repeatMs?: number): number {
14
+ let id = nextId++
15
+ pending.push({ id, fn, time: currentTime + Math.max(0, delay), repeatMs })
16
+ return id
17
+ }
18
+
19
+ function cancel(id: number) {
20
+ pending = pending.filter((t) => t.id !== id)
21
+ }
22
+
23
+ let setTimeoutMock = mock.method(globalThis, 'setTimeout', ((fn: () => void, delay = 0) =>
24
+ schedule(fn, delay)) as unknown as typeof setTimeout)
25
+ let clearTimeoutMock = mock.method(
26
+ globalThis,
27
+ 'clearTimeout',
28
+ cancel as unknown as typeof clearTimeout,
29
+ )
30
+ let setIntervalMock = mock.method(globalThis, 'setInterval', ((fn: () => void, delay = 0) =>
31
+ schedule(fn, delay, Math.max(0, delay))) as unknown as typeof setInterval)
32
+ let clearIntervalMock = mock.method(
33
+ globalThis,
34
+ 'clearInterval',
35
+ cancel as unknown as typeof clearInterval,
36
+ )
37
+
38
+ return {
39
+ advance(ms: number) {
40
+ let targetTime = currentTime + ms
41
+ while (true) {
42
+ let next = pending.filter((t) => t.time <= targetTime).sort((a, b) => a.time - b.time)[0]
43
+ if (!next) break
44
+ currentTime = next.time
45
+ pending = pending.filter((t) => t.id !== next.id)
46
+ // Requeue intervals before running the callback so that calling
47
+ // clearInterval(id) from inside the callback can cancel the next firing.
48
+ if (next.repeatMs !== undefined) {
49
+ pending.push({ ...next, time: next.time + Math.max(1, next.repeatMs) })
50
+ }
51
+ next.fn()
52
+ }
53
+ currentTime = targetTime
54
+ },
55
+ restore() {
56
+ setTimeoutMock.mock.restore?.()
57
+ clearTimeoutMock.mock.restore?.()
58
+ setIntervalMock.mock.restore?.()
59
+ clearIntervalMock.mock.restore?.()
60
+ pending = []
61
+ currentTime = 0
62
+ },
63
+ }
64
+ }
@@ -0,0 +1,29 @@
1
+ import { tsImport } from 'tsx/esm/api'
2
+ import { IS_BUN } from './runtime.ts'
3
+
4
+ interface ImportMetaWithResolve extends ImportMeta {
5
+ resolve(specifier: string, parent?: string | URL): string
6
+ }
7
+
8
+ function hasImportMetaResolve(meta: ImportMeta): meta is ImportMetaWithResolve {
9
+ return 'resolve' in meta && typeof meta.resolve === 'function'
10
+ }
11
+
12
+ /*
13
+ * Loads a module specifier relative to the caller's module context.
14
+ *
15
+ * @param specifier The module specifier or file path to load.
16
+ * @param meta The caller's `import.meta`, used as the context for resolution.
17
+ * @returns The imported module namespace.
18
+ */
19
+ export async function importModule(specifier: string, meta: ImportMeta): Promise<any> {
20
+ if (IS_BUN) {
21
+ if (!hasImportMetaResolve(meta)) {
22
+ throw new Error('importModule() requires import.meta.resolve() in Bun')
23
+ }
24
+
25
+ return import(meta.resolve(specifier, meta.url))
26
+ }
27
+
28
+ return tsImport(specifier, meta.url)
29
+ }
@@ -1,21 +1,3 @@
1
- export type Counts = {
2
- passed: number
3
- failed: number
4
- skipped: number
5
- todo: number
6
- }
7
-
8
- const noColor = process.env.CI === 'true' || !!process.env.NO_COLOR
9
-
10
- export const colors = {
11
- reset: noColor ? '' : '\x1b[0m',
12
- dim: noColor ? (s: string) => s : (s: string) => `\x1b[2m${s}\x1b[0m`,
13
- green: noColor ? (s: string) => s : (s: string) => `\x1b[32m${s}\x1b[0m`,
14
- red: noColor ? (s: string) => s : (s: string) => `\x1b[31m${s}\x1b[0m`,
15
- cyan: noColor ? (s: string) => s : (s: string) => `\x1b[36m${s}\x1b[0m`,
16
- yellow: noColor ? (s: string) => s : (s: string) => `\x1b[2m\x1b[33m${s}\x1b[0m`,
17
- }
18
-
19
1
  function normalizeFilePath(path: string): string {
20
2
  let locSuffix = path.match(/(:\d+:\d+)$/)?.[0] || ''
21
3
  let normalized =
@@ -3,24 +3,22 @@ import * as fs from 'node:fs/promises'
3
3
  import { chromium, firefox, webkit } from 'playwright'
4
4
  import type { BrowserContextOptions, LaunchOptions } from 'playwright'
5
5
  import type { PlaywrightTestConfig } from 'playwright/test'
6
- import { tsImport } from 'tsx/esm/api'
6
+ import { importModule } from './import-module.ts'
7
7
 
8
8
  export type PlaywrightUseOpts = PlaywrightTestConfig['use']
9
9
 
10
10
  export async function loadPlaywrightConfig(
11
11
  input: string | undefined,
12
+ cwd = process.cwd(),
12
13
  ): Promise<PlaywrightTestConfig | undefined> {
13
14
  let candidates = input
14
- ? [path.resolve(process.cwd(), input)]
15
- : [
16
- path.join(process.cwd(), 'playwright.config.ts'),
17
- path.join(process.cwd(), 'playwright.config.js'),
18
- ]
15
+ ? [path.resolve(cwd, input)]
16
+ : [path.join(cwd, 'playwright.config.ts'), path.join(cwd, 'playwright.config.js')]
19
17
 
20
18
  for (let configPath of candidates) {
21
19
  try {
22
20
  await fs.access(configPath)
23
- let mod = await tsImport(configPath, { parentURL: import.meta.url })
21
+ let mod = await importModule(configPath, import.meta)
24
22
  return mod.default ?? mod
25
23
  } catch {
26
24
  // not found or failed to load — try next
@@ -1,6 +1,7 @@
1
- import { colors, normalizeLine, type Counts } from '../utils.ts'
2
- import type { TestResult, TestResults } from '../executor.ts'
1
+ import { colors } from '../colors.ts'
2
+ import { normalizeLine } from '../normalize.ts'
3
3
  import type { Reporter } from './index.ts'
4
+ import type { Counts, TestResult, TestResults } from './results.ts'
4
5
 
5
6
  export class DotReporter implements Reporter {
6
7
  #failures: { name: string; error: TestResult['error'] }[] = []
@@ -1,7 +1,8 @@
1
1
  import * as path from 'node:path'
2
- import { colors, normalizeLine, type Counts } from '../utils.ts'
3
- import type { TestResult, TestResults } from '../executor.ts'
2
+ import { colors } from '../colors.ts'
3
+ import { normalizeLine } from '../normalize.ts'
4
4
  import type { Reporter } from './index.ts'
5
+ import type { Counts, TestResult, TestResults } from './results.ts'
5
6
 
6
7
  export class FilesReporter implements Reporter {
7
8
  #failures: { suiteName: string; name: string; error: TestResult['error'] }[] = []
@@ -1,9 +1,8 @@
1
- import type { Counts } from '../utils.ts'
2
- import type { TestResults } from '../executor.ts'
3
- import { SpecReporter } from './spec.ts'
4
- import { TapReporter } from './tap.ts'
5
1
  import { DotReporter } from './dot.ts'
6
2
  import { FilesReporter } from './files.ts'
3
+ import type { Counts, TestResults } from './results.ts'
4
+ import { SpecReporter } from './spec.ts'
5
+ import { TapReporter } from './tap.ts'
7
6
 
8
7
  export interface Reporter {
9
8
  onResult(results: TestResults, env?: string): void
@@ -11,7 +10,7 @@ export interface Reporter {
11
10
  onSectionStart(label: string): void
12
11
  }
13
12
 
14
- export { SpecReporter, TapReporter, DotReporter, FilesReporter }
13
+ export { DotReporter, FilesReporter, SpecReporter, TapReporter }
15
14
 
16
15
  export function createReporter(type: string): Reporter {
17
16
  switch (type) {
@@ -0,0 +1,29 @@
1
+ import type { V8CoverageEntry } from '../coverage'
2
+
3
+ export interface TestResult {
4
+ name: string
5
+ suiteName: string
6
+ filePath?: string
7
+ status: 'passed' | 'failed' | 'skipped' | 'todo'
8
+ error?: {
9
+ message: string
10
+ stack?: string
11
+ }
12
+ duration: number
13
+ }
14
+
15
+ export interface TestResults {
16
+ passed: number
17
+ failed: number
18
+ skipped: number
19
+ todo: number
20
+ tests: TestResult[]
21
+ e2eBrowserCoverageEntries?: Array<{ entries: V8CoverageEntry[]; baseUrl: string }>
22
+ }
23
+
24
+ export type Counts = {
25
+ passed: number
26
+ failed: number
27
+ skipped: number
28
+ todo: number
29
+ }
@@ -1,6 +1,7 @@
1
- import { colors, normalizeLine, type Counts } from '../utils.ts'
2
- import type { TestResult, TestResults } from '../executor.ts'
1
+ import { colors } from '../colors.ts'
2
+ import { normalizeLine } from '../normalize.ts'
3
3
  import type { Reporter } from './index.ts'
4
+ import type { Counts, TestResult, TestResults } from './results.ts'
4
5
 
5
6
  export class SpecReporter implements Reporter {
6
7
  #failures: { suiteName: string; name: string; error: TestResult['error'] }[] = []
@@ -1,6 +1,6 @@
1
- import { normalizeLine, type Counts } from '../utils.ts'
2
- import type { TestResults } from '../executor.ts'
1
+ import { normalizeLine } from '../normalize.ts'
3
2
  import type { Reporter } from './index.ts'
3
+ import type { Counts, TestResults } from './results.ts'
4
4
 
5
5
  export class TapReporter implements Reporter {
6
6
  #counter = 0