@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.
Files changed (79) hide show
  1. package/README.md +43 -44
  2. package/dist/app/client/entry.js +4 -0
  3. package/dist/app/server.d.ts.map +1 -1
  4. package/dist/app/server.js +10 -10
  5. package/dist/cli.d.ts +30 -0
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +87 -23
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/lib/config.d.ts +55 -21
  11. package/dist/lib/config.d.ts.map +1 -1
  12. package/dist/lib/config.js +82 -33
  13. package/dist/lib/context.d.ts +5 -5
  14. package/dist/lib/coverage-loader.js +2 -2
  15. package/dist/lib/coverage.js +1 -1
  16. package/dist/lib/fake-timers.d.ts +39 -0
  17. package/dist/lib/fake-timers.d.ts.map +1 -1
  18. package/dist/lib/fake-timers.js +27 -8
  19. package/dist/lib/framework.d.ts +12 -6
  20. package/dist/lib/framework.d.ts.map +1 -1
  21. package/dist/lib/framework.js +24 -12
  22. package/dist/lib/import-module.d.ts.map +1 -1
  23. package/dist/lib/import-module.js +13 -3
  24. package/dist/lib/reporters/dot.d.ts.map +1 -1
  25. package/dist/lib/reporters/dot.js +10 -0
  26. package/dist/lib/reporters/files.d.ts.map +1 -1
  27. package/dist/lib/reporters/files.js +10 -0
  28. package/dist/lib/reporters/results.d.ts +1 -1
  29. package/dist/lib/reporters/results.d.ts.map +1 -1
  30. package/dist/lib/reporters/spec.d.ts.map +1 -1
  31. package/dist/lib/reporters/spec.js +10 -0
  32. package/dist/lib/reporters/tap.d.ts.map +1 -1
  33. package/dist/lib/reporters/tap.js +10 -0
  34. package/dist/lib/runner-browser.d.ts.map +1 -1
  35. package/dist/lib/runner-browser.js +40 -2
  36. package/dist/lib/runner.d.ts +18 -1
  37. package/dist/lib/runner.d.ts.map +1 -1
  38. package/dist/lib/runner.js +187 -38
  39. package/dist/lib/worker-e2e-file.d.ts +11 -0
  40. package/dist/lib/worker-e2e-file.d.ts.map +1 -0
  41. package/dist/lib/worker-e2e-file.js +69 -0
  42. package/dist/lib/worker-e2e.js +11 -47
  43. package/dist/lib/worker-process.d.ts +2 -0
  44. package/dist/lib/worker-process.d.ts.map +1 -0
  45. package/dist/lib/worker-process.js +55 -0
  46. package/dist/lib/worker-results.d.ts +3 -0
  47. package/dist/lib/worker-results.d.ts.map +1 -0
  48. package/dist/lib/worker-results.js +20 -0
  49. package/dist/lib/worker-server.d.ts +10 -0
  50. package/dist/lib/worker-server.d.ts.map +1 -0
  51. package/dist/lib/worker-server.js +112 -0
  52. package/dist/lib/worker.js +6 -55
  53. package/package.json +5 -5
  54. package/src/app/client/entry.ts +4 -0
  55. package/src/app/server.ts +11 -10
  56. package/src/cli.ts +121 -28
  57. package/src/index.ts +1 -1
  58. package/src/lib/config.ts +144 -58
  59. package/src/lib/context.ts +5 -5
  60. package/src/lib/coverage-loader.ts +2 -2
  61. package/src/lib/coverage.ts +1 -1
  62. package/src/lib/fake-timers.ts +65 -8
  63. package/src/lib/framework.ts +53 -36
  64. package/src/lib/import-module.ts +14 -3
  65. package/src/lib/reporters/dot.ts +9 -0
  66. package/src/lib/reporters/files.ts +9 -0
  67. package/src/lib/reporters/results.ts +1 -1
  68. package/src/lib/reporters/spec.ts +9 -0
  69. package/src/lib/reporters/tap.ts +9 -0
  70. package/src/lib/runner-browser.ts +46 -2
  71. package/src/lib/runner.ts +253 -50
  72. package/src/lib/ts-transform.ts +1 -1
  73. package/src/lib/worker-e2e-file.ts +98 -0
  74. package/src/lib/worker-e2e.ts +14 -51
  75. package/src/lib/worker-process.ts +69 -0
  76. package/src/lib/worker-results.ts +22 -0
  77. package/src/lib/worker-server.ts +123 -0
  78. package/src/lib/worker.ts +7 -47
  79. 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,3 @@
1
+ import type { TestResults } from './reporters/results.ts';
2
+ export declare function createFailedResults(error: unknown): TestResults;
3
+ //# sourceMappingURL=worker-results.d.ts.map
@@ -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
+ }
@@ -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 { runTests } from "./executor.js";
13
- import { importModule } from "./import-module.js";
14
- import { IS_BUN } from "./runtime.js";
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.2.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.1.0",
70
- "@remix-run/node-fetch-server": "^0.13.0",
71
- "@remix-run/ui": "^0.1.0"
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",
@@ -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
- console.error(`[remix-test] Unhandled error for ${req.url}:`, error)
26
+ logError(`Unhandled error for ${req.url}`, error)
24
27
  if (!res.headersSent) {
25
28
  res.writeHead(500, { 'Content-Type': 'text/plain' })
26
29
  }
@@ -31,7 +34,7 @@ export async function startServer(
31
34
  server.once('error', reject)
32
35
  server.listen(port, () => {
33
36
  server.removeListener('error', reject)
34
- console.log(`Test server running on http://localhost:${port}`)
37
+ log(`Test server running on http://localhost:${port}`)
35
38
  resolve()
36
39
  })
37
40
  })
@@ -39,7 +42,7 @@ export async function startServer(
39
42
  } catch (error: any) {
40
43
  if (error.code !== 'EADDRINUSE') throw error
41
44
  lastError = error
42
- console.log(`Port ${port} is in use, trying another port...`)
45
+ log(`Port ${port} is in use, trying another port...`)
43
46
  port += 1
44
47
  }
45
48
  }
@@ -99,7 +102,7 @@ function createRequestHandler(
99
102
  await serveScript(res, filePath, url.pathname, rootDir)
100
103
  return
101
104
  } catch (error) {
102
- console.error(`[remix-test] Error serving ${url.pathname}:`, error)
105
+ logError(`Error serving ${url.pathname}`, error)
103
106
  sendText(res, 500, String(error))
104
107
  return
105
108
  }
@@ -151,9 +154,8 @@ async function serveScript(
151
154
  let result = await transformTypeScript(source, filePath)
152
155
  code = result.code
153
156
  } catch (error) {
154
- let msg = error instanceof Error ? error.message : String(error)
155
- console.error(`[remix-test] Failed to transform ${urlPath}: ${msg}`)
156
- sendText(res, 500, msg)
157
+ logError(`Failed to transform ${urlPath}`, error)
158
+ sendText(res, 500, `Failed to transform ${urlPath}`)
157
159
  return
158
160
  }
159
161
  } else {
@@ -163,9 +165,8 @@ async function serveScript(
163
165
  try {
164
166
  code = await rewriteImports(code, filePath, rootDir)
165
167
  } catch (error) {
166
- let msg = error instanceof Error ? error.message : String(error)
167
- console.error(`[remix-test] Failed to rewrite imports for ${urlPath}: ${msg}`)
168
- sendText(res, 500, msg)
168
+ logError(`Failed to rewrite imports for ${urlPath}`, error)
169
+ sendText(res, 500, `Failed to rewrite imports for ${urlPath}`)
169
170
  return
170
171
  }
171
172
 
package/src/cli.ts CHANGED
@@ -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.split(',').map((project) => project.trim())
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(`No playwright projects found with name(s) "${config.project}"`)
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<typeof runBrowserTests>> | null = null
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
- browserFiles.length > 0
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.split(','))
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(pattern: string, excludePattern: string, cwd: string): Promise<string[]> {
362
- let files: string[] = []
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 glob = new Glob(pattern)
371
- let excludeGlob = new Glob(excludePattern)
372
- for await (let file of glob.scan({ cwd, absolute: true })) {
373
- if (!excludeGlob.match(path.relative(cwd, file))) {
374
- files.push(file)
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 await (let file of fsp.glob(pattern, { cwd, exclude: [excludePattern] })) {
381
- files.push(path.resolve(cwd, file))
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
@@ -1,4 +1,4 @@
1
- export type { RemixTestConfig } from './lib/config.ts'
1
+ export type { RemixTestConfig, RemixTestPool } from './lib/config.ts'
2
2
  export {
3
3
  describe,
4
4
  it,