@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 +2 -5
- package/dist/app/server.d.ts +1 -0
- package/dist/app/server.d.ts.map +1 -1
- package/dist/app/server.js +44 -43
- package/dist/cli.js +30 -27
- package/dist/lib/executor.d.ts.map +1 -1
- package/dist/lib/executor.js +60 -14
- package/dist/test/framework.test.browser.js +19 -0
- package/package.json +7 -5
- package/src/app/server.ts +46 -42
- package/src/cli.ts +25 -19
- package/src/lib/executor.ts +106 -17
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
|
|
package/dist/app/server.d.ts
CHANGED
package/dist/app/server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/app/server.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,IAAI,MAAM,WAAW,CAAA;
|
|
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"}
|
package/dist/app/server.js
CHANGED
|
@@ -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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
:
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
:
|
|
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,
|
|
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"}
|
package/dist/lib/executor.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createTestContext } from "./context.js";
|
|
2
2
|
export async function runTests(options) {
|
|
3
|
-
let suites =
|
|
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((
|
|
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
|
-
|
|
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((
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
"
|
|
68
|
-
"
|
|
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.
|
|
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 {
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
:
|
|
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)
|
package/src/lib/executor.ts
CHANGED
|
@@ -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 = (
|
|
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((
|
|
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
|
-
|
|
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((
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|