@remix-run/test 0.3.0 → 0.4.1
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 +4 -11
- package/dist/app/server.d.ts.map +1 -1
- package/dist/app/server.js +10 -10
- package/dist/cli.d.ts +30 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +19 -0
- package/dist/lib/config.d.ts +20 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +9 -0
- package/dist/lib/context.d.ts +5 -5
- package/dist/lib/coverage-loader.js +2 -2
- package/dist/lib/coverage.js +1 -1
- package/dist/lib/fake-timers.d.ts +32 -0
- package/dist/lib/fake-timers.d.ts.map +1 -1
- package/dist/lib/framework.d.ts +12 -6
- package/dist/lib/framework.d.ts.map +1 -1
- package/dist/lib/framework.js +24 -12
- package/dist/lib/import-module.d.ts.map +1 -1
- package/dist/lib/import-module.js +5 -4
- package/dist/lib/reporters/results.d.ts +1 -1
- package/dist/lib/reporters/results.d.ts.map +1 -1
- package/dist/lib/runner-browser.d.ts.map +1 -1
- package/dist/lib/runner-browser.js +40 -8
- package/dist/lib/worker-server.js +7 -8
- package/dist/test/framework.test.browser.js +2 -2
- package/package.json +6 -6
- package/src/app/server.ts +11 -10
- package/src/cli.ts +30 -0
- package/src/lib/config.ts +20 -0
- package/src/lib/context.ts +5 -5
- package/src/lib/coverage-loader.ts +2 -2
- package/src/lib/coverage.ts +1 -1
- package/src/lib/fake-timers.ts +32 -0
- package/src/lib/framework.ts +53 -36
- package/src/lib/import-module.ts +5 -4
- package/src/lib/reporters/results.ts +1 -1
- package/src/lib/runner-browser.ts +46 -8
- package/src/lib/ts-transform.ts +1 -1
- package/src/lib/worker-server.ts +8 -8
- package/tsconfig.json +6 -3
|
@@ -16,11 +16,9 @@ export async function runServerTestFile(value) {
|
|
|
16
16
|
let workerData;
|
|
17
17
|
try {
|
|
18
18
|
workerData = parseServerTestWorkerData(value);
|
|
19
|
-
// When coverage is enabled in Node, we use a coverage-friendly TypeScript loader
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// before the inherited tsx hook (hooks are LIFO), so it intercepts .ts imports and
|
|
23
|
-
// short-circuits before tsx transforms them.
|
|
19
|
+
// When coverage is enabled in Node, we use a coverage-friendly TypeScript loader with
|
|
20
|
+
// an un-minified esbuild transform so V8 coverage byte offsets align with readable
|
|
21
|
+
// source lines.
|
|
24
22
|
if (workerData.coverage && !IS_BUN) {
|
|
25
23
|
// Ensure we load the right file whether we're running in the monorepo (TS) or
|
|
26
24
|
// from a published package (JS)
|
|
@@ -35,14 +33,15 @@ export async function runServerTestFile(value) {
|
|
|
35
33
|
await takeCoverage(workerData.coverage);
|
|
36
34
|
return results;
|
|
37
35
|
}
|
|
38
|
-
catch (
|
|
36
|
+
catch (error) {
|
|
37
|
+
let failure = error;
|
|
39
38
|
try {
|
|
40
39
|
await takeCoverage(workerData?.coverage);
|
|
41
40
|
}
|
|
42
41
|
catch (coverageError) {
|
|
43
|
-
|
|
42
|
+
failure = coverageError;
|
|
44
43
|
}
|
|
45
|
-
return createFailedResults(
|
|
44
|
+
return createFailedResults(failure);
|
|
46
45
|
}
|
|
47
46
|
}
|
|
48
47
|
function parseServerTestWorkerData(value) {
|
|
@@ -56,8 +56,8 @@ describe('Counter', () => {
|
|
|
56
56
|
});
|
|
57
57
|
describe('FieldLabel (using decamelize)', () => {
|
|
58
58
|
// Demonstrates that ESM third-party libraries are importable from test modules
|
|
59
|
-
function FieldLabel(
|
|
60
|
-
return (
|
|
59
|
+
function FieldLabel(handle) {
|
|
60
|
+
return () => (_jsx("span", { "data-testid": "label", children: decamelize(handle.props.name, { separator: ' ' }) }));
|
|
61
61
|
}
|
|
62
62
|
it('renders a single word unchanged', (t) => {
|
|
63
63
|
let { $, cleanup } = render(_jsx(FieldLabel, { name: "name" }));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remix-run/test",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "A test framework for JavaScript and TypeScript projects",
|
|
5
5
|
"author": "Shopify Inc.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,9 +45,9 @@
|
|
|
45
45
|
"istanbul-reports": "^3.2.0",
|
|
46
46
|
"magic-string": "^0.30.21",
|
|
47
47
|
"source-map-js": "^1.2.1",
|
|
48
|
-
"tsx": "^4.21.0",
|
|
49
48
|
"v8-to-istanbul": "^9.3.0",
|
|
50
|
-
"@remix-run/
|
|
49
|
+
"@remix-run/node-tsx": "^0.1.1",
|
|
50
|
+
"@remix-run/terminal": "^0.1.1"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
53
|
"playwright": "^1.59.0"
|
|
@@ -66,9 +66,9 @@
|
|
|
66
66
|
"@typescript/native-preview": "7.0.0-dev.20251125.1",
|
|
67
67
|
"decamelize": "^6.0.1",
|
|
68
68
|
"playwright": "^1.59.0",
|
|
69
|
-
"@remix-run/
|
|
70
|
-
"@remix-run/
|
|
71
|
-
"@remix-run/
|
|
69
|
+
"@remix-run/assert": "^0.2.1",
|
|
70
|
+
"@remix-run/ui": "^0.2.0",
|
|
71
|
+
"@remix-run/node-fetch-server": "^0.13.3"
|
|
72
72
|
},
|
|
73
73
|
"keywords": [
|
|
74
74
|
"testing",
|
package/src/app/server.ts
CHANGED
|
@@ -9,6 +9,9 @@ import { SourceMapConsumer, SourceMapGenerator } from 'source-map-js'
|
|
|
9
9
|
import { getBrowserTestRootDir, IS_RUNNING_FROM_SRC } from '../lib/config.ts'
|
|
10
10
|
import { transformTypeScript } from '../lib/ts-transform.ts'
|
|
11
11
|
|
|
12
|
+
const log = (str: string) => console.log(`[remix:test] ${str}`)
|
|
13
|
+
const logError = (str: string, e: unknown) => console.error(`[remix:test] Error: ${str}\n`, e)
|
|
14
|
+
|
|
12
15
|
export async function startServer(
|
|
13
16
|
browserFiles: string[],
|
|
14
17
|
): Promise<{ server: http.Server; port: number }> {
|
|
@@ -20,7 +23,7 @@ export async function startServer(
|
|
|
20
23
|
try {
|
|
21
24
|
let server = http.createServer((req, res) => {
|
|
22
25
|
handle(req, res).catch((error) => {
|
|
23
|
-
|
|
26
|
+
logError(`Unhandled error for ${req.url}`, error)
|
|
24
27
|
if (!res.headersSent) {
|
|
25
28
|
res.writeHead(500, { 'Content-Type': 'text/plain' })
|
|
26
29
|
}
|
|
@@ -31,7 +34,7 @@ export async function startServer(
|
|
|
31
34
|
server.once('error', reject)
|
|
32
35
|
server.listen(port, () => {
|
|
33
36
|
server.removeListener('error', reject)
|
|
34
|
-
|
|
37
|
+
log(`Test server running on http://localhost:${port}`)
|
|
35
38
|
resolve()
|
|
36
39
|
})
|
|
37
40
|
})
|
|
@@ -39,7 +42,7 @@ export async function startServer(
|
|
|
39
42
|
} catch (error: any) {
|
|
40
43
|
if (error.code !== 'EADDRINUSE') throw error
|
|
41
44
|
lastError = error
|
|
42
|
-
|
|
45
|
+
log(`Port ${port} is in use, trying another port...`)
|
|
43
46
|
port += 1
|
|
44
47
|
}
|
|
45
48
|
}
|
|
@@ -99,7 +102,7 @@ function createRequestHandler(
|
|
|
99
102
|
await serveScript(res, filePath, url.pathname, rootDir)
|
|
100
103
|
return
|
|
101
104
|
} catch (error) {
|
|
102
|
-
|
|
105
|
+
logError(`Error serving ${url.pathname}`, error)
|
|
103
106
|
sendText(res, 500, String(error))
|
|
104
107
|
return
|
|
105
108
|
}
|
|
@@ -151,9 +154,8 @@ async function serveScript(
|
|
|
151
154
|
let result = await transformTypeScript(source, filePath)
|
|
152
155
|
code = result.code
|
|
153
156
|
} catch (error) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
sendText(res, 500, msg)
|
|
157
|
+
logError(`Failed to transform ${urlPath}`, error)
|
|
158
|
+
sendText(res, 500, `Failed to transform ${urlPath}`)
|
|
157
159
|
return
|
|
158
160
|
}
|
|
159
161
|
} else {
|
|
@@ -163,9 +165,8 @@ async function serveScript(
|
|
|
163
165
|
try {
|
|
164
166
|
code = await rewriteImports(code, filePath, rootDir)
|
|
165
167
|
} catch (error) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
sendText(res, 500, msg)
|
|
168
|
+
logError(`Failed to rewrite imports for ${urlPath}`, error)
|
|
169
|
+
sendText(res, 500, `Failed to rewrite imports for ${urlPath}`)
|
|
169
170
|
return
|
|
170
171
|
}
|
|
171
172
|
|
package/src/cli.ts
CHANGED
|
@@ -23,8 +23,19 @@ export { getRemixTestHelpText }
|
|
|
23
23
|
const MISSING_PLAYWRIGHT_MESSAGE =
|
|
24
24
|
'Playwright is required to run browser and E2E tests. Install it with `npm i -D playwright`.'
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Options accepted by {@link runRemixTest}.
|
|
28
|
+
*/
|
|
26
29
|
export interface RunRemixTestOptions {
|
|
30
|
+
/**
|
|
31
|
+
* Argument vector to parse. When omitted, `process.argv.slice(2)` is used
|
|
32
|
+
* so the regular CLI flags work transparently.
|
|
33
|
+
*/
|
|
27
34
|
argv?: string[]
|
|
35
|
+
/**
|
|
36
|
+
* Working directory the runner resolves config and test files against
|
|
37
|
+
* (default `process.cwd()`).
|
|
38
|
+
*/
|
|
28
39
|
cwd?: string
|
|
29
40
|
}
|
|
30
41
|
|
|
@@ -37,6 +48,25 @@ interface DiscoveredTests {
|
|
|
37
48
|
e2eFiles: string[]
|
|
38
49
|
}
|
|
39
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Programmatic entry point for the `remix-test` CLI. Loads the user's
|
|
53
|
+
* {@link RemixTestConfig}, discovers test files, and runs them through the
|
|
54
|
+
* server/browser/E2E pipelines configured by the project. In watch mode the
|
|
55
|
+
* promise resolves when the user terminates the runner; otherwise it resolves
|
|
56
|
+
* once the run finishes.
|
|
57
|
+
*
|
|
58
|
+
* @param options Optional overrides for the parsed argv and working directory.
|
|
59
|
+
* @returns The exit code the host process should use (`0` on success, `1` on
|
|
60
|
+
* test failure or unrecoverable error).
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```ts
|
|
64
|
+
* import { runRemixTest } from '@remix-run/test/cli'
|
|
65
|
+
*
|
|
66
|
+
* let exitCode = await runRemixTest()
|
|
67
|
+
* process.exit(exitCode)
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
40
70
|
export async function runRemixTest(options: RunRemixTestOptions = {}): Promise<number> {
|
|
41
71
|
let argv = options.argv ?? process.argv.slice(2)
|
|
42
72
|
let cwd = await resolveCwd(options.cwd ?? process.cwd())
|
package/src/lib/config.ts
CHANGED
|
@@ -172,8 +172,19 @@ const defaultValues: ResolvedRemixTestConfig = {
|
|
|
172
172
|
watch: false,
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Worker pool used by `remix-test` to run server and E2E test files.
|
|
177
|
+
* `'forks'` (default) uses child processes for stronger isolation; `'threads'`
|
|
178
|
+
* uses worker threads for projects that prefer lower-overhead startup.
|
|
179
|
+
*/
|
|
175
180
|
export type RemixTestPool = 'forks' | 'threads'
|
|
176
181
|
|
|
182
|
+
/**
|
|
183
|
+
* User-facing configuration for the `remix-test` CLI. Every field is
|
|
184
|
+
* optional — unset fields fall back to runner defaults. The same shape can
|
|
185
|
+
* be exported from a config file (see `--config`) or passed inline to
|
|
186
|
+
* {@link runRemixTest} via the corresponding flags.
|
|
187
|
+
*/
|
|
177
188
|
export interface RemixTestConfig {
|
|
178
189
|
/**
|
|
179
190
|
* Options for controlling the playwright browser
|
|
@@ -285,6 +296,15 @@ export async function loadConfig(args: string[] = process.argv.slice(2), cwd = p
|
|
|
285
296
|
return config
|
|
286
297
|
}
|
|
287
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Returns the formatted `remix-test --help` text. Useful for embedding the
|
|
301
|
+
* runner's CLI options in higher-level tooling.
|
|
302
|
+
*
|
|
303
|
+
* @param _target Output stream the help text will be written to. Reserved
|
|
304
|
+
* for future use (e.g. width-aware formatting); currently
|
|
305
|
+
* unused.
|
|
306
|
+
* @returns The help text as a single string ready to write to a stream.
|
|
307
|
+
*/
|
|
288
308
|
export function getRemixTestHelpText(_target: NodeJS.WriteStream = process.stdout): string {
|
|
289
309
|
let lines = [
|
|
290
310
|
'Usage: remix-test [glob...] [options]',
|
package/src/lib/context.ts
CHANGED
|
@@ -15,7 +15,7 @@ export interface TestServer {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
* Test Context providing utilities for testing via remix-test
|
|
18
|
+
* Test Context providing utilities for testing via `remix-test`. The context is
|
|
19
19
|
* passed as the first argument to the {@link test}/{@link it} functions.
|
|
20
20
|
*
|
|
21
21
|
* @example
|
|
@@ -36,8 +36,8 @@ export interface TestContext {
|
|
|
36
36
|
after(fn: () => void): void
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
|
-
* Mock tracker for the current test. Mirrors the shape of Node's
|
|
40
|
-
* `t.mock`. Method mocks created
|
|
39
|
+
* Mock tracker for the current test using {@link mock}. Mirrors the shape of Node's
|
|
40
|
+
* `t.mock`. Method mocks created via `t.mock` are auto-restored on test completion.
|
|
41
41
|
*/
|
|
42
42
|
mock: {
|
|
43
43
|
/**
|
|
@@ -77,8 +77,8 @@ export interface TestContext {
|
|
|
77
77
|
/**
|
|
78
78
|
* Wires a running test server up to a Playwright page so the test can drive
|
|
79
79
|
* it. The server is closed automatically when the test ends. Pair with
|
|
80
|
-
*
|
|
81
|
-
*
|
|
80
|
+
* {@link createTestServer} from `@remix-run/node-fetch-server/test` to spin
|
|
81
|
+
* up the server.
|
|
82
82
|
*
|
|
83
83
|
* @param server - The running server the page should target
|
|
84
84
|
* @returns A `Page` whose `baseURL` is set to `server.baseUrl`.
|
|
@@ -4,8 +4,8 @@ import { transformTypeScript } from './ts-transform.ts'
|
|
|
4
4
|
|
|
5
5
|
// Custom ESM loader hook for TypeScript files.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
7
|
+
// Applies an un-minified esbuild transform that preserves line structure.
|
|
8
|
+
// This ensures V8 coverage byte offsets map
|
|
9
9
|
// cleanly to TypeScript source lines via the inline source map, giving
|
|
10
10
|
// accurate per-line coverage rather than collapsing multiple statements onto
|
|
11
11
|
// a single minified line.
|
package/src/lib/coverage.ts
CHANGED
|
@@ -236,7 +236,7 @@ export async function collectServerCoverageMap(
|
|
|
236
236
|
let { code } = await transformTypeScript(tsSource, filePath)
|
|
237
237
|
let success = await addV8EntryToCoverageMap(coverageMap, filePath, entry.functions, code)
|
|
238
238
|
if (success) converted++
|
|
239
|
-
} catch
|
|
239
|
+
} catch {
|
|
240
240
|
// Skip files that can't be converted
|
|
241
241
|
}
|
|
242
242
|
}
|
package/src/lib/fake-timers.ts
CHANGED
|
@@ -1,14 +1,46 @@
|
|
|
1
1
|
import { mock } from './mock.ts'
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Handle returned by `mock.timers.enable()` for driving fake timers during a
|
|
5
|
+
* test. While enabled, `setTimeout`, `setInterval`, `clearTimeout`,
|
|
6
|
+
* `clearInterval`, and `Date.now` use the fake clock instead of the real one;
|
|
7
|
+
* timers fire only when the test calls `advance` (or `advanceAsync`).
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* it('debounces save calls', (t) => {
|
|
12
|
+
* let timers = t.mock.timers.enable()
|
|
13
|
+
* let save = t.mock.fn()
|
|
14
|
+
* let debounced = debounce(save, 100)
|
|
15
|
+
* debounced(); debounced(); debounced()
|
|
16
|
+
* timers.advance(100)
|
|
17
|
+
* assert.equal(save.mock.calls.length, 1)
|
|
18
|
+
* })
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
3
21
|
export interface FakeTimers {
|
|
22
|
+
/**
|
|
23
|
+
* Advance the fake clock by `ms` milliseconds, synchronously firing every
|
|
24
|
+
* timer whose deadline is reached during the advance.
|
|
25
|
+
*
|
|
26
|
+
* @param ms Number of milliseconds to advance.
|
|
27
|
+
*/
|
|
4
28
|
advance(ms: number): void
|
|
5
29
|
/**
|
|
6
30
|
* Like `advance`, but yields to microtasks between each timer firing so
|
|
7
31
|
* Promise continuations (and any timers they schedule) can settle before
|
|
8
32
|
* the next firing is processed. Use this when a callback awaits work that
|
|
9
33
|
* itself depends on the fake clock.
|
|
34
|
+
*
|
|
35
|
+
* @param ms Number of milliseconds to advance.
|
|
36
|
+
* @returns A promise that resolves once all reachable timers have fired.
|
|
10
37
|
*/
|
|
11
38
|
advanceAsync(ms: number): Promise<void>
|
|
39
|
+
/**
|
|
40
|
+
* Restore the original timer functions and the real clock. Called
|
|
41
|
+
* automatically after the test finishes; may also be called early to
|
|
42
|
+
* disable fake timers mid-test.
|
|
43
|
+
*/
|
|
12
44
|
restore(): void
|
|
13
45
|
}
|
|
14
46
|
|
package/src/lib/framework.ts
CHANGED
|
@@ -57,6 +57,13 @@ function registerDescribe(
|
|
|
57
57
|
}
|
|
58
58
|
let suite: TestSuite = { name: fullName, tests: [], ...flags }
|
|
59
59
|
|
|
60
|
+
// Children inherit `skip`/`only` from their parent so that
|
|
61
|
+
// `describe.skip('parent', () => describe('child', () => it(...)))` actually
|
|
62
|
+
// skips the child's tests. The executor walks `rootSuites` as a flat list and
|
|
63
|
+
// only inspects each suite's own flag, so the propagation has to happen here.
|
|
64
|
+
if (currentSuite?.skip) suite.skip = true
|
|
65
|
+
if (currentSuite?.only) suite.only = true
|
|
66
|
+
|
|
60
67
|
// Inherit lifecycle hooks from parent suite (or root hooks if at top level)
|
|
61
68
|
let parent = currentSuite ?? rootHooks
|
|
62
69
|
if (parent.beforeEach) suite.beforeEach = parent.beforeEach
|
|
@@ -80,9 +87,20 @@ function registerDescribe(
|
|
|
80
87
|
}
|
|
81
88
|
}
|
|
82
89
|
|
|
90
|
+
// We implement this standalone so we can leverage multiple signatures through
|
|
91
|
+
// typedoc, but we need to do the `const describe = Object.assign()` thing below to
|
|
92
|
+
// get the modifiers onto the method in a typescript-aware way.
|
|
93
|
+
function describeImpl(name: string, fn: () => void): void
|
|
94
|
+
function describeImpl(name: string, meta: SuiteMeta, fn: () => void): void
|
|
95
|
+
function describeImpl(name: string, metaOrFn: SuiteMeta | (() => void), fn?: () => void): void {
|
|
96
|
+
let meta = typeof metaOrFn === 'function' ? {} : metaOrFn
|
|
97
|
+
let suiteFn = typeof metaOrFn === 'function' ? metaOrFn : fn!
|
|
98
|
+
registerDescribe(name, suiteFn, meta)
|
|
99
|
+
}
|
|
100
|
+
|
|
83
101
|
/**
|
|
84
|
-
* Groups related tests into a named suite. Suites can be nested
|
|
85
|
-
* as such
|
|
102
|
+
* Groups related tests into a named suite. Suites can be nested and will be displayed
|
|
103
|
+
* as such in reporter output. Lifecycle hooks registered inside
|
|
86
104
|
* a `describe` block apply only to tests within that block.
|
|
87
105
|
*
|
|
88
106
|
* @example
|
|
@@ -96,26 +114,20 @@ function registerDescribe(
|
|
|
96
114
|
* describe.todo('planned suite')
|
|
97
115
|
*
|
|
98
116
|
* @param name - The suite name shown in reporter output.
|
|
117
|
+
* @param meta - Suite metadata such as `skip` or `only`.
|
|
99
118
|
* @param fn - A function that registers the tests and lifecycle hooks in this suite.
|
|
100
119
|
*/
|
|
101
|
-
export const describe = Object.assign(
|
|
102
|
-
(name: string,
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
todo: (name: string) => {
|
|
111
|
-
let fullName = currentSuite ? `${currentSuite.name} > ${name}` : name
|
|
112
|
-
if (rootSuites.some((s) => s.name === fullName)) {
|
|
113
|
-
throw new Error(`Duplicate suite name: "${fullName}"`)
|
|
114
|
-
}
|
|
115
|
-
rootSuites.push({ name: fullName, tests: [], todo: true })
|
|
116
|
-
},
|
|
120
|
+
export const describe = Object.assign(describeImpl, {
|
|
121
|
+
skip: (name: string, fn: () => void) => registerDescribe(name, fn, { skip: true }),
|
|
122
|
+
only: (name: string, fn: () => void) => registerDescribe(name, fn, { only: true }),
|
|
123
|
+
todo: (name: string) => {
|
|
124
|
+
let fullName = currentSuite ? `${currentSuite.name} > ${name}` : name
|
|
125
|
+
if (rootSuites.some((s) => s.name === fullName)) {
|
|
126
|
+
throw new Error(`Duplicate suite name: "${fullName}"`)
|
|
127
|
+
}
|
|
128
|
+
rootSuites.push({ name: fullName, tests: [], todo: true })
|
|
117
129
|
},
|
|
118
|
-
)
|
|
130
|
+
})
|
|
119
131
|
|
|
120
132
|
type SuiteMeta = { skip?: boolean; only?: boolean }
|
|
121
133
|
type TestMeta = { skip?: boolean; only?: boolean }
|
|
@@ -129,6 +141,17 @@ function registerIt(name: string, fn: TestFn, flags?: { only?: boolean; skip?: b
|
|
|
129
141
|
suite.tests.push({ name, fn, suite, ...flags })
|
|
130
142
|
}
|
|
131
143
|
|
|
144
|
+
// We implement this standalone so we can leverage multiple signatures through
|
|
145
|
+
// typedoc, but we need to do the `const it = Object.assign()` thing below to
|
|
146
|
+
// get the modifiers onto the method in a typescript-aware way.
|
|
147
|
+
function itImpl(name: string, fn: TestFn): void
|
|
148
|
+
function itImpl(name: string, meta: TestMeta, fn: TestFn): void
|
|
149
|
+
function itImpl(name: string, metaOrFn: TestMeta | TestFn, fn?: TestFn): void {
|
|
150
|
+
let meta = typeof metaOrFn === 'function' ? {} : metaOrFn
|
|
151
|
+
let testFn = typeof metaOrFn === 'function' ? metaOrFn : fn!
|
|
152
|
+
registerIt(name, testFn, meta)
|
|
153
|
+
}
|
|
154
|
+
|
|
132
155
|
/**
|
|
133
156
|
* Defines a single test case. The optional `TestContext` argument `t` provides
|
|
134
157
|
* mock helpers and per-test cleanup registration.
|
|
@@ -145,26 +168,20 @@ function registerIt(name: string, fn: TestFn, flags?: { only?: boolean; skip?: b
|
|
|
145
168
|
* it.todo('coming soon')
|
|
146
169
|
*
|
|
147
170
|
* @param name - The test name shown in reporter output.
|
|
171
|
+
* @param meta - Test metadata such as `skip` or `only`.
|
|
148
172
|
* @param fn - The test body, receiving a {@link TestContext} as its first argument.
|
|
149
173
|
*/
|
|
150
|
-
export const it = Object.assign(
|
|
151
|
-
(name: string,
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
todo: (name: string) => {
|
|
160
|
-
let suite = currentSuite ?? getImplicitRootSuite()
|
|
161
|
-
if (suite.tests.some((t) => t.name === name)) {
|
|
162
|
-
throw new Error(`Duplicate test name: "${name}" in suite "${suite.name || 'Global'}"`)
|
|
163
|
-
}
|
|
164
|
-
suite.tests.push({ name, fn: () => {}, suite, todo: true })
|
|
165
|
-
},
|
|
174
|
+
export const it = Object.assign(itImpl, {
|
|
175
|
+
skip: (name: string, fn?: TestFn) => registerIt(name, fn ?? (() => {}), { skip: true }),
|
|
176
|
+
only: (name: string, fn: TestFn) => registerIt(name, fn, { only: true }),
|
|
177
|
+
todo: (name: string) => {
|
|
178
|
+
let suite = currentSuite ?? getImplicitRootSuite()
|
|
179
|
+
if (suite.tests.some((t) => t.name === name)) {
|
|
180
|
+
throw new Error(`Duplicate test name: "${name}" in suite "${suite.name || 'Global'}"`)
|
|
181
|
+
}
|
|
182
|
+
suite.tests.push({ name, fn: () => {}, suite, todo: true })
|
|
166
183
|
},
|
|
167
|
-
)
|
|
184
|
+
})
|
|
168
185
|
|
|
169
186
|
/** Alias for {@link describe}. */
|
|
170
187
|
export const suite = describe
|
package/src/lib/import-module.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import * as path from 'node:path'
|
|
2
2
|
import { pathToFileURL } from 'node:url'
|
|
3
|
-
import { tsImport } from 'tsx/esm/api'
|
|
4
3
|
import { IS_BUN } from './runtime.ts'
|
|
5
4
|
|
|
6
5
|
interface ImportMetaWithResolve extends ImportMeta {
|
|
@@ -21,8 +20,8 @@ function hasImportMetaResolve(meta: ImportMeta): meta is ImportMetaWithResolve {
|
|
|
21
20
|
export async function importModule(specifier: string, meta: ImportMeta): Promise<any> {
|
|
22
21
|
// Absolute Windows paths (`C:\foo\bar.ts`) aren't valid ESM specifiers — only
|
|
23
22
|
// `file:///C:/foo/bar.ts` URLs, relative specifiers, or POSIX absolute paths
|
|
24
|
-
// are. Convert any absolute filesystem path to its `file:` URL so
|
|
25
|
-
//
|
|
23
|
+
// are. Convert any absolute filesystem path to its `file:` URL so module
|
|
24
|
+
// loaders and `import()` accept it on every platform. POSIX absolute paths
|
|
26
25
|
// happen to work as specifiers without conversion, but going through
|
|
27
26
|
// `pathToFileURL` is safe and platform-agnostic.
|
|
28
27
|
let resolvedSpecifier = path.isAbsolute(specifier) ? pathToFileURL(specifier).href : specifier
|
|
@@ -35,5 +34,7 @@ export async function importModule(specifier: string, meta: ImportMeta): Promise
|
|
|
35
34
|
return import(meta.resolve(resolvedSpecifier, meta.url))
|
|
36
35
|
}
|
|
37
36
|
|
|
38
|
-
|
|
37
|
+
// node-tsx uses Node APIs that fail in Bun if statically imported
|
|
38
|
+
let { loadModule } = await import('@remix-run/node-tsx/load-module')
|
|
39
|
+
return loadModule(resolvedSpecifier, meta.url)
|
|
39
40
|
}
|
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
import type { Reporter } from './reporters/index.ts'
|
|
17
17
|
import type { TestResults } from './reporters/results.ts'
|
|
18
18
|
|
|
19
|
+
const BROWSER_TEST_FILE_TIMEOUT_MS = 90_000
|
|
20
|
+
|
|
19
21
|
// The harness reports each test result with `filePath` set to the
|
|
20
22
|
// `/scripts/<rel>` URL the iframe loaded. Reporters expect a real filesystem
|
|
21
23
|
// path so they can compute `path.relative(cwd, ...)` cleanly; otherwise they
|
|
@@ -61,12 +63,10 @@ export async function runBrowserTests(options: TestRunOptions): Promise<{
|
|
|
61
63
|
getPlaywrightLaunchOptions(options.playwrightUseOpts),
|
|
62
64
|
)
|
|
63
65
|
page = await browser.newPage(getPlaywrightPageOptions(options.playwrightUseOpts))
|
|
64
|
-
// Cap
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
page.setDefaultTimeout(90_000)
|
|
69
|
-
page.setDefaultNavigationTimeout(90_000)
|
|
66
|
+
// Cap individual browser operations, then separately watch for per-file
|
|
67
|
+
// progress so large suites can run longer than this without hiding hangs.
|
|
68
|
+
page.setDefaultTimeout(BROWSER_TEST_FILE_TIMEOUT_MS)
|
|
69
|
+
page.setDefaultNavigationTimeout(BROWSER_TEST_FILE_TIMEOUT_MS)
|
|
70
70
|
|
|
71
71
|
if (options.console) {
|
|
72
72
|
page.on('console', (msg) => console.log(`${colors.dim('[browser console]')} ${msg.text()}`))
|
|
@@ -84,6 +84,32 @@ export async function runBrowserTests(options: TestRunOptions): Promise<{
|
|
|
84
84
|
let totalSkipped = 0
|
|
85
85
|
let totalTodo = 0
|
|
86
86
|
let rootDir = getBrowserTestRootDir()
|
|
87
|
+
let completedFiles = 0
|
|
88
|
+
let totalFiles = options.testFiles?.length ?? 0
|
|
89
|
+
let progressTimeoutId: ReturnType<typeof setTimeout> | undefined
|
|
90
|
+
let rejectProgressTimeout: (error: Error) => void = () => {}
|
|
91
|
+
let progressTimeoutPromise = new Promise<never>((_, reject) => {
|
|
92
|
+
rejectProgressTimeout = reject
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
function clearProgressTimeout() {
|
|
96
|
+
if (progressTimeoutId !== undefined) {
|
|
97
|
+
clearTimeout(progressTimeoutId)
|
|
98
|
+
progressTimeoutId = undefined
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function resetProgressTimeout() {
|
|
103
|
+
clearProgressTimeout()
|
|
104
|
+
progressTimeoutId = setTimeout(() => {
|
|
105
|
+
let progress = totalFiles > 0 ? ` (${completedFiles}/${totalFiles} files completed)` : ''
|
|
106
|
+
rejectProgressTimeout(
|
|
107
|
+
new Error(
|
|
108
|
+
`Timed out waiting ${BROWSER_TEST_FILE_TIMEOUT_MS}ms for browser test progress${progress}`,
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
}, BROWSER_TEST_FILE_TIMEOUT_MS)
|
|
112
|
+
}
|
|
87
113
|
|
|
88
114
|
await page.route('**/file-results', async (route) => {
|
|
89
115
|
let results = route.request().postDataJSON() as TestResults
|
|
@@ -95,6 +121,8 @@ export async function runBrowserTests(options: TestRunOptions): Promise<{
|
|
|
95
121
|
totalFailed += results.failed
|
|
96
122
|
totalSkipped += results.skipped
|
|
97
123
|
totalTodo += results.todo
|
|
124
|
+
completedFiles++
|
|
125
|
+
resetProgressTimeout()
|
|
98
126
|
await route.fulfill({ status: 200 })
|
|
99
127
|
})
|
|
100
128
|
|
|
@@ -117,9 +145,19 @@ export async function runBrowserTests(options: TestRunOptions): Promise<{
|
|
|
117
145
|
|
|
118
146
|
// Prevent unhandled rejection if we fail before setting up the listener
|
|
119
147
|
errorPromise.catch(() => {})
|
|
148
|
+
progressTimeoutPromise.catch(() => {})
|
|
120
149
|
|
|
121
|
-
|
|
122
|
-
|
|
150
|
+
resetProgressTimeout()
|
|
151
|
+
try {
|
|
152
|
+
await page.goto(options.baseUrl)
|
|
153
|
+
await Promise.race([
|
|
154
|
+
page.waitForFunction('window.__testsDone', undefined, { timeout: 0 }),
|
|
155
|
+
errorPromise,
|
|
156
|
+
progressTimeoutPromise,
|
|
157
|
+
])
|
|
158
|
+
} finally {
|
|
159
|
+
clearProgressTimeout()
|
|
160
|
+
}
|
|
123
161
|
|
|
124
162
|
if (coverageEnabled) {
|
|
125
163
|
let entries = (await page.coverage.stopJSCoverage()) as unknown as V8CoverageEntry[]
|
package/src/lib/ts-transform.ts
CHANGED
package/src/lib/worker-server.ts
CHANGED
|
@@ -18,11 +18,9 @@ export async function runServerTestFile(value: unknown): Promise<TestResults> {
|
|
|
18
18
|
try {
|
|
19
19
|
workerData = parseServerTestWorkerData(value)
|
|
20
20
|
|
|
21
|
-
// When coverage is enabled in Node, we use a coverage-friendly TypeScript loader
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// before the inherited tsx hook (hooks are LIFO), so it intercepts .ts imports and
|
|
25
|
-
// short-circuits before tsx transforms them.
|
|
21
|
+
// When coverage is enabled in Node, we use a coverage-friendly TypeScript loader with
|
|
22
|
+
// an un-minified esbuild transform so V8 coverage byte offsets align with readable
|
|
23
|
+
// source lines.
|
|
26
24
|
if (workerData.coverage && !IS_BUN) {
|
|
27
25
|
// Ensure we load the right file whether we're running in the monorepo (TS) or
|
|
28
26
|
// from a published package (JS)
|
|
@@ -36,14 +34,16 @@ export async function runServerTestFile(value: unknown): Promise<TestResults> {
|
|
|
36
34
|
let results = await runTests()
|
|
37
35
|
await takeCoverage(workerData.coverage)
|
|
38
36
|
return results
|
|
39
|
-
} catch (
|
|
37
|
+
} catch (error) {
|
|
38
|
+
let failure = error
|
|
39
|
+
|
|
40
40
|
try {
|
|
41
41
|
await takeCoverage(workerData?.coverage)
|
|
42
42
|
} catch (coverageError) {
|
|
43
|
-
|
|
43
|
+
failure = coverageError
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
return createFailedResults(
|
|
46
|
+
return createFailedResults(failure)
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
package/tsconfig.json
CHANGED
|
@@ -3,14 +3,17 @@
|
|
|
3
3
|
"strict": true,
|
|
4
4
|
"lib": ["ES2024", "DOM", "DOM.Iterable"],
|
|
5
5
|
"types": ["node", "dom-navigation"],
|
|
6
|
-
"module": "
|
|
7
|
-
"moduleResolution": "
|
|
6
|
+
"module": "NodeNext",
|
|
7
|
+
"moduleResolution": "NodeNext",
|
|
8
8
|
"target": "ESNext",
|
|
9
9
|
"allowImportingTsExtensions": true,
|
|
10
10
|
"rewriteRelativeImportExtensions": true,
|
|
11
11
|
"verbatimModuleSyntax": true,
|
|
12
|
+
"erasableSyntaxOnly": true,
|
|
13
|
+
"isolatedModules": true,
|
|
12
14
|
"skipLibCheck": true,
|
|
13
15
|
"jsx": "react-jsx",
|
|
14
16
|
"jsxImportSource": "@remix-run/ui"
|
|
15
|
-
}
|
|
17
|
+
},
|
|
18
|
+
"exclude": ["dist"]
|
|
16
19
|
}
|