@remix-run/test 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -44
- package/dist/app/client/entry.js +4 -0
- package/dist/app/server.d.ts.map +1 -1
- package/dist/app/server.js +10 -10
- package/dist/cli.d.ts +30 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +87 -23
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/lib/config.d.ts +55 -21
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +82 -33
- package/dist/lib/context.d.ts +5 -5
- package/dist/lib/coverage-loader.js +2 -2
- package/dist/lib/coverage.js +1 -1
- package/dist/lib/fake-timers.d.ts +39 -0
- package/dist/lib/fake-timers.d.ts.map +1 -1
- package/dist/lib/fake-timers.js +27 -8
- package/dist/lib/framework.d.ts +12 -6
- package/dist/lib/framework.d.ts.map +1 -1
- package/dist/lib/framework.js +24 -12
- package/dist/lib/import-module.d.ts.map +1 -1
- package/dist/lib/import-module.js +13 -3
- package/dist/lib/reporters/dot.d.ts.map +1 -1
- package/dist/lib/reporters/dot.js +10 -0
- package/dist/lib/reporters/files.d.ts.map +1 -1
- package/dist/lib/reporters/files.js +10 -0
- package/dist/lib/reporters/results.d.ts +1 -1
- package/dist/lib/reporters/results.d.ts.map +1 -1
- package/dist/lib/reporters/spec.d.ts.map +1 -1
- package/dist/lib/reporters/spec.js +10 -0
- package/dist/lib/reporters/tap.d.ts.map +1 -1
- package/dist/lib/reporters/tap.js +10 -0
- package/dist/lib/runner-browser.d.ts.map +1 -1
- package/dist/lib/runner-browser.js +40 -2
- package/dist/lib/runner.d.ts +18 -1
- package/dist/lib/runner.d.ts.map +1 -1
- package/dist/lib/runner.js +187 -38
- package/dist/lib/worker-e2e-file.d.ts +11 -0
- package/dist/lib/worker-e2e-file.d.ts.map +1 -0
- package/dist/lib/worker-e2e-file.js +69 -0
- package/dist/lib/worker-e2e.js +11 -47
- package/dist/lib/worker-process.d.ts +2 -0
- package/dist/lib/worker-process.d.ts.map +1 -0
- package/dist/lib/worker-process.js +55 -0
- package/dist/lib/worker-results.d.ts +3 -0
- package/dist/lib/worker-results.d.ts.map +1 -0
- package/dist/lib/worker-results.js +20 -0
- package/dist/lib/worker-server.d.ts +10 -0
- package/dist/lib/worker-server.d.ts.map +1 -0
- package/dist/lib/worker-server.js +112 -0
- package/dist/lib/worker.js +6 -55
- package/package.json +5 -5
- package/src/app/client/entry.ts +4 -0
- package/src/app/server.ts +11 -10
- package/src/cli.ts +121 -28
- package/src/index.ts +1 -1
- package/src/lib/config.ts +144 -58
- package/src/lib/context.ts +5 -5
- package/src/lib/coverage-loader.ts +2 -2
- package/src/lib/coverage.ts +1 -1
- package/src/lib/fake-timers.ts +65 -8
- package/src/lib/framework.ts +53 -36
- package/src/lib/import-module.ts +14 -3
- package/src/lib/reporters/dot.ts +9 -0
- package/src/lib/reporters/files.ts +9 -0
- package/src/lib/reporters/results.ts +1 -1
- package/src/lib/reporters/spec.ts +9 -0
- package/src/lib/reporters/tap.ts +9 -0
- package/src/lib/runner-browser.ts +46 -2
- package/src/lib/runner.ts +253 -50
- package/src/lib/ts-transform.ts +1 -1
- package/src/lib/worker-e2e-file.ts +98 -0
- package/src/lib/worker-e2e.ts +14 -51
- package/src/lib/worker-process.ts +69 -0
- package/src/lib/worker-results.ts +22 -0
- package/src/lib/worker-server.ts +123 -0
- package/src/lib/worker.ts +7 -47
- package/tsconfig.json +6 -3
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { runE2ETestFile } from "./worker-e2e-file.js";
|
|
2
|
+
import { runServerTestFile } from "./worker-server.js";
|
|
3
|
+
import { createFailedResults } from "./worker-results.js";
|
|
4
|
+
const workerData = await readWorkerData();
|
|
5
|
+
const results = await runWorkerProcessFile(workerData);
|
|
6
|
+
if (results) {
|
|
7
|
+
await sendResults(results);
|
|
8
|
+
}
|
|
9
|
+
if (process.connected) {
|
|
10
|
+
process.disconnect();
|
|
11
|
+
}
|
|
12
|
+
process.exitCode = 0;
|
|
13
|
+
function readWorkerData() {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
function cleanup() {
|
|
16
|
+
process.off('message', onMessage);
|
|
17
|
+
process.off('disconnect', onDisconnect);
|
|
18
|
+
}
|
|
19
|
+
function onMessage(value) {
|
|
20
|
+
cleanup();
|
|
21
|
+
resolve(value);
|
|
22
|
+
}
|
|
23
|
+
function onDisconnect() {
|
|
24
|
+
cleanup();
|
|
25
|
+
reject(new Error('Test worker process disconnected'));
|
|
26
|
+
}
|
|
27
|
+
process.once('message', onMessage);
|
|
28
|
+
process.once('disconnect', onDisconnect);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
async function runWorkerProcessFile(value) {
|
|
32
|
+
try {
|
|
33
|
+
if (!isRecord(value) || (value.type !== 'server' && value.type !== 'e2e')) {
|
|
34
|
+
throw new Error('Invalid test worker process data');
|
|
35
|
+
}
|
|
36
|
+
return value.type === 'e2e'
|
|
37
|
+
? await runE2ETestFile(value, sendResults)
|
|
38
|
+
: await runServerTestFile(value);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
return createFailedResults(error);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function sendResults(results) {
|
|
45
|
+
if (!process.send) {
|
|
46
|
+
throw new Error('Test worker process is missing an IPC channel');
|
|
47
|
+
}
|
|
48
|
+
let send = process.send.bind(process);
|
|
49
|
+
await new Promise((resolve, reject) => {
|
|
50
|
+
send(results, undefined, undefined, (error) => (error ? reject(error) : resolve()));
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function isRecord(value) {
|
|
54
|
+
return typeof value === 'object' && value !== null;
|
|
55
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"worker-results.d.ts","sourceRoot":"","sources":["../../src/lib/worker-results.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAA;AAEzD,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,WAAW,CAmB/D"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function createFailedResults(error) {
|
|
2
|
+
return {
|
|
3
|
+
passed: 0,
|
|
4
|
+
failed: 1,
|
|
5
|
+
skipped: 0,
|
|
6
|
+
todo: 0,
|
|
7
|
+
tests: [
|
|
8
|
+
{
|
|
9
|
+
name: '',
|
|
10
|
+
suiteName: '',
|
|
11
|
+
status: 'failed',
|
|
12
|
+
duration: 0,
|
|
13
|
+
error: {
|
|
14
|
+
message: error instanceof Error ? error.message : String(error),
|
|
15
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { CoverageConfig } from './coverage.ts';
|
|
2
|
+
import type { TestResults } from './reporters/results.ts';
|
|
3
|
+
export interface ServerTestWorkerData {
|
|
4
|
+
file: string;
|
|
5
|
+
coverage?: CoverageConfig;
|
|
6
|
+
}
|
|
7
|
+
export declare function runServerTestFile(value: unknown): Promise<TestResults>;
|
|
8
|
+
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|
|
9
|
+
export declare function parseCoverageConfig(value: unknown): CoverageConfig | undefined;
|
|
10
|
+
//# sourceMappingURL=worker-server.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"worker-server.d.ts","sourceRoot":"","sources":["../../src/lib/worker-server.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AACnD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAA;AAKzD,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,CAAC,EAAE,cAAc,CAAA;CAC1B;AAED,wBAAsB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,WAAW,CAAC,CAiC5E;AAaD,wBAAgB,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAEzE;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,cAAc,GAAG,SAAS,CA2B9E"}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
import * as mod from 'node:module';
|
|
10
|
+
import { IS_RUNNING_FROM_SRC } from "./config.js";
|
|
11
|
+
import { importModule } from "./import-module.js";
|
|
12
|
+
import { runTests } from "./executor.js";
|
|
13
|
+
import { IS_BUN } from "./runtime.js";
|
|
14
|
+
import { createFailedResults } from "./worker-results.js";
|
|
15
|
+
export async function runServerTestFile(value) {
|
|
16
|
+
let workerData;
|
|
17
|
+
try {
|
|
18
|
+
workerData = parseServerTestWorkerData(value);
|
|
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.
|
|
22
|
+
if (workerData.coverage && !IS_BUN) {
|
|
23
|
+
// Ensure we load the right file whether we're running in the monorepo (TS) or
|
|
24
|
+
// from a published package (JS)
|
|
25
|
+
let ext = IS_RUNNING_FROM_SRC ? '.ts' : '.js';
|
|
26
|
+
mod.register(new URL(`./coverage-loader${ext}`, import.meta.url), import.meta.url);
|
|
27
|
+
await import(__rewriteRelativeImportExtension(workerData.file));
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
await importModule(workerData.file, import.meta);
|
|
31
|
+
}
|
|
32
|
+
let results = await runTests();
|
|
33
|
+
await takeCoverage(workerData.coverage);
|
|
34
|
+
return results;
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
let failure = error;
|
|
38
|
+
try {
|
|
39
|
+
await takeCoverage(workerData?.coverage);
|
|
40
|
+
}
|
|
41
|
+
catch (coverageError) {
|
|
42
|
+
failure = coverageError;
|
|
43
|
+
}
|
|
44
|
+
return createFailedResults(failure);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function parseServerTestWorkerData(value) {
|
|
48
|
+
if (!isRecord(value) || typeof value.file !== 'string') {
|
|
49
|
+
throw new Error('Invalid server test worker data');
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
file: value.file,
|
|
53
|
+
coverage: parseCoverageConfig(value.coverage),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export function isRecord(value) {
|
|
57
|
+
return typeof value === 'object' && value !== null;
|
|
58
|
+
}
|
|
59
|
+
export function parseCoverageConfig(value) {
|
|
60
|
+
if (value === undefined) {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
if (!isRecord(value) || typeof value.dir !== 'string') {
|
|
64
|
+
throw new Error('Invalid server test worker coverage config');
|
|
65
|
+
}
|
|
66
|
+
let coverage = {
|
|
67
|
+
dir: value.dir,
|
|
68
|
+
};
|
|
69
|
+
let include = parseStringArray(value.include, 'include');
|
|
70
|
+
let exclude = parseStringArray(value.exclude, 'exclude');
|
|
71
|
+
let statements = parseNumber(value.statements, 'statements');
|
|
72
|
+
let lines = parseNumber(value.lines, 'lines');
|
|
73
|
+
let branches = parseNumber(value.branches, 'branches');
|
|
74
|
+
let functions = parseNumber(value.functions, 'functions');
|
|
75
|
+
if (include)
|
|
76
|
+
coverage.include = include;
|
|
77
|
+
if (exclude)
|
|
78
|
+
coverage.exclude = exclude;
|
|
79
|
+
if (statements !== undefined)
|
|
80
|
+
coverage.statements = statements;
|
|
81
|
+
if (lines !== undefined)
|
|
82
|
+
coverage.lines = lines;
|
|
83
|
+
if (branches !== undefined)
|
|
84
|
+
coverage.branches = branches;
|
|
85
|
+
if (functions !== undefined)
|
|
86
|
+
coverage.functions = functions;
|
|
87
|
+
return coverage;
|
|
88
|
+
}
|
|
89
|
+
function parseStringArray(value, name) {
|
|
90
|
+
if (value === undefined) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
|
|
94
|
+
throw new Error(`Invalid server test worker coverage ${name}`);
|
|
95
|
+
}
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
function parseNumber(value, name) {
|
|
99
|
+
if (value === undefined) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
if (typeof value !== 'number') {
|
|
103
|
+
throw new Error(`Invalid server test worker coverage ${name}`);
|
|
104
|
+
}
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
async function takeCoverage(coverage) {
|
|
108
|
+
if (coverage && !IS_BUN) {
|
|
109
|
+
let v8 = await import('node:v8');
|
|
110
|
+
v8.takeCoverage();
|
|
111
|
+
}
|
|
112
|
+
}
|
package/dist/lib/worker.js
CHANGED
|
@@ -1,57 +1,8 @@
|
|
|
1
|
-
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
-
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
-
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
-
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
-
});
|
|
6
|
-
}
|
|
7
|
-
return path;
|
|
8
|
-
};
|
|
9
|
-
import * as mod from 'node:module';
|
|
10
|
-
import * as path from 'node:path';
|
|
11
1
|
import { parentPort, workerData } from 'node:worker_threads';
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
import { IS_RUNNING_FROM_SRC } from "./config.js";
|
|
16
|
-
try {
|
|
17
|
-
// When coverage is enabled in Node, we use a coverage-friendly TypeScript loader which
|
|
18
|
-
// replaces tsx's minified transformation with a non-minified esbuild transform
|
|
19
|
-
// so V8 coverage byte offsets align with readable source lines. This hook runs
|
|
20
|
-
// before the inherited tsx hook (hooks are LIFO), so it intercepts .ts imports and
|
|
21
|
-
// short-circuits before tsx transforms them.
|
|
22
|
-
if (workerData.coverage && !IS_BUN) {
|
|
23
|
-
// Ensure we load the right file whether we're running in the monorepo (TS) or
|
|
24
|
-
// from a published package (JS)
|
|
25
|
-
let ext = IS_RUNNING_FROM_SRC ? '.ts' : '.js';
|
|
26
|
-
mod.register(new URL(`./coverage-loader${ext}`, import.meta.url), import.meta.url);
|
|
27
|
-
await import(__rewriteRelativeImportExtension(workerData.file));
|
|
28
|
-
}
|
|
29
|
-
else {
|
|
30
|
-
await importModule(workerData.file, import.meta);
|
|
31
|
-
}
|
|
32
|
-
let results = await runTests();
|
|
33
|
-
parentPort.postMessage(results);
|
|
34
|
-
process.exit(0);
|
|
35
|
-
}
|
|
36
|
-
catch (e) {
|
|
37
|
-
let results = {
|
|
38
|
-
passed: 0,
|
|
39
|
-
failed: 1,
|
|
40
|
-
skipped: 0,
|
|
41
|
-
todo: 0,
|
|
42
|
-
tests: [
|
|
43
|
-
{
|
|
44
|
-
name: '',
|
|
45
|
-
suiteName: '',
|
|
46
|
-
status: 'failed',
|
|
47
|
-
duration: 0,
|
|
48
|
-
error: {
|
|
49
|
-
message: e instanceof Error ? e.message : String(e),
|
|
50
|
-
stack: e instanceof Error ? e.stack : undefined,
|
|
51
|
-
},
|
|
52
|
-
},
|
|
53
|
-
],
|
|
54
|
-
};
|
|
55
|
-
parentPort.postMessage(results);
|
|
56
|
-
process.exit(0);
|
|
2
|
+
import { runServerTestFile } from "./worker-server.js";
|
|
3
|
+
if (!parentPort) {
|
|
4
|
+
throw new Error('Server test worker is missing a parent port');
|
|
57
5
|
}
|
|
6
|
+
const results = await runServerTestFile(workerData);
|
|
7
|
+
parentPort.postMessage(results);
|
|
8
|
+
process.exit(0);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remix-run/test",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "A test framework for JavaScript and TypeScript projects",
|
|
5
5
|
"author": "Shopify Inc.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,8 +45,8 @@
|
|
|
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",
|
|
49
|
+
"@remix-run/node-tsx": "^0.1.0",
|
|
50
50
|
"@remix-run/terminal": "^0.1.0"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
@@ -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/assert": "^0.
|
|
70
|
-
"@remix-run/
|
|
71
|
-
"@remix-run/
|
|
69
|
+
"@remix-run/assert": "^0.2.0",
|
|
70
|
+
"@remix-run/ui": "^0.1.2",
|
|
71
|
+
"@remix-run/node-fetch-server": "^0.13.2"
|
|
72
72
|
},
|
|
73
73
|
"keywords": [
|
|
74
74
|
"testing",
|
package/src/app/client/entry.ts
CHANGED
|
@@ -185,6 +185,10 @@ function runInIframe(testFile: string): Promise<FileResults> {
|
|
|
185
185
|
return new Promise((resolve) => {
|
|
186
186
|
let iframe = document.createElement('iframe')
|
|
187
187
|
iframe.src = `/iframe?file=${encodeURIComponent(testFile)}`
|
|
188
|
+
// Make the iframe as big so we don't get unintentional scrolling in test UIs
|
|
189
|
+
let parentBody = iframe.contentWindow?.document.body
|
|
190
|
+
iframe.width = Math.max(parentBody?.scrollWidth ?? 0, 800).toString()
|
|
191
|
+
iframe.height = Math.max(Math.round((parentBody?.scrollHeight ?? 0) / 2), 400).toString()
|
|
188
192
|
document.body.appendChild(iframe)
|
|
189
193
|
|
|
190
194
|
function onMessage(event: MessageEvent) {
|
package/src/app/server.ts
CHANGED
|
@@ -9,6 +9,9 @@ import { SourceMapConsumer, SourceMapGenerator } from 'source-map-js'
|
|
|
9
9
|
import { getBrowserTestRootDir, IS_RUNNING_FROM_SRC } from '../lib/config.ts'
|
|
10
10
|
import { transformTypeScript } from '../lib/ts-transform.ts'
|
|
11
11
|
|
|
12
|
+
const log = (str: string) => console.log(`[remix:test] ${str}`)
|
|
13
|
+
const logError = (str: string, e: unknown) => console.error(`[remix:test] Error: ${str}\n`, e)
|
|
14
|
+
|
|
12
15
|
export async function startServer(
|
|
13
16
|
browserFiles: string[],
|
|
14
17
|
): Promise<{ server: http.Server; port: number }> {
|
|
@@ -20,7 +23,7 @@ export async function startServer(
|
|
|
20
23
|
try {
|
|
21
24
|
let server = http.createServer((req, res) => {
|
|
22
25
|
handle(req, res).catch((error) => {
|
|
23
|
-
|
|
26
|
+
logError(`Unhandled error for ${req.url}`, error)
|
|
24
27
|
if (!res.headersSent) {
|
|
25
28
|
res.writeHead(500, { 'Content-Type': 'text/plain' })
|
|
26
29
|
}
|
|
@@ -31,7 +34,7 @@ export async function startServer(
|
|
|
31
34
|
server.once('error', reject)
|
|
32
35
|
server.listen(port, () => {
|
|
33
36
|
server.removeListener('error', reject)
|
|
34
|
-
|
|
37
|
+
log(`Test server running on http://localhost:${port}`)
|
|
35
38
|
resolve()
|
|
36
39
|
})
|
|
37
40
|
})
|
|
@@ -39,7 +42,7 @@ export async function startServer(
|
|
|
39
42
|
} catch (error: any) {
|
|
40
43
|
if (error.code !== 'EADDRINUSE') throw error
|
|
41
44
|
lastError = error
|
|
42
|
-
|
|
45
|
+
log(`Port ${port} is in use, trying another port...`)
|
|
43
46
|
port += 1
|
|
44
47
|
}
|
|
45
48
|
}
|
|
@@ -99,7 +102,7 @@ function createRequestHandler(
|
|
|
99
102
|
await serveScript(res, filePath, url.pathname, rootDir)
|
|
100
103
|
return
|
|
101
104
|
} catch (error) {
|
|
102
|
-
|
|
105
|
+
logError(`Error serving ${url.pathname}`, error)
|
|
103
106
|
sendText(res, 500, String(error))
|
|
104
107
|
return
|
|
105
108
|
}
|
|
@@ -151,9 +154,8 @@ async function serveScript(
|
|
|
151
154
|
let result = await transformTypeScript(source, filePath)
|
|
152
155
|
code = result.code
|
|
153
156
|
} catch (error) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
sendText(res, 500, msg)
|
|
157
|
+
logError(`Failed to transform ${urlPath}`, error)
|
|
158
|
+
sendText(res, 500, `Failed to transform ${urlPath}`)
|
|
157
159
|
return
|
|
158
160
|
}
|
|
159
161
|
} else {
|
|
@@ -163,9 +165,8 @@ async function serveScript(
|
|
|
163
165
|
try {
|
|
164
166
|
code = await rewriteImports(code, filePath, rootDir)
|
|
165
167
|
} catch (error) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
sendText(res, 500, msg)
|
|
168
|
+
logError(`Failed to rewrite imports for ${urlPath}`, error)
|
|
169
|
+
sendText(res, 500, `Failed to rewrite imports for ${urlPath}`)
|
|
169
170
|
return
|
|
170
171
|
}
|
|
171
172
|
|
package/src/cli.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import * as fsp from 'node:fs/promises'
|
|
2
2
|
import type * as http from 'node:http'
|
|
3
3
|
import * as path from 'node:path'
|
|
4
|
+
import type * as browserTestRunner from './lib/runner-browser.ts'
|
|
4
5
|
import {
|
|
5
6
|
getRemixTestHelpText,
|
|
6
7
|
IS_RUNNING_FROM_SRC,
|
|
7
8
|
loadConfig,
|
|
8
9
|
type ResolvedRemixTestConfig,
|
|
9
10
|
} from './lib/config.ts'
|
|
11
|
+
import type * as playwrightSupport from './lib/playwright.ts'
|
|
10
12
|
import { generateCombinedCoverageReport } from './lib/coverage.ts'
|
|
11
|
-
import { loadPlaywrightConfig, resolveProjects } from './lib/playwright.ts'
|
|
12
13
|
import { createReporter } from './lib/reporters/index.ts'
|
|
13
|
-
import { runBrowserTests } from './lib/runner-browser.ts'
|
|
14
14
|
import { runServerTests } from './lib/runner.ts'
|
|
15
15
|
import { createWatcher } from './lib/watcher.ts'
|
|
16
16
|
import { importModule } from './lib/import-module.ts'
|
|
@@ -20,11 +20,27 @@ import { isMainThread } from 'node:worker_threads'
|
|
|
20
20
|
|
|
21
21
|
export { getRemixTestHelpText }
|
|
22
22
|
|
|
23
|
+
const MISSING_PLAYWRIGHT_MESSAGE =
|
|
24
|
+
'Playwright is required to run browser and E2E tests. Install it with `npm i -D playwright`.'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Options accepted by {@link runRemixTest}.
|
|
28
|
+
*/
|
|
23
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
|
+
*/
|
|
24
34
|
argv?: string[]
|
|
35
|
+
/**
|
|
36
|
+
* Working directory the runner resolves config and test files against
|
|
37
|
+
* (default `process.cwd()`).
|
|
38
|
+
*/
|
|
25
39
|
cwd?: string
|
|
26
40
|
}
|
|
27
41
|
|
|
42
|
+
type RunBrowserTests = typeof browserTestRunner.runBrowserTests
|
|
43
|
+
|
|
28
44
|
interface DiscoveredTests {
|
|
29
45
|
files: string[]
|
|
30
46
|
serverFiles: string[]
|
|
@@ -32,6 +48,25 @@ interface DiscoveredTests {
|
|
|
32
48
|
e2eFiles: string[]
|
|
33
49
|
}
|
|
34
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
|
+
*/
|
|
35
70
|
export async function runRemixTest(options: RunRemixTestOptions = {}): Promise<number> {
|
|
36
71
|
let argv = options.argv ?? process.argv.slice(2)
|
|
37
72
|
let cwd = await resolveCwd(options.cwd ?? process.cwd())
|
|
@@ -157,11 +192,6 @@ async function runRemixTestInCwd(argv: string[], cwd: string): Promise<number> {
|
|
|
157
192
|
browserPort = result.port
|
|
158
193
|
}
|
|
159
194
|
|
|
160
|
-
let playwrightConfig =
|
|
161
|
-
config.playwrightConfig == null || typeof config.playwrightConfig === 'string'
|
|
162
|
-
? await loadPlaywrightConfig(config.playwrightConfig, cwd)
|
|
163
|
-
: config.playwrightConfig
|
|
164
|
-
|
|
165
195
|
let reporter = createReporter(config.reporter)
|
|
166
196
|
let startTime = performance.now()
|
|
167
197
|
|
|
@@ -183,6 +213,7 @@ async function runRemixTestInCwd(argv: string[], cwd: string): Promise<number> {
|
|
|
183
213
|
{
|
|
184
214
|
coverage: config.coverage,
|
|
185
215
|
cwd,
|
|
216
|
+
pool: config.pool,
|
|
186
217
|
},
|
|
187
218
|
)
|
|
188
219
|
counts.failed += serverResult.failed
|
|
@@ -194,18 +225,26 @@ async function runRemixTestInCwd(argv: string[], cwd: string): Promise<number> {
|
|
|
194
225
|
|
|
195
226
|
// Run browser/e2e tests for all browsers configured by the user
|
|
196
227
|
if (browserFiles.length > 0 || e2eFiles.length > 0) {
|
|
228
|
+
let { loadPlaywrightConfig, resolveProjects } = await importPlaywrightSupport()
|
|
229
|
+
let runBrowserTests =
|
|
230
|
+
browserFiles.length > 0 ? (await importBrowserTestRunner()).runBrowserTests : undefined
|
|
231
|
+
let playwrightConfig =
|
|
232
|
+
config.playwrightConfig == null || typeof config.playwrightConfig === 'string'
|
|
233
|
+
? await loadPlaywrightConfig(config.playwrightConfig, cwd)
|
|
234
|
+
: config.playwrightConfig
|
|
197
235
|
let projects = resolveProjects(playwrightConfig)
|
|
236
|
+
|
|
198
237
|
if (config.project) {
|
|
199
|
-
let projectNames = config.project
|
|
200
|
-
projects = projects.filter(
|
|
201
|
-
(project) => project.name && projectNames.includes(project.name),
|
|
202
|
-
)
|
|
238
|
+
let projectNames = new Set(config.project)
|
|
239
|
+
projects = projects.filter((project) => project.name && projectNames.has(project.name))
|
|
203
240
|
if (projects.length === 0) {
|
|
204
|
-
throw new Error(
|
|
241
|
+
throw new Error(
|
|
242
|
+
`No playwright projects found with name(s) "${config.project.join(', ')}"`,
|
|
243
|
+
)
|
|
205
244
|
}
|
|
206
245
|
}
|
|
207
246
|
|
|
208
|
-
let lastBrowserResult: Awaited<ReturnType<
|
|
247
|
+
let lastBrowserResult: Awaited<ReturnType<RunBrowserTests>> | null = null
|
|
209
248
|
|
|
210
249
|
for (let project of projects) {
|
|
211
250
|
reporter.onSectionStart(`\nRunning tests for project \`${project.name}\`:`)
|
|
@@ -222,7 +261,7 @@ async function runRemixTestInCwd(argv: string[], cwd: string): Promise<number> {
|
|
|
222
261
|
}
|
|
223
262
|
|
|
224
263
|
let [browserResult, e2eResult] = await Promise.all([
|
|
225
|
-
|
|
264
|
+
runBrowserTests != null
|
|
226
265
|
? runBrowserTests({
|
|
227
266
|
baseUrl: `http://localhost:${browserPort}`,
|
|
228
267
|
console: config.browser?.echo,
|
|
@@ -241,6 +280,7 @@ async function runRemixTestInCwd(argv: string[], cwd: string): Promise<number> {
|
|
|
241
280
|
projectName: project.name,
|
|
242
281
|
coverage: config.coverage,
|
|
243
282
|
cwd,
|
|
283
|
+
pool: config.pool,
|
|
244
284
|
})
|
|
245
285
|
: null,
|
|
246
286
|
])
|
|
@@ -312,6 +352,42 @@ async function runRemixTestInCwd(argv: string[], cwd: string): Promise<number> {
|
|
|
312
352
|
return await runPromise
|
|
313
353
|
}
|
|
314
354
|
|
|
355
|
+
async function importPlaywrightSupport(): Promise<typeof playwrightSupport> {
|
|
356
|
+
try {
|
|
357
|
+
return await import('./lib/playwright.ts')
|
|
358
|
+
} catch (error) {
|
|
359
|
+
throw toPlaywrightImportError(error)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function importBrowserTestRunner(): Promise<typeof browserTestRunner> {
|
|
364
|
+
try {
|
|
365
|
+
return await import('./lib/runner-browser.ts')
|
|
366
|
+
} catch (error) {
|
|
367
|
+
throw toPlaywrightImportError(error)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function toPlaywrightImportError(error: unknown): unknown {
|
|
372
|
+
return isMissingPlaywrightImport(error) ? new Error(MISSING_PLAYWRIGHT_MESSAGE) : error
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function isMissingPlaywrightImport(error: unknown): boolean {
|
|
376
|
+
if (!isRecord(error) || typeof error.message !== 'string') {
|
|
377
|
+
return false
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return (
|
|
381
|
+
(error.code === 'ERR_MODULE_NOT_FOUND' || error.code === 'MODULE_NOT_FOUND') &&
|
|
382
|
+
(error.message.includes("Cannot find package 'playwright'") ||
|
|
383
|
+
error.message.includes("Cannot find module 'playwright'"))
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
388
|
+
return typeof value === 'object' && value !== null
|
|
389
|
+
}
|
|
390
|
+
|
|
315
391
|
async function resolveCwd(cwd: string): Promise<string> {
|
|
316
392
|
try {
|
|
317
393
|
return await fsp.realpath(cwd)
|
|
@@ -327,13 +403,13 @@ async function discoverTests(
|
|
|
327
403
|
let files = await findFiles(config.glob.test, config.glob.exclude, cwd)
|
|
328
404
|
|
|
329
405
|
if (files.length === 0) {
|
|
330
|
-
console.log(`No test files found matching pattern: ${config.glob.test}`)
|
|
406
|
+
console.log(`No test files found matching pattern: ${config.glob.test.join(', ')}`)
|
|
331
407
|
return null
|
|
332
408
|
}
|
|
333
409
|
|
|
334
410
|
let browserSet = new Set(await findFiles(config.glob.browser, config.glob.exclude, cwd))
|
|
335
411
|
let e2eSet = new Set(await findFiles(config.glob.e2e, config.glob.exclude, cwd))
|
|
336
|
-
let types = new Set(config.type
|
|
412
|
+
let types = new Set(config.type)
|
|
337
413
|
let browserFiles = types.has('browser') ? files.filter((f) => browserSet.has(f)) : []
|
|
338
414
|
let e2eFiles = types.has('e2e') ? files.filter((file) => e2eSet.has(file)) : []
|
|
339
415
|
let serverFiles = types.has('server')
|
|
@@ -342,7 +418,7 @@ async function discoverTests(
|
|
|
342
418
|
let totalFiles = browserFiles.length + serverFiles.length + e2eFiles.length
|
|
343
419
|
|
|
344
420
|
if (totalFiles === 0) {
|
|
345
|
-
console.log(`No test files remain after filtering for type ${config.type}`)
|
|
421
|
+
console.log(`No test files remain after filtering for type ${config.type.join(', ')}`)
|
|
346
422
|
return null
|
|
347
423
|
}
|
|
348
424
|
|
|
@@ -358,8 +434,12 @@ async function discoverTests(
|
|
|
358
434
|
}
|
|
359
435
|
}
|
|
360
436
|
|
|
361
|
-
async function findFiles(
|
|
362
|
-
|
|
437
|
+
async function findFiles(
|
|
438
|
+
patterns: string[],
|
|
439
|
+
excludePatterns: string[],
|
|
440
|
+
cwd: string,
|
|
441
|
+
): Promise<string[]> {
|
|
442
|
+
let files = new Set<string>()
|
|
363
443
|
|
|
364
444
|
if (IS_BUN) {
|
|
365
445
|
// Bun's `fs.promises.glob` follows symlinks and doesn't prune traversal
|
|
@@ -367,18 +447,31 @@ async function findFiles(pattern: string, excludePattern: string, cwd: string):
|
|
|
367
447
|
// Use Bun's native Glob, which defaults to `followSymlinks: false`.
|
|
368
448
|
// @ts-expect-error — bun module is only resolvable under the Bun runtime
|
|
369
449
|
let { Glob } = await import('bun')
|
|
370
|
-
let
|
|
371
|
-
let
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
450
|
+
let excludeGlobs = excludePatterns.map((p) => new Glob(p))
|
|
451
|
+
for (let pattern of patterns) {
|
|
452
|
+
let glob = new Glob(pattern)
|
|
453
|
+
for await (let file of glob.scan({ cwd, absolute: true })) {
|
|
454
|
+
let rel = toPosix(path.relative(cwd, file))
|
|
455
|
+
if (!excludeGlobs.some((eg: { match: (s: string) => boolean }) => eg.match(rel))) {
|
|
456
|
+
files.add(toPosix(file))
|
|
457
|
+
}
|
|
375
458
|
}
|
|
376
459
|
}
|
|
377
|
-
return files
|
|
460
|
+
return [...files]
|
|
378
461
|
}
|
|
379
462
|
|
|
380
|
-
for
|
|
381
|
-
|
|
463
|
+
for (let pattern of patterns) {
|
|
464
|
+
for await (let file of fsp.glob(pattern, { cwd, exclude: excludePatterns })) {
|
|
465
|
+
files.add(toPosix(path.resolve(cwd, file)))
|
|
466
|
+
}
|
|
382
467
|
}
|
|
383
|
-
return files
|
|
468
|
+
return [...files]
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Normalize discovered paths so set membership across the test/browser/e2e
|
|
472
|
+
// `findFiles` calls is byte-stable on every platform. Node accepts forward
|
|
473
|
+
// slashes for filesystem operations on Windows, so downstream `fs.readFile`
|
|
474
|
+
// etc. work without further conversion.
|
|
475
|
+
function toPosix(p: string): string {
|
|
476
|
+
return path.sep === '/' ? p : p.replace(/\\/g, '/')
|
|
384
477
|
}
|
package/src/index.ts
CHANGED