@remix-run/test 0.2.0 → 0.3.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/README.md +39 -33
- package/dist/app/client/entry.js +4 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +68 -23
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/lib/config.d.ts +35 -21
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +73 -33
- package/dist/lib/fake-timers.d.ts +7 -0
- package/dist/lib/fake-timers.d.ts.map +1 -1
- package/dist/lib/fake-timers.js +27 -8
- package/dist/lib/import-module.d.ts.map +1 -1
- package/dist/lib/import-module.js +11 -2
- package/dist/lib/reporters/dot.d.ts.map +1 -1
- package/dist/lib/reporters/dot.js +10 -0
- package/dist/lib/reporters/files.d.ts.map +1 -1
- package/dist/lib/reporters/files.js +10 -0
- package/dist/lib/reporters/spec.d.ts.map +1 -1
- package/dist/lib/reporters/spec.js +10 -0
- package/dist/lib/reporters/tap.d.ts.map +1 -1
- package/dist/lib/reporters/tap.js +10 -0
- package/dist/lib/runner-browser.d.ts.map +1 -1
- package/dist/lib/runner-browser.js +6 -0
- package/dist/lib/runner.d.ts +18 -1
- package/dist/lib/runner.d.ts.map +1 -1
- package/dist/lib/runner.js +187 -38
- package/dist/lib/worker-e2e-file.d.ts +11 -0
- package/dist/lib/worker-e2e-file.d.ts.map +1 -0
- package/dist/lib/worker-e2e-file.js +69 -0
- package/dist/lib/worker-e2e.js +11 -47
- package/dist/lib/worker-process.d.ts +2 -0
- package/dist/lib/worker-process.d.ts.map +1 -0
- package/dist/lib/worker-process.js +55 -0
- package/dist/lib/worker-results.d.ts +3 -0
- package/dist/lib/worker-results.d.ts.map +1 -0
- package/dist/lib/worker-results.js +20 -0
- package/dist/lib/worker-server.d.ts +10 -0
- package/dist/lib/worker-server.d.ts.map +1 -0
- package/dist/lib/worker-server.js +113 -0
- package/dist/lib/worker.js +6 -55
- package/package.json +4 -4
- package/src/app/client/entry.ts +4 -0
- package/src/cli.ts +91 -28
- package/src/index.ts +1 -1
- package/src/lib/config.ts +124 -58
- package/src/lib/fake-timers.ts +33 -8
- package/src/lib/import-module.ts +12 -2
- package/src/lib/reporters/dot.ts +9 -0
- package/src/lib/reporters/files.ts +9 -0
- package/src/lib/reporters/spec.ts +9 -0
- package/src/lib/reporters/tap.ts +9 -0
- package/src/lib/runner-browser.ts +6 -0
- package/src/lib/runner.ts +253 -50
- package/src/lib/worker-e2e-file.ts +98 -0
- package/src/lib/worker-e2e.ts +14 -51
- package/src/lib/worker-process.ts +69 -0
- package/src/lib/worker-results.ts +22 -0
- package/src/lib/worker-server.ts +123 -0
- package/src/lib/worker.ts +7 -47
package/src/cli.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import * as fsp from 'node:fs/promises'
|
|
2
2
|
import type * as http from 'node:http'
|
|
3
3
|
import * as path from 'node:path'
|
|
4
|
+
import type * as browserTestRunner from './lib/runner-browser.ts'
|
|
4
5
|
import {
|
|
5
6
|
getRemixTestHelpText,
|
|
6
7
|
IS_RUNNING_FROM_SRC,
|
|
7
8
|
loadConfig,
|
|
8
9
|
type ResolvedRemixTestConfig,
|
|
9
10
|
} from './lib/config.ts'
|
|
11
|
+
import type * as playwrightSupport from './lib/playwright.ts'
|
|
10
12
|
import { generateCombinedCoverageReport } from './lib/coverage.ts'
|
|
11
|
-
import { loadPlaywrightConfig, resolveProjects } from './lib/playwright.ts'
|
|
12
13
|
import { createReporter } from './lib/reporters/index.ts'
|
|
13
|
-
import { runBrowserTests } from './lib/runner-browser.ts'
|
|
14
14
|
import { runServerTests } from './lib/runner.ts'
|
|
15
15
|
import { createWatcher } from './lib/watcher.ts'
|
|
16
16
|
import { importModule } from './lib/import-module.ts'
|
|
@@ -20,11 +20,16 @@ import { isMainThread } from 'node:worker_threads'
|
|
|
20
20
|
|
|
21
21
|
export { getRemixTestHelpText }
|
|
22
22
|
|
|
23
|
+
const MISSING_PLAYWRIGHT_MESSAGE =
|
|
24
|
+
'Playwright is required to run browser and E2E tests. Install it with `npm i -D playwright`.'
|
|
25
|
+
|
|
23
26
|
export interface RunRemixTestOptions {
|
|
24
27
|
argv?: string[]
|
|
25
28
|
cwd?: string
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
type RunBrowserTests = typeof browserTestRunner.runBrowserTests
|
|
32
|
+
|
|
28
33
|
interface DiscoveredTests {
|
|
29
34
|
files: string[]
|
|
30
35
|
serverFiles: string[]
|
|
@@ -157,11 +162,6 @@ async function runRemixTestInCwd(argv: string[], cwd: string): Promise<number> {
|
|
|
157
162
|
browserPort = result.port
|
|
158
163
|
}
|
|
159
164
|
|
|
160
|
-
let playwrightConfig =
|
|
161
|
-
config.playwrightConfig == null || typeof config.playwrightConfig === 'string'
|
|
162
|
-
? await loadPlaywrightConfig(config.playwrightConfig, cwd)
|
|
163
|
-
: config.playwrightConfig
|
|
164
|
-
|
|
165
165
|
let reporter = createReporter(config.reporter)
|
|
166
166
|
let startTime = performance.now()
|
|
167
167
|
|
|
@@ -183,6 +183,7 @@ async function runRemixTestInCwd(argv: string[], cwd: string): Promise<number> {
|
|
|
183
183
|
{
|
|
184
184
|
coverage: config.coverage,
|
|
185
185
|
cwd,
|
|
186
|
+
pool: config.pool,
|
|
186
187
|
},
|
|
187
188
|
)
|
|
188
189
|
counts.failed += serverResult.failed
|
|
@@ -194,18 +195,26 @@ async function runRemixTestInCwd(argv: string[], cwd: string): Promise<number> {
|
|
|
194
195
|
|
|
195
196
|
// Run browser/e2e tests for all browsers configured by the user
|
|
196
197
|
if (browserFiles.length > 0 || e2eFiles.length > 0) {
|
|
198
|
+
let { loadPlaywrightConfig, resolveProjects } = await importPlaywrightSupport()
|
|
199
|
+
let runBrowserTests =
|
|
200
|
+
browserFiles.length > 0 ? (await importBrowserTestRunner()).runBrowserTests : undefined
|
|
201
|
+
let playwrightConfig =
|
|
202
|
+
config.playwrightConfig == null || typeof config.playwrightConfig === 'string'
|
|
203
|
+
? await loadPlaywrightConfig(config.playwrightConfig, cwd)
|
|
204
|
+
: config.playwrightConfig
|
|
197
205
|
let projects = resolveProjects(playwrightConfig)
|
|
206
|
+
|
|
198
207
|
if (config.project) {
|
|
199
|
-
let projectNames = config.project
|
|
200
|
-
projects = projects.filter(
|
|
201
|
-
(project) => project.name && projectNames.includes(project.name),
|
|
202
|
-
)
|
|
208
|
+
let projectNames = new Set(config.project)
|
|
209
|
+
projects = projects.filter((project) => project.name && projectNames.has(project.name))
|
|
203
210
|
if (projects.length === 0) {
|
|
204
|
-
throw new Error(
|
|
211
|
+
throw new Error(
|
|
212
|
+
`No playwright projects found with name(s) "${config.project.join(', ')}"`,
|
|
213
|
+
)
|
|
205
214
|
}
|
|
206
215
|
}
|
|
207
216
|
|
|
208
|
-
let lastBrowserResult: Awaited<ReturnType<
|
|
217
|
+
let lastBrowserResult: Awaited<ReturnType<RunBrowserTests>> | null = null
|
|
209
218
|
|
|
210
219
|
for (let project of projects) {
|
|
211
220
|
reporter.onSectionStart(`\nRunning tests for project \`${project.name}\`:`)
|
|
@@ -222,7 +231,7 @@ async function runRemixTestInCwd(argv: string[], cwd: string): Promise<number> {
|
|
|
222
231
|
}
|
|
223
232
|
|
|
224
233
|
let [browserResult, e2eResult] = await Promise.all([
|
|
225
|
-
|
|
234
|
+
runBrowserTests != null
|
|
226
235
|
? runBrowserTests({
|
|
227
236
|
baseUrl: `http://localhost:${browserPort}`,
|
|
228
237
|
console: config.browser?.echo,
|
|
@@ -241,6 +250,7 @@ async function runRemixTestInCwd(argv: string[], cwd: string): Promise<number> {
|
|
|
241
250
|
projectName: project.name,
|
|
242
251
|
coverage: config.coverage,
|
|
243
252
|
cwd,
|
|
253
|
+
pool: config.pool,
|
|
244
254
|
})
|
|
245
255
|
: null,
|
|
246
256
|
])
|
|
@@ -312,6 +322,42 @@ async function runRemixTestInCwd(argv: string[], cwd: string): Promise<number> {
|
|
|
312
322
|
return await runPromise
|
|
313
323
|
}
|
|
314
324
|
|
|
325
|
+
async function importPlaywrightSupport(): Promise<typeof playwrightSupport> {
|
|
326
|
+
try {
|
|
327
|
+
return await import('./lib/playwright.ts')
|
|
328
|
+
} catch (error) {
|
|
329
|
+
throw toPlaywrightImportError(error)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function importBrowserTestRunner(): Promise<typeof browserTestRunner> {
|
|
334
|
+
try {
|
|
335
|
+
return await import('./lib/runner-browser.ts')
|
|
336
|
+
} catch (error) {
|
|
337
|
+
throw toPlaywrightImportError(error)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function toPlaywrightImportError(error: unknown): unknown {
|
|
342
|
+
return isMissingPlaywrightImport(error) ? new Error(MISSING_PLAYWRIGHT_MESSAGE) : error
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function isMissingPlaywrightImport(error: unknown): boolean {
|
|
346
|
+
if (!isRecord(error) || typeof error.message !== 'string') {
|
|
347
|
+
return false
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return (
|
|
351
|
+
(error.code === 'ERR_MODULE_NOT_FOUND' || error.code === 'MODULE_NOT_FOUND') &&
|
|
352
|
+
(error.message.includes("Cannot find package 'playwright'") ||
|
|
353
|
+
error.message.includes("Cannot find module 'playwright'"))
|
|
354
|
+
)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
358
|
+
return typeof value === 'object' && value !== null
|
|
359
|
+
}
|
|
360
|
+
|
|
315
361
|
async function resolveCwd(cwd: string): Promise<string> {
|
|
316
362
|
try {
|
|
317
363
|
return await fsp.realpath(cwd)
|
|
@@ -327,13 +373,13 @@ async function discoverTests(
|
|
|
327
373
|
let files = await findFiles(config.glob.test, config.glob.exclude, cwd)
|
|
328
374
|
|
|
329
375
|
if (files.length === 0) {
|
|
330
|
-
console.log(`No test files found matching pattern: ${config.glob.test}`)
|
|
376
|
+
console.log(`No test files found matching pattern: ${config.glob.test.join(', ')}`)
|
|
331
377
|
return null
|
|
332
378
|
}
|
|
333
379
|
|
|
334
380
|
let browserSet = new Set(await findFiles(config.glob.browser, config.glob.exclude, cwd))
|
|
335
381
|
let e2eSet = new Set(await findFiles(config.glob.e2e, config.glob.exclude, cwd))
|
|
336
|
-
let types = new Set(config.type
|
|
382
|
+
let types = new Set(config.type)
|
|
337
383
|
let browserFiles = types.has('browser') ? files.filter((f) => browserSet.has(f)) : []
|
|
338
384
|
let e2eFiles = types.has('e2e') ? files.filter((file) => e2eSet.has(file)) : []
|
|
339
385
|
let serverFiles = types.has('server')
|
|
@@ -342,7 +388,7 @@ async function discoverTests(
|
|
|
342
388
|
let totalFiles = browserFiles.length + serverFiles.length + e2eFiles.length
|
|
343
389
|
|
|
344
390
|
if (totalFiles === 0) {
|
|
345
|
-
console.log(`No test files remain after filtering for type ${config.type}`)
|
|
391
|
+
console.log(`No test files remain after filtering for type ${config.type.join(', ')}`)
|
|
346
392
|
return null
|
|
347
393
|
}
|
|
348
394
|
|
|
@@ -358,8 +404,12 @@ async function discoverTests(
|
|
|
358
404
|
}
|
|
359
405
|
}
|
|
360
406
|
|
|
361
|
-
async function findFiles(
|
|
362
|
-
|
|
407
|
+
async function findFiles(
|
|
408
|
+
patterns: string[],
|
|
409
|
+
excludePatterns: string[],
|
|
410
|
+
cwd: string,
|
|
411
|
+
): Promise<string[]> {
|
|
412
|
+
let files = new Set<string>()
|
|
363
413
|
|
|
364
414
|
if (IS_BUN) {
|
|
365
415
|
// Bun's `fs.promises.glob` follows symlinks and doesn't prune traversal
|
|
@@ -367,18 +417,31 @@ async function findFiles(pattern: string, excludePattern: string, cwd: string):
|
|
|
367
417
|
// Use Bun's native Glob, which defaults to `followSymlinks: false`.
|
|
368
418
|
// @ts-expect-error — bun module is only resolvable under the Bun runtime
|
|
369
419
|
let { Glob } = await import('bun')
|
|
370
|
-
let
|
|
371
|
-
let
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
420
|
+
let excludeGlobs = excludePatterns.map((p) => new Glob(p))
|
|
421
|
+
for (let pattern of patterns) {
|
|
422
|
+
let glob = new Glob(pattern)
|
|
423
|
+
for await (let file of glob.scan({ cwd, absolute: true })) {
|
|
424
|
+
let rel = toPosix(path.relative(cwd, file))
|
|
425
|
+
if (!excludeGlobs.some((eg: { match: (s: string) => boolean }) => eg.match(rel))) {
|
|
426
|
+
files.add(toPosix(file))
|
|
427
|
+
}
|
|
375
428
|
}
|
|
376
429
|
}
|
|
377
|
-
return files
|
|
430
|
+
return [...files]
|
|
378
431
|
}
|
|
379
432
|
|
|
380
|
-
for
|
|
381
|
-
|
|
433
|
+
for (let pattern of patterns) {
|
|
434
|
+
for await (let file of fsp.glob(pattern, { cwd, exclude: excludePatterns })) {
|
|
435
|
+
files.add(toPosix(path.resolve(cwd, file)))
|
|
436
|
+
}
|
|
382
437
|
}
|
|
383
|
-
return files
|
|
438
|
+
return [...files]
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Normalize discovered paths so set membership across the test/browser/e2e
|
|
442
|
+
// `findFiles` calls is byte-stable on every platform. Node accepts forward
|
|
443
|
+
// slashes for filesystem operations on Windows, so downstream `fs.readFile`
|
|
444
|
+
// etc. work without further conversion.
|
|
445
|
+
function toPosix(p: string): string {
|
|
446
|
+
return path.sep === '/' ? p : p.replace(/\\/g, '/')
|
|
384
447
|
}
|
package/src/index.ts
CHANGED
package/src/lib/config.ts
CHANGED
|
@@ -44,19 +44,23 @@ const cliOptions = {
|
|
|
44
44
|
},
|
|
45
45
|
'glob.browser': {
|
|
46
46
|
type: 'string',
|
|
47
|
-
|
|
47
|
+
multiple: true,
|
|
48
|
+
description: 'Glob pattern(s) for browser test files',
|
|
48
49
|
},
|
|
49
50
|
'glob.e2e': {
|
|
50
51
|
type: 'string',
|
|
51
|
-
|
|
52
|
+
multiple: true,
|
|
53
|
+
description: 'Glob pattern(s) for E2E test files',
|
|
52
54
|
},
|
|
53
55
|
'glob.exclude': {
|
|
54
56
|
type: 'string',
|
|
55
|
-
|
|
57
|
+
multiple: true,
|
|
58
|
+
description: 'Glob pattern(s) for paths to exclude from discovery',
|
|
56
59
|
},
|
|
57
60
|
'glob.test': {
|
|
58
61
|
type: 'string',
|
|
59
|
-
|
|
62
|
+
multiple: true,
|
|
63
|
+
description: 'Glob pattern(s) for all test files',
|
|
60
64
|
},
|
|
61
65
|
concurrency: {
|
|
62
66
|
type: 'string',
|
|
@@ -113,7 +117,12 @@ const cliOptions = {
|
|
|
113
117
|
project: {
|
|
114
118
|
type: 'string',
|
|
115
119
|
short: 'p',
|
|
116
|
-
|
|
120
|
+
multiple: true,
|
|
121
|
+
description: 'Filter to specific Playwright project(s)',
|
|
122
|
+
},
|
|
123
|
+
pool: {
|
|
124
|
+
type: 'string',
|
|
125
|
+
description: 'Pool used to run server and E2E test files: forks, threads (default: forks)',
|
|
117
126
|
},
|
|
118
127
|
reporter: {
|
|
119
128
|
type: 'string',
|
|
@@ -123,7 +132,8 @@ const cliOptions = {
|
|
|
123
132
|
type: {
|
|
124
133
|
type: 'string',
|
|
125
134
|
short: 't',
|
|
126
|
-
|
|
135
|
+
multiple: true,
|
|
136
|
+
description: 'Test types to run (default: server, browser, e2e)',
|
|
127
137
|
},
|
|
128
138
|
watch: {
|
|
129
139
|
type: 'boolean',
|
|
@@ -148,18 +158,21 @@ const defaultValues: ResolvedRemixTestConfig = {
|
|
|
148
158
|
functions: undefined,
|
|
149
159
|
},
|
|
150
160
|
glob: {
|
|
151
|
-
test: '**/*.test{,.e2e,.browser}.{ts,tsx}',
|
|
152
|
-
browser: '**/*.test.browser.{ts,tsx}',
|
|
153
|
-
e2e: '**/*.test.e2e.{ts,tsx}',
|
|
154
|
-
exclude: 'node_modules/**',
|
|
161
|
+
test: ['**/*.test{,.e2e,.browser}.{ts,tsx}'],
|
|
162
|
+
browser: ['**/*.test.browser.{ts,tsx}'],
|
|
163
|
+
e2e: ['**/*.test.e2e.{ts,tsx}'],
|
|
164
|
+
exclude: ['node_modules/**'],
|
|
155
165
|
},
|
|
156
|
-
|
|
157
|
-
type: 'server,browser,e2e',
|
|
158
|
-
setup: undefined,
|
|
166
|
+
pool: 'forks',
|
|
159
167
|
playwrightConfig: undefined,
|
|
160
168
|
project: undefined,
|
|
169
|
+
reporter: process.env.CI === 'true' ? 'files' : 'spec',
|
|
170
|
+
setup: undefined,
|
|
171
|
+
type: ['server', 'browser', 'e2e'],
|
|
161
172
|
watch: false,
|
|
162
|
-
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export type RemixTestPool = 'forks' | 'threads'
|
|
163
176
|
|
|
164
177
|
export interface RemixTestConfig {
|
|
165
178
|
/**
|
|
@@ -172,17 +185,18 @@ export interface RemixTestConfig {
|
|
|
172
185
|
open?: boolean
|
|
173
186
|
}
|
|
174
187
|
/**
|
|
175
|
-
* Glob patterns to identify test files
|
|
176
|
-
*
|
|
177
|
-
* - `glob.
|
|
178
|
-
* - `glob.
|
|
179
|
-
* - `glob.
|
|
188
|
+
* Glob patterns to identify test files. Each field accepts a single pattern
|
|
189
|
+
* or an array of patterns; arrays are unioned during discovery.
|
|
190
|
+
* - `glob.test`: Glob pattern(s) for all test files (--glob.test)
|
|
191
|
+
* - `glob.browser`: Glob pattern(s) for the subset of browser test files (--glob.browser)
|
|
192
|
+
* - `glob.e2e`: Glob pattern(s) for the subset of e2e test files (--glob.e2e)
|
|
193
|
+
* - `glob.exclude`: Glob pattern(s) for paths to exclude from discovery (--glob.exclude)
|
|
180
194
|
*/
|
|
181
195
|
glob?: {
|
|
182
|
-
test?: string
|
|
183
|
-
browser?: string
|
|
184
|
-
e2e?: string
|
|
185
|
-
exclude?: string
|
|
196
|
+
test?: string | string[]
|
|
197
|
+
browser?: string | string[]
|
|
198
|
+
e2e?: string | string[]
|
|
199
|
+
exclude?: string | string[]
|
|
186
200
|
}
|
|
187
201
|
/** Max number of concurrent test workers (--concurrency) */
|
|
188
202
|
concurrency?: number | string
|
|
@@ -194,8 +208,8 @@ export interface RemixTestConfig {
|
|
|
194
208
|
| boolean
|
|
195
209
|
| {
|
|
196
210
|
dir?: string
|
|
197
|
-
include?: string[]
|
|
198
|
-
exclude?: string[]
|
|
211
|
+
include?: string | string[]
|
|
212
|
+
exclude?: string | string[]
|
|
199
213
|
statements?: number | string
|
|
200
214
|
lines?: number | string
|
|
201
215
|
branches?: number | string
|
|
@@ -211,12 +225,23 @@ export interface RemixTestConfig {
|
|
|
211
225
|
* PlaywrightTestConfig object. CLI `--playwrightConfig` only accepts a file path.
|
|
212
226
|
*/
|
|
213
227
|
playwrightConfig?: string | PlaywrightTestConfig
|
|
214
|
-
/**
|
|
215
|
-
|
|
228
|
+
/**
|
|
229
|
+
* Pool used to run server and E2E test files. Forked child processes are the default,
|
|
230
|
+
* but worker threads are available for projects that prefer the previous behavior.
|
|
231
|
+
*/
|
|
232
|
+
pool?: RemixTestPool
|
|
233
|
+
/**
|
|
234
|
+
* Filter tests to specific playwright project(s) (--project). Accepts a single
|
|
235
|
+
* project name or an array of names; `--project` may be repeated on the CLI.
|
|
236
|
+
*/
|
|
237
|
+
project?: string | string[]
|
|
216
238
|
/** Test reporter (--reporter) */
|
|
217
239
|
reporter?: string
|
|
218
|
-
/**
|
|
219
|
-
|
|
240
|
+
/**
|
|
241
|
+
* Test type(s) to run (--type). Accepts a single type or an array of types;
|
|
242
|
+
* `--type` may be repeated on the CLI. Valid values: "server", "browser", "e2e".
|
|
243
|
+
*/
|
|
244
|
+
type?: string | string[]
|
|
220
245
|
/** Watch mode — re-run tests on file changes (--watch) */
|
|
221
246
|
watch?: boolean
|
|
222
247
|
}
|
|
@@ -239,16 +264,17 @@ export interface ResolvedRemixTestConfig {
|
|
|
239
264
|
}
|
|
240
265
|
| undefined
|
|
241
266
|
glob: {
|
|
242
|
-
test: string
|
|
243
|
-
browser: string
|
|
244
|
-
e2e: string
|
|
245
|
-
exclude: string
|
|
267
|
+
test: string[]
|
|
268
|
+
browser: string[]
|
|
269
|
+
e2e: string[]
|
|
270
|
+
exclude: string[]
|
|
246
271
|
}
|
|
247
272
|
playwrightConfig: string | PlaywrightTestConfig | undefined
|
|
248
|
-
project: string | undefined
|
|
273
|
+
project: string[] | undefined
|
|
249
274
|
reporter: string
|
|
275
|
+
pool: RemixTestPool
|
|
250
276
|
setup: string | undefined
|
|
251
|
-
type: string
|
|
277
|
+
type: string[]
|
|
252
278
|
watch: boolean
|
|
253
279
|
}
|
|
254
280
|
|
|
@@ -261,10 +287,10 @@ export async function loadConfig(args: string[] = process.argv.slice(2), cwd = p
|
|
|
261
287
|
|
|
262
288
|
export function getRemixTestHelpText(_target: NodeJS.WriteStream = process.stdout): string {
|
|
263
289
|
let lines = [
|
|
264
|
-
'Usage: remix-test [glob] [options]',
|
|
290
|
+
'Usage: remix-test [glob...] [options]',
|
|
265
291
|
'',
|
|
266
292
|
'Arguments:',
|
|
267
|
-
` glob Glob pattern for test files (default: "${defaultValues.glob.test}")`,
|
|
293
|
+
` glob Glob pattern(s) for test files (default: "${defaultValues.glob.test.join(', ')}")`,
|
|
268
294
|
'',
|
|
269
295
|
'Options:',
|
|
270
296
|
]
|
|
@@ -284,6 +310,19 @@ function parseCliArgs(args: string[]) {
|
|
|
284
310
|
return util.parseArgs({ args, options: cliOptions, allowPositionals: true })
|
|
285
311
|
}
|
|
286
312
|
|
|
313
|
+
function toArray<T>(value: T | readonly T[]): T[] {
|
|
314
|
+
return Array.isArray(value) ? [...value] : [value as T]
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function toCommaSeparatedArray(value: string | readonly string[]): string[] {
|
|
318
|
+
return toArray(value).flatMap((item) =>
|
|
319
|
+
item
|
|
320
|
+
.split(',')
|
|
321
|
+
.map((part) => part.trim())
|
|
322
|
+
.filter(Boolean),
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
|
|
287
326
|
function resolveConfig(
|
|
288
327
|
fileConfig: RemixTestConfig,
|
|
289
328
|
{ values: cliValues, positionals }: ReturnType<typeof parseCliArgs>,
|
|
@@ -291,14 +330,18 @@ function resolveConfig(
|
|
|
291
330
|
let fileCoverage = typeof fileConfig.coverage === 'boolean' ? {} : fileConfig.coverage || {}
|
|
292
331
|
return {
|
|
293
332
|
glob: {
|
|
294
|
-
test:
|
|
295
|
-
positionals
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
browser:
|
|
300
|
-
|
|
301
|
-
|
|
333
|
+
test: toArray(
|
|
334
|
+
positionals.length > 0
|
|
335
|
+
? positionals
|
|
336
|
+
: (cliValues['glob.test'] ?? fileConfig.glob?.test ?? defaultValues.glob.test),
|
|
337
|
+
),
|
|
338
|
+
browser: toArray(
|
|
339
|
+
cliValues['glob.browser'] ?? fileConfig.glob?.browser ?? defaultValues.glob.browser,
|
|
340
|
+
),
|
|
341
|
+
e2e: toArray(cliValues['glob.e2e'] ?? fileConfig.glob?.e2e ?? defaultValues.glob.e2e),
|
|
342
|
+
exclude: toArray(
|
|
343
|
+
cliValues['glob.exclude'] ?? fileConfig.glob?.exclude ?? defaultValues.glob.exclude,
|
|
344
|
+
),
|
|
302
345
|
},
|
|
303
346
|
browser: {
|
|
304
347
|
echo: cliValues['browser.echo'] ?? fileConfig.browser?.echo ?? defaultValues.browser.echo,
|
|
@@ -311,14 +354,20 @@ function resolveConfig(
|
|
|
311
354
|
cliValues.coverage === true || !!fileConfig.coverage
|
|
312
355
|
? {
|
|
313
356
|
dir: cliValues['coverage.dir'] ?? fileCoverage.dir ?? defaultValues.coverage!.dir,
|
|
314
|
-
include:
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
357
|
+
include: (() => {
|
|
358
|
+
let raw =
|
|
359
|
+
cliValues['coverage.include'] ??
|
|
360
|
+
fileCoverage.include ??
|
|
361
|
+
defaultValues.coverage!.include
|
|
362
|
+
return raw === undefined ? undefined : toArray(raw)
|
|
363
|
+
})(),
|
|
364
|
+
exclude: (() => {
|
|
365
|
+
let raw =
|
|
366
|
+
cliValues['coverage.exclude'] ??
|
|
367
|
+
fileCoverage.exclude ??
|
|
368
|
+
defaultValues.coverage!.exclude
|
|
369
|
+
return raw === undefined ? undefined : toArray(raw)
|
|
370
|
+
})(),
|
|
322
371
|
statements:
|
|
323
372
|
cliValues['coverage.statements'] !== undefined
|
|
324
373
|
? Number(cliValues['coverage.statements'])
|
|
@@ -348,13 +397,25 @@ function resolveConfig(
|
|
|
348
397
|
setup: cliValues.setup ?? fileConfig.setup ?? defaultValues.setup,
|
|
349
398
|
playwrightConfig:
|
|
350
399
|
cliValues.playwrightConfig ?? fileConfig.playwrightConfig ?? defaultValues.playwrightConfig,
|
|
351
|
-
|
|
400
|
+
pool: resolvePool(cliValues.pool ?? fileConfig.pool ?? defaultValues.pool),
|
|
401
|
+
project: (() => {
|
|
402
|
+
let raw = cliValues.project ?? fileConfig.project ?? defaultValues.project
|
|
403
|
+
return raw === undefined ? undefined : toCommaSeparatedArray(raw)
|
|
404
|
+
})(),
|
|
352
405
|
reporter: cliValues.reporter ?? fileConfig.reporter ?? defaultValues.reporter,
|
|
353
|
-
type: cliValues.type ?? fileConfig.type ?? defaultValues.type,
|
|
406
|
+
type: toCommaSeparatedArray(cliValues.type ?? fileConfig.type ?? defaultValues.type),
|
|
354
407
|
watch: cliValues.watch ?? fileConfig.watch ?? defaultValues.watch,
|
|
355
408
|
}
|
|
356
409
|
}
|
|
357
410
|
|
|
411
|
+
function resolvePool(value: string): RemixTestPool {
|
|
412
|
+
if (value === 'forks' || value === 'threads') {
|
|
413
|
+
return value
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
throw new Error(`Unsupported test pool "${value}". Supported pools are: forks, threads`)
|
|
417
|
+
}
|
|
418
|
+
|
|
358
419
|
async function loadConfigFile(
|
|
359
420
|
configPath: string | undefined,
|
|
360
421
|
cwd: string,
|
|
@@ -366,11 +427,16 @@ async function loadConfigFile(
|
|
|
366
427
|
for (let candidate of candidates) {
|
|
367
428
|
try {
|
|
368
429
|
await fsp.access(candidate)
|
|
369
|
-
let mod = await importModule(candidate, import.meta)
|
|
370
|
-
return mod.default ?? mod
|
|
371
430
|
} catch {
|
|
372
|
-
// not found
|
|
431
|
+
// not found — try the next candidate
|
|
432
|
+
continue
|
|
373
433
|
}
|
|
434
|
+
// The file exists; let import errors propagate rather than silently
|
|
435
|
+
// falling through to defaults — that masking is what hid "Windows
|
|
436
|
+
// absolute paths aren't valid ESM specifiers" by classifying every
|
|
437
|
+
// browser test as a server test.
|
|
438
|
+
let mod = await importModule(candidate, import.meta)
|
|
439
|
+
return mod.default ?? mod
|
|
374
440
|
}
|
|
375
441
|
|
|
376
442
|
return {}
|
package/src/lib/fake-timers.ts
CHANGED
|
@@ -2,6 +2,13 @@ import { mock } from './mock.ts'
|
|
|
2
2
|
|
|
3
3
|
export interface FakeTimers {
|
|
4
4
|
advance(ms: number): void
|
|
5
|
+
/**
|
|
6
|
+
* Like `advance`, but yields to microtasks between each timer firing so
|
|
7
|
+
* Promise continuations (and any timers they schedule) can settle before
|
|
8
|
+
* the next firing is processed. Use this when a callback awaits work that
|
|
9
|
+
* itself depends on the fake clock.
|
|
10
|
+
*/
|
|
11
|
+
advanceAsync(ms: number): Promise<void>
|
|
5
12
|
restore(): void
|
|
6
13
|
}
|
|
7
14
|
|
|
@@ -35,20 +42,38 @@ export function createFakeTimers(): FakeTimers {
|
|
|
35
42
|
cancel as unknown as typeof clearInterval,
|
|
36
43
|
)
|
|
37
44
|
|
|
45
|
+
function takeNext(targetTime: number) {
|
|
46
|
+
let next = pending.filter((t) => t.time <= targetTime).sort((a, b) => a.time - b.time)[0]
|
|
47
|
+
if (!next) return null
|
|
48
|
+
currentTime = next.time
|
|
49
|
+
pending = pending.filter((t) => t.id !== next.id)
|
|
50
|
+
// Requeue intervals before running the callback so that calling
|
|
51
|
+
// clearInterval(id) from inside the callback can cancel the next firing.
|
|
52
|
+
if (next.repeatMs !== undefined) {
|
|
53
|
+
pending.push({ ...next, time: next.time + Math.max(1, next.repeatMs) })
|
|
54
|
+
}
|
|
55
|
+
return next
|
|
56
|
+
}
|
|
57
|
+
|
|
38
58
|
return {
|
|
39
59
|
advance(ms: number) {
|
|
40
60
|
let targetTime = currentTime + ms
|
|
41
61
|
while (true) {
|
|
42
|
-
let next =
|
|
62
|
+
let next = takeNext(targetTime)
|
|
63
|
+
if (!next) break
|
|
64
|
+
next.fn()
|
|
65
|
+
}
|
|
66
|
+
currentTime = targetTime
|
|
67
|
+
},
|
|
68
|
+
async advanceAsync(ms: number) {
|
|
69
|
+
let targetTime = currentTime + ms
|
|
70
|
+
while (true) {
|
|
71
|
+
let next = takeNext(targetTime)
|
|
43
72
|
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
73
|
next.fn()
|
|
74
|
+
// Drain microtasks so Promise continuations (and any timers they
|
|
75
|
+
// schedule) can settle before we look for the next firing.
|
|
76
|
+
await Promise.resolve()
|
|
52
77
|
}
|
|
53
78
|
currentTime = targetTime
|
|
54
79
|
},
|
package/src/lib/import-module.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import * as path from 'node:path'
|
|
2
|
+
import { pathToFileURL } from 'node:url'
|
|
1
3
|
import { tsImport } from 'tsx/esm/api'
|
|
2
4
|
import { IS_BUN } from './runtime.ts'
|
|
3
5
|
|
|
@@ -17,13 +19,21 @@ function hasImportMetaResolve(meta: ImportMeta): meta is ImportMetaWithResolve {
|
|
|
17
19
|
* @returns The imported module namespace.
|
|
18
20
|
*/
|
|
19
21
|
export async function importModule(specifier: string, meta: ImportMeta): Promise<any> {
|
|
22
|
+
// Absolute Windows paths (`C:\foo\bar.ts`) aren't valid ESM specifiers — only
|
|
23
|
+
// `file:///C:/foo/bar.ts` URLs, relative specifiers, or POSIX absolute paths
|
|
24
|
+
// are. Convert any absolute filesystem path to its `file:` URL so loaders like
|
|
25
|
+
// `tsImport` and `import()` accept it on every platform. POSIX absolute paths
|
|
26
|
+
// happen to work as specifiers without conversion, but going through
|
|
27
|
+
// `pathToFileURL` is safe and platform-agnostic.
|
|
28
|
+
let resolvedSpecifier = path.isAbsolute(specifier) ? pathToFileURL(specifier).href : specifier
|
|
29
|
+
|
|
20
30
|
if (IS_BUN) {
|
|
21
31
|
if (!hasImportMetaResolve(meta)) {
|
|
22
32
|
throw new Error('importModule() requires import.meta.resolve() in Bun')
|
|
23
33
|
}
|
|
24
34
|
|
|
25
|
-
return import(meta.resolve(
|
|
35
|
+
return import(meta.resolve(resolvedSpecifier, meta.url))
|
|
26
36
|
}
|
|
27
37
|
|
|
28
|
-
return tsImport(
|
|
38
|
+
return tsImport(resolvedSpecifier, meta.url)
|
|
29
39
|
}
|
package/src/lib/reporters/dot.ts
CHANGED
|
@@ -6,10 +6,17 @@ import type { Counts, TestResult, TestResults } from './results.ts'
|
|
|
6
6
|
export class DotReporter implements Reporter {
|
|
7
7
|
#failures: { name: string; error: TestResult['error'] }[] = []
|
|
8
8
|
#dotCount = 0
|
|
9
|
+
#files = new Set<string>()
|
|
10
|
+
#suites = new Set<string>()
|
|
9
11
|
|
|
10
12
|
onSectionStart(_label: string) {}
|
|
11
13
|
|
|
12
14
|
onResult(results: TestResults, _env?: string) {
|
|
15
|
+
for (let test of results.tests) {
|
|
16
|
+
if (test.filePath) this.#files.add(test.filePath)
|
|
17
|
+
if (test.suiteName) this.#suites.add(test.suiteName)
|
|
18
|
+
}
|
|
19
|
+
|
|
13
20
|
for (let test of results.tests) {
|
|
14
21
|
if (test.status === 'passed') {
|
|
15
22
|
process.stdout.write(colors.green('.'))
|
|
@@ -47,6 +54,8 @@ export class DotReporter implements Reporter {
|
|
|
47
54
|
let { passed, failed, skipped, todo } = counts
|
|
48
55
|
let info = colors.cyan('ℹ')
|
|
49
56
|
console.log()
|
|
57
|
+
console.log(`${info} files ${this.#files.size}`)
|
|
58
|
+
console.log(`${info} suites ${this.#suites.size}`)
|
|
50
59
|
console.log(`${info} tests ${passed + failed + skipped + todo}`)
|
|
51
60
|
console.log(`${info} pass ${passed}`)
|
|
52
61
|
console.log(`${info} fail ${failed}`)
|