@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.
Files changed (40) hide show
  1. package/README.md +4 -11
  2. package/dist/app/server.d.ts.map +1 -1
  3. package/dist/app/server.js +10 -10
  4. package/dist/cli.d.ts +30 -0
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/cli.js +19 -0
  7. package/dist/lib/config.d.ts +20 -0
  8. package/dist/lib/config.d.ts.map +1 -1
  9. package/dist/lib/config.js +9 -0
  10. package/dist/lib/context.d.ts +5 -5
  11. package/dist/lib/coverage-loader.js +2 -2
  12. package/dist/lib/coverage.js +1 -1
  13. package/dist/lib/fake-timers.d.ts +32 -0
  14. package/dist/lib/fake-timers.d.ts.map +1 -1
  15. package/dist/lib/framework.d.ts +12 -6
  16. package/dist/lib/framework.d.ts.map +1 -1
  17. package/dist/lib/framework.js +24 -12
  18. package/dist/lib/import-module.d.ts.map +1 -1
  19. package/dist/lib/import-module.js +5 -4
  20. package/dist/lib/reporters/results.d.ts +1 -1
  21. package/dist/lib/reporters/results.d.ts.map +1 -1
  22. package/dist/lib/runner-browser.d.ts.map +1 -1
  23. package/dist/lib/runner-browser.js +40 -8
  24. package/dist/lib/worker-server.js +7 -8
  25. package/dist/test/framework.test.browser.js +2 -2
  26. package/package.json +6 -6
  27. package/src/app/server.ts +11 -10
  28. package/src/cli.ts +30 -0
  29. package/src/lib/config.ts +20 -0
  30. package/src/lib/context.ts +5 -5
  31. package/src/lib/coverage-loader.ts +2 -2
  32. package/src/lib/coverage.ts +1 -1
  33. package/src/lib/fake-timers.ts +32 -0
  34. package/src/lib/framework.ts +53 -36
  35. package/src/lib/import-module.ts +5 -4
  36. package/src/lib/reporters/results.ts +1 -1
  37. package/src/lib/runner-browser.ts +46 -8
  38. package/src/lib/ts-transform.ts +1 -1
  39. package/src/lib/worker-server.ts +8 -8
  40. 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 which
20
- // replaces tsx's minified transformation with a non-minified esbuild transform
21
- // so V8 coverage byte offsets align with readable source lines. This hook runs
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 (e) {
36
+ catch (error) {
37
+ let failure = error;
39
38
  try {
40
39
  await takeCoverage(workerData?.coverage);
41
40
  }
42
41
  catch (coverageError) {
43
- e = coverageError;
42
+ failure = coverageError;
44
43
  }
45
- return createFailedResults(e);
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(_handle) {
60
- return (props) => (_jsx("span", { "data-testid": "label", children: decamelize(props.name, { separator: ' ' }) }));
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.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/terminal": "^0.1.0"
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/node-fetch-server": "^0.13.1",
70
- "@remix-run/assert": "^0.2.0",
71
- "@remix-run/ui": "^0.1.1"
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
- console.error(`[remix-test] Unhandled error for ${req.url}:`, error)
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
- console.log(`Test server running on http://localhost:${port}`)
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
- console.log(`Port ${port} is in use, trying another port...`)
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
- console.error(`[remix-test] Error serving ${url.pathname}:`, error)
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
- let msg = error instanceof Error ? error.message : String(error)
155
- console.error(`[remix-test] Failed to transform ${urlPath}: ${msg}`)
156
- sendText(res, 500, msg)
157
+ 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
- let msg = error instanceof Error ? error.message : String(error)
167
- console.error(`[remix-test] Failed to rewrite imports for ${urlPath}: ${msg}`)
168
- sendText(res, 500, msg)
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]',
@@ -15,7 +15,7 @@ export interface TestServer {
15
15
  }
16
16
 
17
17
  /**
18
- * Test Context providing utilities for testing via remix-test. The context is
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 here are auto-restored on test completion.
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
- * `createTestServer` from `@remix-run/node-fetch-server/test` (or any other
81
- * source of a `{ baseUrl, close }` handle) to spin up the server first.
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
- // Replaces tsx's minified transformation with an un-minified esbuild transform
8
- // that preserves line structure. This ensures V8 coverage byte offsets map
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.
@@ -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 (e) {
239
+ } catch {
240
240
  // Skip files that can't be converted
241
241
  }
242
242
  }
@@ -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
 
@@ -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 snd will be displayed
85
- * as such or joined with ` > ` in reporter output. Lifecycle hooks registered inside
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, metaOrFn: SuiteMeta | (() => void), fn?: () => void) => {
103
- let meta = typeof metaOrFn === 'function' ? {} : metaOrFn
104
- let suiteFn = typeof metaOrFn === 'function' ? metaOrFn : fn!
105
- registerDescribe(name, suiteFn, meta)
106
- },
107
- {
108
- skip: (name: string, fn: () => void) => registerDescribe(name, fn, { skip: true }),
109
- only: (name: string, fn: () => void) => registerDescribe(name, fn, { only: true }),
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, metaOrFn: TestMeta | TestFn, fn?: TestFn) => {
152
- let meta = typeof metaOrFn === 'function' ? {} : metaOrFn
153
- let testFn = typeof metaOrFn === 'function' ? metaOrFn : fn!
154
- registerIt(name, testFn, meta)
155
- },
156
- {
157
- skip: (name: string, fn?: TestFn) => registerIt(name, fn ?? (() => {}), { skip: true }),
158
- only: (name: string, fn: TestFn) => registerIt(name, fn, { only: true }),
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
@@ -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 loaders like
25
- // `tsImport` and `import()` accept it on every platform. POSIX absolute paths
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
- return tsImport(resolvedSpecifier, meta.url)
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
  }
@@ -1,4 +1,4 @@
1
- import type { V8CoverageEntry } from '../coverage'
1
+ import type { V8CoverageEntry } from '../coverage.ts'
2
2
 
3
3
  export interface TestResult {
4
4
  name: string
@@ -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 how long we'll wait for a browser-test file to signal completion.
65
- // Playwright's default is 30s; bumping to 60s buys headroom for slower
66
- // suites without letting a hung test hide forever. Plumb this through
67
- // config later if anyone needs to tune it.
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
- await page.goto(options.baseUrl)
122
- await Promise.race([page.waitForFunction('window.__testsDone'), errorPromise])
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[]
@@ -1,4 +1,4 @@
1
- import { transform, type TsconfigRaw } from 'esbuild'
1
+ import { transform } from 'esbuild'
2
2
  import { getTsconfig, type TsConfigResult } from 'get-tsconfig'
3
3
  import * as path from 'node:path'
4
4
 
@@ -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 which
22
- // replaces tsx's minified transformation with a non-minified esbuild transform
23
- // so V8 coverage byte offsets align with readable source lines. This hook runs
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 (e) {
37
+ } catch (error) {
38
+ let failure = error
39
+
40
40
  try {
41
41
  await takeCoverage(workerData?.coverage)
42
42
  } catch (coverageError) {
43
- e = coverageError
43
+ failure = coverageError
44
44
  }
45
45
 
46
- return createFailedResults(e)
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": "ES2022",
7
- "moduleResolution": "Bundler",
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
  }