@remix-run/test 0.4.1 → 0.4.2

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 CHANGED
@@ -209,8 +209,7 @@ suite('My Test Suite', () => {
209
209
 
210
210
  ### Programmatic runner
211
211
 
212
- `remix/test/cli` exports `runRemixTest()` for tools that want to run the test runner without
213
- exiting the current process:
212
+ `remix/test/cli` exports `runRemixTest()` for tools that want to run the test runner without exiting the current process:
214
213
 
215
214
  ```ts
216
215
  import { runRemixTest } from 'remix/test/cli'
@@ -221,9 +220,7 @@ let exitCode = await runRemixTest({
221
220
  })
222
221
  ```
223
222
 
224
- `runRemixTest()` returns the runner exit code. The `remix test` bin wrapper calls
225
- `process.exit()` with that code when the run finishes so open workers, browsers, or project handles
226
- cannot keep the CLI alive.
223
+ `runRemixTest()` returns the runner exit code. The `remix test` bin wrapper calls `process.exit()` with that code when the run finishes so open workers, browsers, or project handles cannot keep the CLI alive.
227
224
 
228
225
  ### Test Context
229
226
 
@@ -2,5 +2,6 @@ import * as http from 'node:http';
2
2
  export declare function startServer(browserFiles: string[]): Promise<{
3
3
  server: http.Server;
4
4
  port: number;
5
+ baseUrl: string;
5
6
  }>;
6
7
  //# sourceMappingURL=server.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/app/server.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,IAAI,MAAM,WAAW,CAAA;AAWjC,wBAAsB,WAAW,CAC/B,YAAY,EAAE,MAAM,EAAE,GACrB,OAAO,CAAC;IAAE,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAkChD"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/app/server.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,IAAI,MAAM,WAAW,CAAA;AAmBjC,wBAAsB,WAAW,CAC/B,YAAY,EAAE,MAAM,EAAE,GACrB,OAAO,CAAC;IAAE,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CAmCjE"}
@@ -2,49 +2,58 @@ import { init as initEsModuleLexer, parse as parseEsModule } from 'es-module-lex
2
2
  import MagicString from 'magic-string';
3
3
  import * as fsp from 'node:fs/promises';
4
4
  import * as http from 'node:http';
5
- import { createRequire } from 'node:module';
6
5
  import * as path from 'node:path';
7
6
  import { fileURLToPath } from 'node:url';
7
+ import { ResolverFactory } from 'oxc-resolver';
8
8
  import { SourceMapConsumer, SourceMapGenerator } from 'source-map-js';
9
9
  import { getBrowserTestRootDir, IS_RUNNING_FROM_SRC } from "../lib/config.js";
10
10
  import { transformTypeScript } from "../lib/ts-transform.js";
11
11
  const log = (str) => console.log(`[remix:test] ${str}`);
12
12
  const logError = (str, e) => console.error(`[remix:test] Error: ${str}\n`, e);
13
+ const browserResolver = new ResolverFactory({
14
+ aliasFields: [['browser']],
15
+ conditionNames: ['browser', 'import', 'module', 'default'],
16
+ extensions: ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.json'],
17
+ mainFields: ['browser', 'module', 'main'],
18
+ tsconfig: 'auto',
19
+ });
13
20
  export async function startServer(browserFiles) {
14
21
  let handle = createRequestHandler(browserFiles);
15
- let port = 44101;
16
- let lastError;
17
- for (let i = 0; i < 5; i++) {
18
- try {
19
- let server = http.createServer((req, res) => {
20
- handle(req, res).catch((error) => {
21
- logError(`Unhandled error for ${req.url}`, error);
22
- if (!res.headersSent) {
23
- res.writeHead(500, { 'Content-Type': 'text/plain' });
24
- }
25
- if (!res.writableEnded)
26
- res.end();
27
- });
28
- });
29
- await new Promise((resolve, reject) => {
30
- server.once('error', reject);
31
- server.listen(port, () => {
32
- server.removeListener('error', reject);
33
- log(`Test server running on http://localhost:${port}`);
22
+ let server = http.createServer((req, res) => {
23
+ handle(req, res).catch((error) => {
24
+ logError(`Unhandled error for ${req.url}`, error);
25
+ if (!res.headersSent) {
26
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
27
+ }
28
+ if (!res.writableEnded)
29
+ res.end();
30
+ });
31
+ });
32
+ await new Promise((resolve, reject) => {
33
+ server.once('error', reject);
34
+ server.listen(0, () => {
35
+ server.removeListener('error', reject);
36
+ resolve();
37
+ });
38
+ });
39
+ let address = server.address();
40
+ if (!isAddressInfo(address)) {
41
+ await new Promise((resolve, reject) => {
42
+ server.close((error) => {
43
+ if (error)
44
+ reject(error);
45
+ else
34
46
  resolve();
35
- });
36
47
  });
37
- return { server, port };
38
- }
39
- catch (error) {
40
- if (error.code !== 'EADDRINUSE')
41
- throw error;
42
- lastError = error;
43
- log(`Port ${port} is in use, trying another port...`);
44
- port += 1;
45
- }
48
+ });
49
+ throw new Error('Test server did not bind to a TCP port');
46
50
  }
47
- throw lastError;
51
+ let baseUrl = `http://localhost:${address.port}`;
52
+ log(`Test server running on ${baseUrl}`);
53
+ return { server, port: address.port, baseUrl };
54
+ }
55
+ function isAddressInfo(address) {
56
+ return address != null && typeof address === 'object';
48
57
  }
49
58
  function createRequestHandler(browserFiles) {
50
59
  let rootDir = getBrowserTestRootDir();
@@ -251,19 +260,11 @@ function resolveSpecifier(spec, importerFile, rootDir) {
251
260
  resolvedPath = path.resolve(path.dirname(importerFile), spec);
252
261
  }
253
262
  else {
254
- // Bare specifiers must be resolved from the importer's filesystem
255
- // location, not this module's. `import.meta.resolve(spec, parent)` looks
256
- // like the right tool but its `parent` argument is gated behind
257
- // `--experimental-import-meta-resolve` through at least Node 24 —
258
- // without the flag, the parent argument is silently ignored and
259
- // resolution happens from `import.meta.url` of the calling module. That
260
- // made bare specifiers only resolvable when they were direct deps of
261
- // `@remix-run/test` itself (so `remix/assert` failed even when the
262
- // importing package depended on `remix`). `createRequire` walks
263
- // node_modules from the importer's actual location and has been stable
264
- // since Node 12 with no flags.
265
263
  try {
266
- resolvedPath = createRequire(importerFile).resolve(spec);
264
+ let resolutionResult = browserResolver.resolveFileSync(importerFile, spec);
265
+ if (!resolutionResult.path)
266
+ return null;
267
+ resolvedPath = resolutionResult.path;
267
268
  }
268
269
  catch {
269
270
  return null;
package/dist/cli.js CHANGED
@@ -58,7 +58,7 @@ async function runRemixTestInCwd(argv, cwd) {
58
58
  let rerunTimer;
59
59
  let browserServer;
60
60
  let browserServerFilesKey;
61
- let browserPort;
61
+ let browserBaseUrl;
62
62
  let resolveRun;
63
63
  let runPromise = new Promise((resolve) => {
64
64
  resolveRun = resolve;
@@ -82,7 +82,7 @@ async function runRemixTestInCwd(argv, cwd) {
82
82
  await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
83
83
  browserServer = undefined;
84
84
  browserServerFilesKey = undefined;
85
- browserPort = undefined;
85
+ browserBaseUrl = undefined;
86
86
  };
87
87
  let queueRerun = (reason) => {
88
88
  if (!config.watch || hasExited)
@@ -135,7 +135,7 @@ async function runRemixTestInCwd(argv, cwd) {
135
135
  let result = await startServer(browserFiles);
136
136
  browserServer = result.server;
137
137
  browserServerFilesKey = browserFilesKey;
138
- browserPort = result.port;
138
+ browserBaseUrl = result.baseUrl;
139
139
  }
140
140
  let reporter = createReporter(config.reporter);
141
141
  let startTime = performance.now();
@@ -186,30 +186,33 @@ async function runRemixTestInCwd(argv, cwd) {
186
186
  project.playwrightUseOpts = { ...project.playwrightUseOpts, headless: false };
187
187
  }
188
188
  }
189
- let [browserResult, e2eResult] = await Promise.all([
190
- runBrowserTests != null
191
- ? runBrowserTests({
192
- baseUrl: `http://localhost:${browserPort}`,
193
- console: config.browser?.echo,
194
- coverage: !!config.coverage,
195
- open: config.browser?.open,
196
- playwrightUseOpts: project.playwrightUseOpts,
197
- projectName: project.name,
198
- reporter,
199
- testFiles: browserFiles,
200
- })
201
- : null,
202
- e2eFiles.length > 0
203
- ? runServerTests(e2eFiles, reporter, config.concurrency, 'e2e', {
204
- open: config.browser?.open,
205
- playwrightUseOpts: project.playwrightUseOpts,
206
- projectName: project.name,
207
- coverage: config.coverage,
208
- cwd,
209
- pool: config.pool,
210
- })
211
- : null,
212
- ]);
189
+ let browserResult = null;
190
+ if (runBrowserTests != null) {
191
+ let activeBrowserBaseUrl = browserBaseUrl;
192
+ if (activeBrowserBaseUrl == null) {
193
+ throw new Error('Browser test server was not started');
194
+ }
195
+ browserResult = await runBrowserTests({
196
+ baseUrl: activeBrowserBaseUrl,
197
+ console: config.browser?.echo,
198
+ coverage: !!config.coverage,
199
+ open: config.browser?.open,
200
+ playwrightUseOpts: project.playwrightUseOpts,
201
+ projectName: project.name,
202
+ reporter,
203
+ testFiles: browserFiles,
204
+ });
205
+ }
206
+ let e2eResult = e2eFiles.length > 0
207
+ ? await runServerTests(e2eFiles, reporter, config.concurrency, 'e2e', {
208
+ open: config.browser?.open,
209
+ playwrightUseOpts: project.playwrightUseOpts,
210
+ projectName: project.name,
211
+ coverage: config.coverage,
212
+ cwd,
213
+ pool: config.pool,
214
+ })
215
+ : null;
213
216
  counts.passed += (browserResult?.results.passed ?? 0) + (e2eResult?.passed ?? 0);
214
217
  counts.failed += (browserResult?.results.failed ?? 0) + (e2eResult?.failed ?? 0);
215
218
  counts.skipped += (browserResult?.results.skipped ?? 0) + (e2eResult?.skipped ?? 0);
@@ -1 +1 @@
1
- {"version":3,"file":"executor.d.ts","sourceRoot":"","sources":["../../src/lib/executor.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqB,KAAK,wBAAwB,EAAE,MAAM,cAAc,CAAA;AAE/E,OAAO,KAAK,EAAc,WAAW,EAAE,MAAM,wBAAwB,CAAA;AAErE,wBAAsB,QAAQ,CAC5B,OAAO,CAAC,EAAE,IAAI,CAAC,wBAAwB,EAAE,uBAAuB,CAAC,GAChE,OAAO,CAAC,WAAW,CAAC,CA0ItB"}
1
+ {"version":3,"file":"executor.d.ts","sourceRoot":"","sources":["../../src/lib/executor.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqB,KAAK,wBAAwB,EAAoB,MAAM,cAAc,CAAA;AAEjG,OAAO,KAAK,EAAc,WAAW,EAAE,MAAM,wBAAwB,CAAA;AAwBrE,wBAAsB,QAAQ,CAC5B,OAAO,CAAC,EAAE,IAAI,CAAC,wBAAwB,EAAE,uBAAuB,CAAC,GAChE,OAAO,CAAC,WAAW,CAAC,CA2JtB"}
@@ -1,6 +1,6 @@
1
1
  import { createTestContext } from "./context.js";
2
2
  export async function runTests(options) {
3
- let suites = globalThis.__testSuites || [];
3
+ let suites = getRegisteredSuites();
4
4
  let e2eCoverageEntries = [];
5
5
  let results = {
6
6
  passed: 0,
@@ -9,7 +9,7 @@ export async function runTests(options) {
9
9
  todo: 0,
10
10
  tests: [],
11
11
  };
12
- let hasOnlySuites = suites.some((s) => s.only);
12
+ let hasOnlySuites = suites.some((suite) => suite.only);
13
13
  for (let suite of suites) {
14
14
  // If any suite uses .only, skip all non-only suites
15
15
  if (hasOnlySuites && !suite.only) {
@@ -38,15 +38,17 @@ export async function runTests(options) {
38
38
  continue;
39
39
  }
40
40
  if (suite.beforeAll) {
41
+ let startTime = performance.now();
41
42
  try {
42
43
  await suite.beforeAll();
43
44
  }
44
45
  catch (error) {
45
- console.error(`beforeAll failed in suite "${suite.name}":`, error);
46
+ results.tests.push(createFailedHookResult('beforeAll', suite.name, error, performance.now() - startTime));
47
+ results.failed++;
46
48
  continue;
47
49
  }
48
50
  }
49
- let hasOnlyTests = suite.tests.some((t) => t.only);
51
+ let hasOnlyTests = suite.tests.some((test) => test.only);
50
52
  for (let test of suite.tests) {
51
53
  // If any test uses .only, skip all non-only tests in this suite
52
54
  if (hasOnlyTests && !test.only) {
@@ -72,6 +74,10 @@ export async function runTests(options) {
72
74
  status: 'passed',
73
75
  duration: 0,
74
76
  };
77
+ let testError;
78
+ let afterEachError;
79
+ let testFailed = false;
80
+ let afterEachFailed = false;
75
81
  let contextOpts = options
76
82
  ? {
77
83
  ...options,
@@ -84,16 +90,10 @@ export async function runTests(options) {
84
90
  await suite.beforeEach();
85
91
  }
86
92
  await test.fn(testContext);
87
- result.status = 'passed';
88
- results.passed++;
89
93
  }
90
94
  catch (error) {
91
- result.status = 'failed';
92
- result.error = {
93
- message: error.message || String(error),
94
- stack: error.stack,
95
- };
96
- results.failed++;
95
+ testFailed = true;
96
+ testError = error;
97
97
  }
98
98
  finally {
99
99
  await cleanup();
@@ -102,19 +102,30 @@ export async function runTests(options) {
102
102
  await suite.afterEach();
103
103
  }
104
104
  catch (error) {
105
- console.error('afterEach failed:', error);
105
+ afterEachFailed = true;
106
+ afterEachError = error;
106
107
  }
107
108
  }
109
+ if (testFailed || afterEachFailed) {
110
+ result.status = 'failed';
111
+ result.error = createTestError(testFailed ? testError : undefined, afterEachFailed ? createHookFailure('afterEach', afterEachError) : undefined);
112
+ results.failed++;
113
+ }
114
+ else {
115
+ results.passed++;
116
+ }
108
117
  result.duration = performance.now() - startTime;
109
118
  results.tests.push(result);
110
119
  }
111
120
  }
112
121
  if (suite.afterAll) {
122
+ let startTime = performance.now();
113
123
  try {
114
124
  await suite.afterAll();
115
125
  }
116
126
  catch (error) {
117
- console.error(`afterAll failed in suite "${suite.name}":`, error);
127
+ results.tests.push(createFailedHookResult('afterAll', suite.name, error, performance.now() - startTime));
128
+ results.failed++;
118
129
  }
119
130
  }
120
131
  }
@@ -126,3 +137,38 @@ export async function runTests(options) {
126
137
  }
127
138
  return results;
128
139
  }
140
+ function getRegisteredSuites() {
141
+ let global = globalThis;
142
+ return global.__testSuites ?? [];
143
+ }
144
+ function createFailedHookResult(hookName, suiteName, error, duration) {
145
+ return {
146
+ name: hookName,
147
+ suiteName,
148
+ status: 'failed',
149
+ error: createTestError(createHookFailure(hookName, error)),
150
+ duration,
151
+ };
152
+ }
153
+ function createHookFailure(hookName, error) {
154
+ let cause = error instanceof Error ? error : new Error(String(error));
155
+ return new Error(`${hookName} failed: ${cause.message}`, { cause });
156
+ }
157
+ function createTestError(primaryError, secondaryError = undefined) {
158
+ let message = primaryError !== undefined ? getErrorMessage(primaryError) : undefined;
159
+ let stack = primaryError instanceof Error ? primaryError.stack : undefined;
160
+ if (secondaryError) {
161
+ message = message ? `${message}\n${secondaryError.message}` : secondaryError.message;
162
+ stack =
163
+ stack && secondaryError.stack
164
+ ? `${stack}\n${secondaryError.stack}`
165
+ : (stack ?? secondaryError.stack);
166
+ }
167
+ return {
168
+ message: message ?? 'Test failed',
169
+ stack,
170
+ };
171
+ }
172
+ function getErrorMessage(error) {
173
+ return error instanceof Error ? error.message : String(error);
174
+ }
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "@remix-run/ui/jsx-runtime";
2
2
  import decamelize from 'decamelize';
3
+ import cx from 'clsx';
3
4
  import * as assert from '@remix-run/assert';
4
5
  import { describe, it } from '@remix-run/test';
5
6
  import { on } from '@remix-run/ui';
@@ -75,6 +76,24 @@ describe('FieldLabel (using decamelize)', () => {
75
76
  assert.equal($('[data-testid="label"]')?.textContent, 'date of birth');
76
77
  });
77
78
  });
79
+ describe('MobileMenu (using clsx)', () => {
80
+ function MobileMenu(handle) {
81
+ return () => {
82
+ let { isOpen } = handle.props;
83
+ return (_jsxs("nav", { "aria-label": "Mobile navigation", className: cx('mobile-menu', {
84
+ 'mobile-menu--open': isOpen,
85
+ 'mobile-menu--closed': !isOpen,
86
+ }), children: [
87
+ _jsx("a", { href: "/docs", children: "Docs" }), _jsx("a", { href: "/blog", children: "Blog" })
88
+ ] }));
89
+ };
90
+ }
91
+ it('resolves browser-oriented package exports for default imports', (t) => {
92
+ let { $, cleanup } = render(_jsx(MobileMenu, { isOpen: true }));
93
+ t.after(cleanup);
94
+ assert.equal($('[aria-label="Mobile navigation"]')?.className, 'mobile-menu mobile-menu--open');
95
+ });
96
+ });
78
97
  describe('DOM Tests', () => {
79
98
  it('can interact with DOM', async () => {
80
99
  let div = document.createElement('div');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remix-run/test",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "A test framework for JavaScript and TypeScript projects",
5
5
  "author": "Shopify Inc.",
6
6
  "license": "MIT",
@@ -44,13 +44,14 @@
44
44
  "istanbul-lib-report": "^3.0.1",
45
45
  "istanbul-reports": "^3.2.0",
46
46
  "magic-string": "^0.30.21",
47
+ "oxc-resolver": "^11.19.1",
47
48
  "source-map-js": "^1.2.1",
48
49
  "v8-to-istanbul": "^9.3.0",
49
50
  "@remix-run/node-tsx": "^0.1.1",
50
51
  "@remix-run/terminal": "^0.1.1"
51
52
  },
52
53
  "peerDependencies": {
53
- "playwright": "^1.59.0"
54
+ "playwright": "^1.60.0"
54
55
  },
55
56
  "peerDependenciesMeta": {
56
57
  "playwright": {
@@ -64,10 +65,11 @@
64
65
  "@types/istanbul-reports": "^3.0.4",
65
66
  "@types/node": "^24.6.0",
66
67
  "@typescript/native-preview": "7.0.0-dev.20251125.1",
67
- "decamelize": "^6.0.1",
68
- "playwright": "^1.59.0",
68
+ "clsx": "2.1.1",
69
+ "decamelize": "6.0.1",
70
+ "playwright": "^1.60.0",
69
71
  "@remix-run/assert": "^0.2.1",
70
- "@remix-run/ui": "^0.2.0",
72
+ "@remix-run/ui": "^0.3.0",
71
73
  "@remix-run/node-fetch-server": "^0.13.3"
72
74
  },
73
75
  "keywords": [
package/src/app/server.ts CHANGED
@@ -2,52 +2,65 @@ import { init as initEsModuleLexer, parse as parseEsModule } from 'es-module-lex
2
2
  import MagicString from 'magic-string'
3
3
  import * as fsp from 'node:fs/promises'
4
4
  import * as http from 'node:http'
5
- import { createRequire } from 'node:module'
5
+ import type { AddressInfo } from 'node:net'
6
6
  import * as path from 'node:path'
7
7
  import { fileURLToPath } from 'node:url'
8
+ import { ResolverFactory } from 'oxc-resolver'
8
9
  import { SourceMapConsumer, SourceMapGenerator } from 'source-map-js'
9
10
  import { getBrowserTestRootDir, IS_RUNNING_FROM_SRC } from '../lib/config.ts'
10
11
  import { transformTypeScript } from '../lib/ts-transform.ts'
11
12
 
12
13
  const log = (str: string) => console.log(`[remix:test] ${str}`)
13
14
  const logError = (str: string, e: unknown) => console.error(`[remix:test] Error: ${str}\n`, e)
15
+ const browserResolver = new ResolverFactory({
16
+ aliasFields: [['browser']],
17
+ conditionNames: ['browser', 'import', 'module', 'default'],
18
+ extensions: ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.json'],
19
+ mainFields: ['browser', 'module', 'main'],
20
+ tsconfig: 'auto',
21
+ })
14
22
 
15
23
  export async function startServer(
16
24
  browserFiles: string[],
17
- ): Promise<{ server: http.Server; port: number }> {
25
+ ): Promise<{ server: http.Server; port: number; baseUrl: string }> {
18
26
  let handle = createRequestHandler(browserFiles)
19
- let port = 44101
20
27
 
21
- let lastError: unknown
22
- for (let i = 0; i < 5; i++) {
23
- try {
24
- let server = http.createServer((req, res) => {
25
- handle(req, res).catch((error) => {
26
- logError(`Unhandled error for ${req.url}`, error)
27
- if (!res.headersSent) {
28
- res.writeHead(500, { 'Content-Type': 'text/plain' })
29
- }
30
- if (!res.writableEnded) res.end()
31
- })
32
- })
33
- await new Promise<void>((resolve, reject) => {
34
- server.once('error', reject)
35
- server.listen(port, () => {
36
- server.removeListener('error', reject)
37
- log(`Test server running on http://localhost:${port}`)
38
- resolve()
39
- })
28
+ let server = http.createServer((req, res) => {
29
+ handle(req, res).catch((error) => {
30
+ logError(`Unhandled error for ${req.url}`, error)
31
+ if (!res.headersSent) {
32
+ res.writeHead(500, { 'Content-Type': 'text/plain' })
33
+ }
34
+ if (!res.writableEnded) res.end()
35
+ })
36
+ })
37
+
38
+ await new Promise<void>((resolve, reject) => {
39
+ server.once('error', reject)
40
+ server.listen(0, () => {
41
+ server.removeListener('error', reject)
42
+ resolve()
43
+ })
44
+ })
45
+
46
+ let address = server.address()
47
+ if (!isAddressInfo(address)) {
48
+ await new Promise<void>((resolve, reject) => {
49
+ server.close((error) => {
50
+ if (error) reject(error)
51
+ else resolve()
40
52
  })
41
- return { server, port }
42
- } catch (error: any) {
43
- if (error.code !== 'EADDRINUSE') throw error
44
- lastError = error
45
- log(`Port ${port} is in use, trying another port...`)
46
- port += 1
47
- }
53
+ })
54
+ throw new Error('Test server did not bind to a TCP port')
48
55
  }
49
56
 
50
- throw lastError
57
+ let baseUrl = `http://localhost:${address.port}`
58
+ log(`Test server running on ${baseUrl}`)
59
+ return { server, port: address.port, baseUrl }
60
+ }
61
+
62
+ function isAddressInfo(address: ReturnType<http.Server['address']>): address is AddressInfo {
63
+ return address != null && typeof address === 'object'
51
64
  }
52
65
 
53
66
  function createRequestHandler(
@@ -280,19 +293,10 @@ function resolveSpecifier(spec: string, importerFile: string, rootDir: string):
280
293
  if (spec.startsWith('.') || spec.startsWith('/')) {
281
294
  resolvedPath = path.resolve(path.dirname(importerFile), spec)
282
295
  } else {
283
- // Bare specifiers must be resolved from the importer's filesystem
284
- // location, not this module's. `import.meta.resolve(spec, parent)` looks
285
- // like the right tool but its `parent` argument is gated behind
286
- // `--experimental-import-meta-resolve` through at least Node 24 —
287
- // without the flag, the parent argument is silently ignored and
288
- // resolution happens from `import.meta.url` of the calling module. That
289
- // made bare specifiers only resolvable when they were direct deps of
290
- // `@remix-run/test` itself (so `remix/assert` failed even when the
291
- // importing package depended on `remix`). `createRequire` walks
292
- // node_modules from the importer's actual location and has been stable
293
- // since Node 12 with no flags.
294
296
  try {
295
- resolvedPath = createRequire(importerFile).resolve(spec)
297
+ let resolutionResult = browserResolver.resolveFileSync(importerFile, spec)
298
+ if (!resolutionResult.path) return null
299
+ resolvedPath = resolutionResult.path
296
300
  } catch {
297
301
  return null
298
302
  }
package/src/cli.ts CHANGED
@@ -99,7 +99,7 @@ async function runRemixTestInCwd(argv: string[], cwd: string): Promise<number> {
99
99
  let rerunTimer: NodeJS.Timeout | undefined
100
100
  let browserServer: http.Server | undefined
101
101
  let browserServerFilesKey: string | undefined
102
- let browserPort: number | undefined
102
+ let browserBaseUrl: string | undefined
103
103
  let resolveRun: ((exitCode: number) => void) | undefined
104
104
 
105
105
  let runPromise = new Promise<number>((resolve) => {
@@ -127,7 +127,7 @@ async function runRemixTestInCwd(argv: string[], cwd: string): Promise<number> {
127
127
  )
128
128
  browserServer = undefined
129
129
  browserServerFilesKey = undefined
130
- browserPort = undefined
130
+ browserBaseUrl = undefined
131
131
  }
132
132
 
133
133
  let queueRerun = (reason: string) => {
@@ -189,7 +189,7 @@ async function runRemixTestInCwd(argv: string[], cwd: string): Promise<number> {
189
189
  let result = await startServer(browserFiles)
190
190
  browserServer = result.server
191
191
  browserServerFilesKey = browserFilesKey
192
- browserPort = result.port
192
+ browserBaseUrl = result.baseUrl
193
193
  }
194
194
 
195
195
  let reporter = createReporter(config.reporter)
@@ -260,21 +260,28 @@ async function runRemixTestInCwd(argv: string[], cwd: string): Promise<number> {
260
260
  }
261
261
  }
262
262
 
263
- let [browserResult, e2eResult] = await Promise.all([
264
- runBrowserTests != null
265
- ? runBrowserTests({
266
- baseUrl: `http://localhost:${browserPort}`,
267
- console: config.browser?.echo,
268
- coverage: !!config.coverage,
269
- open: config.browser?.open,
270
- playwrightUseOpts: project.playwrightUseOpts,
271
- projectName: project.name,
272
- reporter,
273
- testFiles: browserFiles,
274
- })
275
- : null,
263
+ let browserResult: Awaited<ReturnType<RunBrowserTests>> | null = null
264
+ if (runBrowserTests != null) {
265
+ let activeBrowserBaseUrl = browserBaseUrl
266
+ if (activeBrowserBaseUrl == null) {
267
+ throw new Error('Browser test server was not started')
268
+ }
269
+
270
+ browserResult = await runBrowserTests({
271
+ baseUrl: activeBrowserBaseUrl,
272
+ console: config.browser?.echo,
273
+ coverage: !!config.coverage,
274
+ open: config.browser?.open,
275
+ playwrightUseOpts: project.playwrightUseOpts,
276
+ projectName: project.name,
277
+ reporter,
278
+ testFiles: browserFiles,
279
+ })
280
+ }
281
+
282
+ let e2eResult =
276
283
  e2eFiles.length > 0
277
- ? runServerTests(e2eFiles, reporter, config.concurrency, 'e2e', {
284
+ ? await runServerTests(e2eFiles, reporter, config.concurrency, 'e2e', {
278
285
  open: config.browser?.open,
279
286
  playwrightUseOpts: project.playwrightUseOpts,
280
287
  projectName: project.name,
@@ -282,8 +289,7 @@ async function runRemixTestInCwd(argv: string[], cwd: string): Promise<number> {
282
289
  cwd,
283
290
  pool: config.pool,
284
291
  })
285
- : null,
286
- ])
292
+ : null
287
293
 
288
294
  counts.passed += (browserResult?.results.passed ?? 0) + (e2eResult?.passed ?? 0)
289
295
  counts.failed += (browserResult?.results.failed ?? 0) + (e2eResult?.failed ?? 0)
@@ -1,11 +1,33 @@
1
- import { createTestContext, type CreateTestContextOptions } from './context.ts'
1
+ import { createTestContext, type CreateTestContextOptions, type TestContext } from './context.ts'
2
2
  import type { V8CoverageEntry } from './coverage.ts'
3
3
  import type { TestResult, TestResults } from './reporters/results.ts'
4
4
 
5
+ type LifecycleHook = () => void | Promise<void>
6
+
7
+ interface RegisteredSuite {
8
+ name: string
9
+ tests: RegisteredTest[]
10
+ only?: boolean
11
+ skip?: boolean
12
+ todo?: boolean
13
+ beforeEach?: LifecycleHook
14
+ afterEach?: LifecycleHook
15
+ beforeAll?: LifecycleHook
16
+ afterAll?: LifecycleHook
17
+ }
18
+
19
+ interface RegisteredTest {
20
+ name: string
21
+ fn: (t: TestContext) => void | Promise<void>
22
+ only?: boolean
23
+ skip?: boolean
24
+ todo?: boolean
25
+ }
26
+
5
27
  export async function runTests(
6
28
  options?: Omit<CreateTestContextOptions, 'addE2ECoverageEntries'>,
7
29
  ): Promise<TestResults> {
8
- let suites = (globalThis as any).__testSuites || []
30
+ let suites = getRegisteredSuites()
9
31
  let e2eCoverageEntries: Array<{ entries: V8CoverageEntry[]; baseUrl: string }> = []
10
32
  let results: TestResults = {
11
33
  passed: 0,
@@ -15,7 +37,7 @@ export async function runTests(
15
37
  tests: [],
16
38
  }
17
39
 
18
- let hasOnlySuites = suites.some((s: any) => s.only)
40
+ let hasOnlySuites = suites.some((suite) => suite.only)
19
41
 
20
42
  for (let suite of suites) {
21
43
  // If any suite uses .only, skip all non-only suites
@@ -47,15 +69,19 @@ export async function runTests(
47
69
  }
48
70
 
49
71
  if (suite.beforeAll) {
72
+ let startTime = performance.now()
50
73
  try {
51
74
  await suite.beforeAll()
52
75
  } catch (error) {
53
- console.error(`beforeAll failed in suite "${suite.name}":`, error)
76
+ results.tests.push(
77
+ createFailedHookResult('beforeAll', suite.name, error, performance.now() - startTime),
78
+ )
79
+ results.failed++
54
80
  continue
55
81
  }
56
82
  }
57
83
 
58
- let hasOnlyTests = suite.tests.some((t: any) => t.only)
84
+ let hasOnlyTests = suite.tests.some((test) => test.only)
59
85
 
60
86
  for (let test of suite.tests) {
61
87
  // If any test uses .only, skip all non-only tests in this suite
@@ -84,6 +110,10 @@ export async function runTests(
84
110
  status: 'passed',
85
111
  duration: 0,
86
112
  }
113
+ let testError: unknown
114
+ let afterEachError: unknown
115
+ let testFailed = false
116
+ let afterEachFailed = false
87
117
 
88
118
  let contextOpts: CreateTestContextOptions | undefined = options
89
119
  ? {
@@ -99,36 +129,45 @@ export async function runTests(
99
129
  }
100
130
 
101
131
  await test.fn(testContext)
102
-
103
- result.status = 'passed'
104
- results.passed++
105
- } catch (error: any) {
106
- result.status = 'failed'
107
- result.error = {
108
- message: error.message || String(error),
109
- stack: error.stack,
110
- }
111
- results.failed++
132
+ } catch (error) {
133
+ testFailed = true
134
+ testError = error
112
135
  } finally {
113
136
  await cleanup()
114
137
  if (suite.afterEach) {
115
138
  try {
116
139
  await suite.afterEach()
117
140
  } catch (error) {
118
- console.error('afterEach failed:', error)
141
+ afterEachFailed = true
142
+ afterEachError = error
119
143
  }
120
144
  }
121
145
 
146
+ if (testFailed || afterEachFailed) {
147
+ result.status = 'failed'
148
+ result.error = createTestError(
149
+ testFailed ? testError : undefined,
150
+ afterEachFailed ? createHookFailure('afterEach', afterEachError) : undefined,
151
+ )
152
+ results.failed++
153
+ } else {
154
+ results.passed++
155
+ }
156
+
122
157
  result.duration = performance.now() - startTime
123
158
  results.tests.push(result)
124
159
  }
125
160
  }
126
161
 
127
162
  if (suite.afterAll) {
163
+ let startTime = performance.now()
128
164
  try {
129
165
  await suite.afterAll()
130
166
  } catch (error) {
131
- console.error(`afterAll failed in suite "${suite.name}":`, error)
167
+ results.tests.push(
168
+ createFailedHookResult('afterAll', suite.name, error, performance.now() - startTime),
169
+ )
170
+ results.failed++
132
171
  }
133
172
  }
134
173
  }
@@ -143,3 +182,53 @@ export async function runTests(
143
182
 
144
183
  return results
145
184
  }
185
+
186
+ function getRegisteredSuites(): RegisteredSuite[] {
187
+ let global = globalThis as typeof globalThis & { __testSuites?: RegisteredSuite[] }
188
+ return global.__testSuites ?? []
189
+ }
190
+
191
+ function createFailedHookResult(
192
+ hookName: string,
193
+ suiteName: string,
194
+ error: unknown,
195
+ duration: number,
196
+ ): TestResult {
197
+ return {
198
+ name: hookName,
199
+ suiteName,
200
+ status: 'failed',
201
+ error: createTestError(createHookFailure(hookName, error)),
202
+ duration,
203
+ }
204
+ }
205
+
206
+ function createHookFailure(hookName: string, error: unknown): Error {
207
+ let cause = error instanceof Error ? error : new Error(String(error))
208
+ return new Error(`${hookName} failed: ${cause.message}`, { cause })
209
+ }
210
+
211
+ function createTestError(
212
+ primaryError: unknown,
213
+ secondaryError: Error | undefined = undefined,
214
+ ): TestResult['error'] {
215
+ let message = primaryError !== undefined ? getErrorMessage(primaryError) : undefined
216
+ let stack = primaryError instanceof Error ? primaryError.stack : undefined
217
+
218
+ if (secondaryError) {
219
+ message = message ? `${message}\n${secondaryError.message}` : secondaryError.message
220
+ stack =
221
+ stack && secondaryError.stack
222
+ ? `${stack}\n${secondaryError.stack}`
223
+ : (stack ?? secondaryError.stack)
224
+ }
225
+
226
+ return {
227
+ message: message ?? 'Test failed',
228
+ stack,
229
+ }
230
+ }
231
+
232
+ function getErrorMessage(error: unknown): string {
233
+ return error instanceof Error ? error.message : String(error)
234
+ }