@remix-run/test 0.1.0 → 0.2.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 (143) hide show
  1. package/README.md +140 -35
  2. package/dist/app/client/entry.d.ts +2 -0
  3. package/dist/app/client/entry.d.ts.map +1 -0
  4. package/dist/app/client/entry.js +324 -0
  5. package/dist/app/client/iframe.d.ts +2 -0
  6. package/dist/app/client/iframe.d.ts.map +1 -0
  7. package/dist/app/client/iframe.js +22 -0
  8. package/dist/app/server.d.ts +6 -0
  9. package/dist/app/server.d.ts.map +1 -0
  10. package/dist/app/server.js +303 -0
  11. package/dist/cli-entry.d.ts +3 -0
  12. package/dist/cli-entry.d.ts.map +1 -0
  13. package/dist/cli-entry.js +14 -0
  14. package/dist/cli.d.ts +7 -2
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +273 -139
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/lib/colors.d.ts +2 -0
  20. package/dist/lib/colors.d.ts.map +1 -0
  21. package/dist/lib/colors.js +2 -0
  22. package/dist/lib/config.d.ts +32 -1
  23. package/dist/lib/config.d.ts.map +1 -1
  24. package/dist/lib/config.js +125 -22
  25. package/dist/lib/context.d.ts +37 -13
  26. package/dist/lib/context.d.ts.map +1 -1
  27. package/dist/lib/context.js +19 -3
  28. package/dist/lib/coverage-loader.d.ts +16 -0
  29. package/dist/lib/coverage-loader.d.ts.map +1 -0
  30. package/dist/lib/coverage-loader.js +20 -0
  31. package/dist/lib/coverage.d.ts +28 -0
  32. package/dist/lib/coverage.d.ts.map +1 -0
  33. package/dist/lib/coverage.js +212 -0
  34. package/dist/lib/executor.d.ts +3 -26
  35. package/dist/lib/executor.d.ts.map +1 -1
  36. package/dist/lib/executor.js +11 -6
  37. package/dist/lib/fake-timers.d.ts +6 -0
  38. package/dist/lib/fake-timers.d.ts.map +1 -0
  39. package/dist/lib/fake-timers.js +45 -0
  40. package/dist/lib/import-module.d.ts +2 -0
  41. package/dist/lib/import-module.d.ts.map +1 -0
  42. package/dist/lib/import-module.js +29 -0
  43. package/dist/lib/normalize.d.ts +2 -0
  44. package/dist/lib/normalize.d.ts.map +1 -0
  45. package/dist/lib/{utils.js → normalize.js} +0 -9
  46. package/dist/lib/playwright.d.ts +1 -1
  47. package/dist/lib/playwright.d.ts.map +1 -1
  48. package/dist/lib/playwright.js +5 -8
  49. package/dist/lib/reporters/dot.d.ts +1 -2
  50. package/dist/lib/reporters/dot.d.ts.map +1 -1
  51. package/dist/lib/reporters/dot.js +2 -1
  52. package/dist/lib/reporters/files.d.ts +1 -2
  53. package/dist/lib/reporters/files.d.ts.map +1 -1
  54. package/dist/lib/reporters/files.js +2 -1
  55. package/dist/lib/reporters/index.d.ts +4 -5
  56. package/dist/lib/reporters/index.d.ts.map +1 -1
  57. package/dist/lib/reporters/index.js +3 -3
  58. package/dist/lib/reporters/results.d.ts +30 -0
  59. package/dist/lib/reporters/results.d.ts.map +1 -0
  60. package/dist/lib/reporters/results.js +1 -0
  61. package/dist/lib/reporters/spec.d.ts +1 -2
  62. package/dist/lib/reporters/spec.d.ts.map +1 -1
  63. package/dist/lib/reporters/spec.js +2 -1
  64. package/dist/lib/reporters/tap.d.ts +1 -2
  65. package/dist/lib/reporters/tap.d.ts.map +1 -1
  66. package/dist/lib/reporters/tap.js +1 -1
  67. package/dist/lib/runner-browser.d.ts +21 -0
  68. package/dist/lib/runner-browser.d.ts.map +1 -0
  69. package/dist/lib/runner-browser.js +117 -0
  70. package/dist/lib/runner.d.ts +7 -2
  71. package/dist/lib/runner.d.ts.map +1 -1
  72. package/dist/lib/runner.js +33 -4
  73. package/dist/lib/runtime.d.ts +2 -0
  74. package/dist/lib/runtime.d.ts.map +1 -0
  75. package/dist/lib/runtime.js +2 -0
  76. package/dist/lib/ts-transform.d.ts +4 -0
  77. package/dist/lib/ts-transform.d.ts.map +1 -0
  78. package/dist/lib/ts-transform.js +29 -0
  79. package/dist/lib/worker-e2e.js +5 -4
  80. package/dist/lib/worker.js +31 -3
  81. package/dist/test/coverage/fixture.d.ts +5 -0
  82. package/dist/test/coverage/fixture.d.ts.map +1 -0
  83. package/dist/test/coverage/fixture.js +32 -0
  84. package/dist/test/coverage/test-browser.d.ts +2 -0
  85. package/dist/test/coverage/test-browser.d.ts.map +1 -0
  86. package/dist/test/coverage/test-browser.js +24 -0
  87. package/dist/test/coverage/test-e2e.d.ts +2 -0
  88. package/dist/test/coverage/test-e2e.d.ts.map +1 -0
  89. package/dist/test/coverage/test-e2e.js +60 -0
  90. package/dist/test/coverage/test-unit.d.ts +2 -0
  91. package/dist/test/coverage/test-unit.d.ts.map +1 -0
  92. package/dist/test/coverage/test-unit.js +27 -0
  93. package/dist/test/framework.test.browser.d.ts +2 -0
  94. package/dist/test/framework.test.browser.d.ts.map +1 -0
  95. package/dist/test/framework.test.browser.js +107 -0
  96. package/dist/test/framework.test.e2e.d.ts.map +1 -0
  97. package/dist/test/framework.test.e2e.js +34 -0
  98. package/package.json +30 -9
  99. package/src/app/client/entry.ts +353 -0
  100. package/src/app/client/iframe.ts +18 -0
  101. package/src/app/server.ts +336 -0
  102. package/src/cli-entry.ts +15 -0
  103. package/src/cli.ts +322 -148
  104. package/src/index.ts +1 -0
  105. package/src/lib/colors.ts +3 -0
  106. package/src/lib/config.ts +169 -23
  107. package/src/lib/context.ts +59 -17
  108. package/src/lib/coverage-loader.ts +31 -0
  109. package/src/lib/coverage.ts +320 -0
  110. package/src/lib/executor.ts +18 -35
  111. package/src/lib/fake-timers.ts +64 -0
  112. package/src/lib/import-module.ts +29 -0
  113. package/src/lib/{utils.ts → normalize.ts} +0 -18
  114. package/src/lib/playwright.ts +5 -7
  115. package/src/lib/reporters/dot.ts +3 -2
  116. package/src/lib/reporters/files.ts +3 -2
  117. package/src/lib/reporters/index.ts +4 -5
  118. package/src/lib/reporters/results.ts +29 -0
  119. package/src/lib/reporters/spec.ts +3 -2
  120. package/src/lib/reporters/tap.ts +2 -2
  121. package/src/lib/runner-browser.ts +165 -0
  122. package/src/lib/runner.ts +62 -10
  123. package/src/lib/runtime.ts +2 -0
  124. package/src/lib/ts-transform.ts +36 -0
  125. package/src/lib/worker-e2e.ts +7 -5
  126. package/src/lib/worker.ts +24 -4
  127. package/src/test/coverage/fixture.ts +34 -0
  128. package/src/test/coverage/test-browser.ts +29 -0
  129. package/src/test/coverage/test-e2e.ts +70 -0
  130. package/src/test/coverage/test-unit.ts +32 -0
  131. package/tsconfig.json +3 -1
  132. package/dist/lib/e2e-server.d.ts +0 -11
  133. package/dist/lib/e2e-server.d.ts.map +0 -1
  134. package/dist/lib/e2e-server.js +0 -15
  135. package/dist/lib/framework.test.d.ts +0 -2
  136. package/dist/lib/framework.test.d.ts.map +0 -1
  137. package/dist/lib/framework.test.e2e.d.ts.map +0 -1
  138. package/dist/lib/framework.test.e2e.js +0 -29
  139. package/dist/lib/framework.test.js +0 -283
  140. package/dist/lib/utils.d.ts +0 -16
  141. package/dist/lib/utils.d.ts.map +0 -1
  142. package/src/lib/e2e-server.ts +0 -28
  143. /package/dist/{lib → test}/framework.test.e2e.d.ts +0 -0
@@ -1,8 +1,31 @@
1
+ import * as fsp from 'node:fs/promises';
1
2
  import * as os from 'node:os';
2
3
  import * as path from 'node:path';
3
- import * as fsp from 'node:fs/promises';
4
+ import { fileURLToPath } from 'node:url';
4
5
  import * as util from 'node:util';
5
- import { tsImport } from 'tsx/esm/api';
6
+ import { importModule } from "./import-module.js";
7
+ export const IS_RUNNING_FROM_SRC = path.extname(new URL(import.meta.url).pathname) === '.ts';
8
+ /*
9
+ * The root directory for the test code. Coverage URLs are emitted as
10
+ * `/scripts/<rel-from-rootDir>` and resolved back via the same anchor.
11
+ *
12
+ * - In a published install: `process.cwd()`, since deps and user source all
13
+ * live under it.
14
+ * - In monorepo src mode: the monorepo root, computed by walking back from
15
+ * the resolved `@remix-run/test` source path. `process.cwd()` doesn't work
16
+ * here because workspace deps and node_modules live above the per-package
17
+ * cwd.
18
+ */
19
+ export function getBrowserTestRootDir() {
20
+ return IS_RUNNING_FROM_SRC
21
+ ? // Resolve to packages/test/src/index.ts and the pop 3 directories off to the repo root
22
+ path
23
+ .dirname(fileURLToPath(import.meta.resolve('@remix-run/test')))
24
+ .split(path.sep)
25
+ .slice(0, -3)
26
+ .join(path.sep)
27
+ : process.cwd();
28
+ }
6
29
  // prettier-ignore
7
30
  // Note: `description` is not a field used by parseArgs(), it's an additional field
8
31
  // we use for `--help`
@@ -15,10 +38,18 @@ const cliOptions = {
15
38
  type: 'boolean',
16
39
  description: 'Open browser window and keep open after tests finish',
17
40
  },
41
+ 'glob.browser': {
42
+ type: 'string',
43
+ description: 'Glob pattern for browser test files',
44
+ },
18
45
  'glob.e2e': {
19
46
  type: 'string',
20
47
  description: 'Glob pattern for E2E test files',
21
48
  },
49
+ 'glob.exclude': {
50
+ type: 'string',
51
+ description: 'Glob pattern for paths to exclude from discovery',
52
+ },
22
53
  'glob.test': {
23
54
  type: 'string',
24
55
  description: 'Glob pattern for all test files',
@@ -32,6 +63,40 @@ const cliOptions = {
32
63
  type: 'string',
33
64
  description: 'Path to config file (default: remix-test.config.ts)',
34
65
  },
66
+ coverage: {
67
+ type: 'boolean',
68
+ description: 'Enable or disable coverage collection (default: false)',
69
+ },
70
+ 'coverage.dir': {
71
+ type: 'string',
72
+ description: 'Directory to output coverage reports (default: .coverage)',
73
+ },
74
+ 'coverage.include': {
75
+ type: 'string',
76
+ multiple: true,
77
+ description: 'Glob pattern(s) for files to include in coverage',
78
+ },
79
+ 'coverage.exclude': {
80
+ type: 'string',
81
+ multiple: true,
82
+ description: 'Glob pattern(s) for files to exclude from coverage',
83
+ },
84
+ 'coverage.branches': {
85
+ type: 'string',
86
+ description: 'Branches coverage threshold percentage',
87
+ },
88
+ 'coverage.functions': {
89
+ type: 'string',
90
+ description: 'Functions coverage threshold percentage',
91
+ },
92
+ 'coverage.lines': {
93
+ type: 'string',
94
+ description: 'Lines coverage threshold percentage',
95
+ },
96
+ 'coverage.statements': {
97
+ type: 'string',
98
+ description: 'Statements coverage threshold percentage',
99
+ },
35
100
  setup: {
36
101
  type: 'string',
37
102
  short: 's',
@@ -54,7 +119,7 @@ const cliOptions = {
54
119
  type: {
55
120
  type: 'string',
56
121
  short: 't',
57
- description: 'Comma-separated test types to run (default: server,e2e)',
122
+ description: 'Comma-separated test types to run (default: server,browser,e2e)',
58
123
  },
59
124
  watch: {
60
125
  type: 'boolean',
@@ -68,28 +133,35 @@ const defaultValues = {
68
133
  open: false,
69
134
  },
70
135
  concurrency: os.availableParallelism(),
136
+ coverage: {
137
+ dir: '.coverage',
138
+ include: undefined,
139
+ exclude: undefined,
140
+ statements: undefined,
141
+ lines: undefined,
142
+ branches: undefined,
143
+ functions: undefined,
144
+ },
71
145
  glob: {
72
- test: '**/*.test?(.e2e).{ts,tsx}',
146
+ test: '**/*.test{,.e2e,.browser}.{ts,tsx}',
147
+ browser: '**/*.test.browser.{ts,tsx}',
73
148
  e2e: '**/*.test.e2e.{ts,tsx}',
149
+ exclude: 'node_modules/**',
74
150
  },
75
- reporter: process.env.CI === 'true' ? 'dot' : 'spec',
76
- type: 'server,e2e',
151
+ reporter: process.env.CI === 'true' ? 'files' : 'spec',
152
+ type: 'server,browser,e2e',
77
153
  setup: undefined,
78
154
  playwrightConfig: undefined,
79
155
  project: undefined,
80
156
  watch: false,
81
157
  };
82
- export async function loadConfig() {
83
- if (process.argv.includes('--help') || process.argv.includes('-h')) {
84
- console.log(generateHelp());
85
- process.exit(0);
86
- }
87
- let parsed = parseCliArgs();
88
- let fileConfig = await loadConfigFile(parsed.values.config);
158
+ export async function loadConfig(args = process.argv.slice(2), cwd = process.cwd()) {
159
+ let parsed = parseCliArgs(args);
160
+ let fileConfig = await loadConfigFile(parsed.values.config, cwd);
89
161
  let config = resolveConfig(fileConfig, parsed);
90
162
  return config;
91
163
  }
92
- function generateHelp() {
164
+ export function getRemixTestHelpText(_target = process.stdout) {
93
165
  let lines = [
94
166
  'Usage: remix-test [glob] [options]',
95
167
  '',
@@ -106,23 +178,57 @@ function generateHelp() {
106
178
  lines.push(` ${'-h, --help'.padEnd(30)} Show this help message`);
107
179
  return lines.join('\n');
108
180
  }
109
- function parseCliArgs(args = process.argv.slice(2)) {
181
+ function parseCliArgs(args) {
110
182
  return util.parseArgs({ args, options: cliOptions, allowPositionals: true });
111
183
  }
112
184
  function resolveConfig(fileConfig, { values: cliValues, positionals }) {
185
+ let fileCoverage = typeof fileConfig.coverage === 'boolean' ? {} : fileConfig.coverage || {};
113
186
  return {
114
187
  glob: {
115
188
  test: positionals[0] ??
116
189
  cliValues['glob.test'] ??
117
190
  fileConfig.glob?.test ??
118
191
  defaultValues.glob.test,
192
+ browser: cliValues['glob.browser'] ?? fileConfig.glob?.browser ?? defaultValues.glob.browser,
119
193
  e2e: cliValues['glob.e2e'] ?? fileConfig.glob?.e2e ?? defaultValues.glob.e2e,
194
+ exclude: cliValues['glob.exclude'] ?? fileConfig.glob?.exclude ?? defaultValues.glob.exclude,
120
195
  },
121
196
  browser: {
122
197
  echo: cliValues['browser.echo'] ?? fileConfig.browser?.echo ?? defaultValues.browser.echo,
123
198
  open: cliValues['browser.open'] ?? fileConfig.browser?.open ?? defaultValues.browser.open,
124
199
  },
125
200
  concurrency: Number(cliValues.concurrency ?? fileConfig.concurrency ?? defaultValues.concurrency),
201
+ coverage: cliValues.coverage === true || !!fileConfig.coverage
202
+ ? {
203
+ dir: cliValues['coverage.dir'] ?? fileCoverage.dir ?? defaultValues.coverage.dir,
204
+ include: cliValues['coverage.include'] ??
205
+ fileCoverage.include ??
206
+ defaultValues.coverage.include,
207
+ exclude: cliValues['coverage.exclude'] ??
208
+ fileCoverage.exclude ??
209
+ defaultValues.coverage.exclude,
210
+ statements: cliValues['coverage.statements'] !== undefined
211
+ ? Number(cliValues['coverage.statements'])
212
+ : fileCoverage.statements !== undefined
213
+ ? Number(fileCoverage.statements)
214
+ : undefined,
215
+ lines: cliValues['coverage.lines'] !== undefined
216
+ ? Number(cliValues['coverage.lines'])
217
+ : fileCoverage.lines !== undefined
218
+ ? Number(fileCoverage.lines)
219
+ : undefined,
220
+ branches: cliValues['coverage.branches'] !== undefined
221
+ ? Number(cliValues['coverage.branches'])
222
+ : fileCoverage.branches !== undefined
223
+ ? Number(fileCoverage.branches)
224
+ : undefined,
225
+ functions: cliValues['coverage.functions'] !== undefined
226
+ ? Number(cliValues['coverage.functions'])
227
+ : fileCoverage.functions !== undefined
228
+ ? Number(fileCoverage.functions)
229
+ : undefined,
230
+ }
231
+ : undefined,
126
232
  setup: cliValues.setup ?? fileConfig.setup ?? defaultValues.setup,
127
233
  playwrightConfig: cliValues.playwrightConfig ?? fileConfig.playwrightConfig ?? defaultValues.playwrightConfig,
128
234
  project: cliValues.project ?? fileConfig.project ?? defaultValues.project,
@@ -131,17 +237,14 @@ function resolveConfig(fileConfig, { values: cliValues, positionals }) {
131
237
  watch: cliValues.watch ?? fileConfig.watch ?? defaultValues.watch,
132
238
  };
133
239
  }
134
- async function loadConfigFile(configPath) {
240
+ async function loadConfigFile(configPath, cwd) {
135
241
  let candidates = configPath
136
- ? [path.resolve(process.cwd(), configPath)]
137
- : [
138
- path.join(process.cwd(), 'remix-test.config.ts'),
139
- path.join(process.cwd(), 'remix-test.config.js'),
140
- ];
242
+ ? [path.resolve(cwd, configPath)]
243
+ : [path.join(cwd, 'remix-test.config.ts'), path.join(cwd, 'remix-test.config.js')];
141
244
  for (let candidate of candidates) {
142
245
  try {
143
246
  await fsp.access(candidate);
144
- let mod = await tsImport(candidate, { parentURL: import.meta.url });
247
+ let mod = await importModule(candidate, import.meta);
145
248
  return mod.default ?? mod;
146
249
  }
147
250
  catch {
@@ -1,7 +1,17 @@
1
1
  import type { Browser, Page } from 'playwright';
2
- import { type MockFunction, type MockCall, type MockContext } from './mock.ts';
3
- import type { CreateServerFunction } from './e2e-server.ts';
2
+ import type { V8CoverageEntry } from './coverage.ts';
3
+ import { type FakeTimers } from './fake-timers.ts';
4
+ import { type MockCall, type MockContext, type MockFunction } from './mock.ts';
4
5
  import type { getPlaywrightPageOptions } from './playwright.ts';
6
+ /**
7
+ * The shape `t.serve()` consumes. Matches the result of `createTestServer`
8
+ * from `@remix-run/node-fetch-server/test`, but any object with a `baseUrl`
9
+ * and async `close()` works.
10
+ */
11
+ export interface TestServer {
12
+ baseUrl: string;
13
+ close(): Promise<void>;
14
+ }
5
15
  /**
6
16
  * Test Context providing utilities for testing via remix-test. The context is
7
17
  * passed as the first argument to the {@link test}/{@link it} functions.
@@ -49,21 +59,35 @@ export interface TestContext {
49
59
  method<T extends object, K extends keyof T>(obj: T, methodName: K, impl?: Function): MockFunction;
50
60
  };
51
61
  /**
52
- * Starts a test server with the provided request handler.
62
+ * Activates fake timers for testing time-dependent code.
53
63
  *
54
- * @param {(req: Request) => Promise<Response>} handler - Function handling incoming requests
55
- * @returns {Promise<Page>} A promise resolving to a page instance for the server
64
+ * @returns {FakeTimers} A fake timers instance for controlling time
56
65
  */
57
- serve(handler: (req: Request) => Promise<Response>): Promise<Page>;
66
+ useFakeTimers(): FakeTimers;
67
+ /**
68
+ * Wires a running test server up to a Playwright page so the test can drive
69
+ * it. The server is closed automatically when the test ends. Pair with
70
+ * `createTestServer` from `@remix-run/node-fetch-server/test` (or any other
71
+ * source of a `{ baseUrl, close }` handle) to spin up the server first.
72
+ *
73
+ * @param server - The running server the page should target
74
+ * @returns A `Page` whose `baseURL` is set to `server.baseUrl`.
75
+ */
76
+ serve(server: TestServer): Promise<Page>;
77
+ }
78
+ export interface CreateTestContextOptions {
79
+ addE2ECoverageEntries: (value: {
80
+ entries: V8CoverageEntry[];
81
+ baseUrl: string;
82
+ }) => void;
83
+ browser: Browser;
84
+ coverage: boolean;
85
+ open: boolean;
86
+ playwrightPageOptions: ReturnType<typeof getPlaywrightPageOptions>;
58
87
  }
59
- export declare function createTestContext(options: {
60
- createServer?: CreateServerFunction;
61
- browser?: Browser;
62
- open?: boolean;
63
- playwrightPageOptions?: ReturnType<typeof getPlaywrightPageOptions>;
64
- }): {
88
+ export declare function createTestContext(options?: CreateTestContextOptions): {
65
89
  testContext: TestContext;
66
90
  cleanup(): Promise<void>;
67
91
  };
68
- export type { MockFunction, MockCall, MockContext };
92
+ export type { MockCall, MockContext, MockFunction };
69
93
  //# sourceMappingURL=context.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../../src/lib/context.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAA;AAC/C,OAAO,EAAQ,KAAK,YAAY,EAAE,KAAK,QAAQ,EAAE,KAAK,WAAW,EAAE,MAAM,WAAW,CAAA;AAEpF,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAA;AAC3D,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,iBAAiB,CAAA;AAE/D;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,WAAW;IAC1B;;;;;OAKG;IACH,KAAK,CAAC,EAAE,EAAE,MAAM,IAAI,GAAG,IAAI,CAAA;IAE3B;;;OAGG;IACH,IAAI,EAAE;QACJ;;;;;;WAMG;QACH,EAAE,CAAC,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,EAAE,IAAI,CAAC,EAAE,CAAC,GAAG,YAAY,CAAC,CAAC,CAAC,CAAA;QAEhE;;;;;;;;;;WAUG;QACH,MAAM,CAAC,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,MAAM,CAAC,EACxC,GAAG,EAAE,CAAC,EACN,UAAU,EAAE,CAAC,EACb,IAAI,CAAC,EAAE,QAAQ,GACd,YAAY,CAAA;KAChB,CAAA;IAED;;;;;OAKG;IACH,KAAK,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CACnE;AAED,wBAAgB,iBAAiB,CAAC,OAAO,EAAE;IACzC,YAAY,CAAC,EAAE,oBAAoB,CAAA;IACnC,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,qBAAqB,CAAC,EAAE,UAAU,CAAC,OAAO,wBAAwB,CAAC,CAAA;CACpE,GAAG;IAAE,WAAW,EAAE,WAAW,CAAC;IAAC,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CAAE,CAkDzD;AAED,YAAY,EAAE,YAAY,EAAE,QAAQ,EAAE,WAAW,EAAE,CAAA"}
1
+ {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../../src/lib/context.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAA;AAC/C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AACpD,OAAO,EAAoB,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAA;AACpE,OAAO,EAAQ,KAAK,QAAQ,EAAE,KAAK,WAAW,EAAE,KAAK,YAAY,EAAE,MAAM,WAAW,CAAA;AACpF,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,iBAAiB,CAAA;AAE/D;;;;GAIG;AACH,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CACvB;AAED;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,WAAW;IAC1B;;;;;OAKG;IACH,KAAK,CAAC,EAAE,EAAE,MAAM,IAAI,GAAG,IAAI,CAAA;IAE3B;;;OAGG;IACH,IAAI,EAAE;QACJ;;;;;;WAMG;QACH,EAAE,CAAC,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,EAAE,IAAI,CAAC,EAAE,CAAC,GAAG,YAAY,CAAC,CAAC,CAAC,CAAA;QAEhE;;;;;;;;;;WAUG;QACH,MAAM,CAAC,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,MAAM,CAAC,EACxC,GAAG,EAAE,CAAC,EACN,UAAU,EAAE,CAAC,EACb,IAAI,CAAC,EAAE,QAAQ,GACd,YAAY,CAAA;KAChB,CAAA;IAED;;;;OAIG;IACH,aAAa,IAAI,UAAU,CAAA;IAE3B;;;;;;;;OAQG;IACH,KAAK,CAAC,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CACzC;AAED,MAAM,WAAW,wBAAwB;IACvC,qBAAqB,EAAE,CAAC,KAAK,EAAE;QAAE,OAAO,EAAE,eAAe,EAAE,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAA;IACvF,OAAO,EAAE,OAAO,CAAA;IAChB,QAAQ,EAAE,OAAO,CAAA;IACjB,IAAI,EAAE,OAAO,CAAA;IACb,qBAAqB,EAAE,UAAU,CAAC,OAAO,wBAAwB,CAAC,CAAA;CACnE;AAED,wBAAgB,iBAAiB,CAAC,OAAO,CAAC,EAAE,wBAAwB,GAAG;IACrE,WAAW,EAAE,WAAW,CAAA;IACxB,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CACzB,CAkEA;AAED,YAAY,EAAE,QAAQ,EAAE,WAAW,EAAE,YAAY,EAAE,CAAA"}
@@ -1,3 +1,4 @@
1
+ import { createFakeTimers } from "./fake-timers.js";
1
2
  import { mock } from "./mock.js";
2
3
  export function createTestContext(options) {
3
4
  let cleanups = [];
@@ -14,11 +15,15 @@ export function createTestContext(options) {
14
15
  after(fn) {
15
16
  cleanups.push(fn);
16
17
  },
17
- async serve(handler) {
18
- if (!options.createServer || !options.browser) {
18
+ useFakeTimers() {
19
+ let timers = createFakeTimers();
20
+ cleanups.push(timers.restore);
21
+ return timers;
22
+ },
23
+ async serve(server) {
24
+ if (!options || !options.browser) {
19
25
  throw new Error('t.serve() is only available in E2E test suites');
20
26
  }
21
- let server = await options.createServer(handler);
22
27
  let page = await options.browser.newPage({
23
28
  ...options.playwrightPageOptions,
24
29
  baseURL: server.baseUrl,
@@ -29,6 +34,17 @@ export function createTestContext(options) {
29
34
  if (options.playwrightPageOptions?.actionTimeout != null) {
30
35
  page.setDefaultTimeout(options.playwrightPageOptions.actionTimeout);
31
36
  }
37
+ let coverageEnabled = options.coverage && options.browser.browserType().name() === 'chromium';
38
+ if (coverageEnabled) {
39
+ await page.coverage.startJSCoverage({ resetOnNavigation: false });
40
+ cleanups.push(async () => {
41
+ let entries = await page.coverage.stopJSCoverage();
42
+ options.addE2ECoverageEntries?.({
43
+ entries: entries,
44
+ baseUrl: server.baseUrl,
45
+ });
46
+ });
47
+ }
32
48
  cleanups.push(async () => {
33
49
  if (!options.open) {
34
50
  await page.close();
@@ -0,0 +1,16 @@
1
+ export declare function load(url: string, context: {
2
+ format?: string;
3
+ }, nextLoad: (url: string, context: {
4
+ format?: string;
5
+ }) => Promise<{
6
+ format: string;
7
+ source: string;
8
+ }>): Promise<{
9
+ format: string;
10
+ source: string;
11
+ } | {
12
+ format: string;
13
+ source: string;
14
+ shortCircuit: boolean;
15
+ }>;
16
+ //# sourceMappingURL=coverage-loader.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"coverage-loader.d.ts","sourceRoot":"","sources":["../../src/lib/coverage-loader.ts"],"names":[],"mappings":"AAYA,wBAAsB,IAAI,CACxB,GAAG,EAAE,MAAM,EACX,OAAO,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,EAC5B,QAAQ,EAAE,CACR,GAAG,EAAE,MAAM,EACX,OAAO,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,KACzB,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;;;;;;;GAYjD"}
@@ -0,0 +1,20 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { transformTypeScript } from "./ts-transform.js";
4
+ // Custom ESM loader hook for TypeScript files.
5
+ //
6
+ // Replaces tsx's minified transformation with an un-minified esbuild transform
7
+ // that preserves line structure. This ensures V8 coverage byte offsets map
8
+ // cleanly to TypeScript source lines via the inline source map, giving
9
+ // accurate per-line coverage rather than collapsing multiple statements onto
10
+ // a single minified line.
11
+ export async function load(url, context, nextLoad) {
12
+ let cleanUrl = url.includes('?') ? url.slice(0, url.indexOf('?')) : url;
13
+ if (!cleanUrl.endsWith('.ts') && !cleanUrl.endsWith('.tsx')) {
14
+ return nextLoad(url, context);
15
+ }
16
+ let filePath = fileURLToPath(cleanUrl);
17
+ let source = await readFile(filePath, 'utf-8');
18
+ let { code } = await transformTypeScript(source, filePath);
19
+ return { format: 'module', source: code, shortCircuit: true };
20
+ }
@@ -0,0 +1,28 @@
1
+ import type { createCoverageMap as CreateCoverageMap } from 'istanbul-lib-coverage';
2
+ export interface CoverageConfig {
3
+ dir: string;
4
+ include?: string[];
5
+ exclude?: string[];
6
+ statements?: number;
7
+ lines?: number;
8
+ branches?: number;
9
+ functions?: number;
10
+ }
11
+ export interface V8CoverageEntry {
12
+ url: string;
13
+ source?: string;
14
+ functions: Array<{
15
+ functionName: string;
16
+ isBlockCoverage: boolean;
17
+ ranges: Array<{
18
+ startOffset: number;
19
+ endOffset: number;
20
+ count: number;
21
+ }>;
22
+ }>;
23
+ }
24
+ export type CoverageMap = ReturnType<typeof CreateCoverageMap>;
25
+ export declare function collectServerCoverageMap(coverageDataDir: string, cwd: string, testFiles: Set<string>): Promise<CoverageMap | null>;
26
+ export declare function collectCoverageMapFromPlaywright(entries: V8CoverageEntry[], rootDir: string, testFiles: Set<string>, resolveRelativePath: (url: string) => Promise<string | null>): Promise<CoverageMap | null>;
27
+ export declare function generateCombinedCoverageReport(maps: (CoverageMap | null | undefined)[], cwd: string, config: CoverageConfig): Promise<boolean>;
28
+ //# sourceMappingURL=coverage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"coverage.d.ts","sourceRoot":"","sources":["../../src/lib/coverage.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,IAAI,iBAAiB,EAAE,MAAM,uBAAuB,CAAA;AAsCnF,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAA;IACX,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,eAAe;IAC9B,GAAG,EAAE,MAAM,CAAA;IACX,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,SAAS,EAAE,KAAK,CAAC;QACf,YAAY,EAAE,MAAM,CAAA;QACpB,eAAe,EAAE,OAAO,CAAA;QACxB,MAAM,EAAE,KAAK,CAAC;YAAE,WAAW,EAAE,MAAM,CAAC;YAAC,SAAS,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,MAAM,CAAA;SAAE,CAAC,CAAA;KACzE,CAAC,CAAA;CACH;AAED,MAAM,MAAM,WAAW,GAAG,UAAU,CAAC,OAAO,iBAAiB,CAAC,CAAA;AAmI9D,wBAAsB,wBAAwB,CAC5C,eAAe,EAAE,MAAM,EACvB,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,GAAG,CAAC,MAAM,CAAC,GACrB,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAuD7B;AAED,wBAAsB,gCAAgC,CACpD,OAAO,EAAE,eAAe,EAAE,EAC1B,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,GAAG,CAAC,MAAM,CAAC,EACtB,mBAAmB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,GAC3D,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CA2C7B;AAED,wBAAsB,8BAA8B,CAClD,IAAI,EAAE,CAAC,WAAW,GAAG,IAAI,GAAG,SAAS,CAAC,EAAE,EACxC,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,cAAc,GACrB,OAAO,CAAC,OAAO,CAAC,CAelB"}
@@ -0,0 +1,212 @@
1
+ import * as fsp from 'node:fs/promises';
2
+ import { createRequire } from 'node:module';
3
+ import * as path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { colors } from "./colors.js";
6
+ import { transformTypeScript } from "./ts-transform.js";
7
+ // Istanbul packages are loaded lazily so that FORCE_COLOR can be set based on
8
+ // the actual TTY state before supports-color caches its detection result.
9
+ let _istanbul;
10
+ function getIstanbul() {
11
+ if (!_istanbul) {
12
+ process.env.FORCE_COLOR ??= process.stdout.isTTY ? '1' : '0';
13
+ let require = createRequire(import.meta.url);
14
+ _istanbul = {
15
+ V8ToIstanbul: require('v8-to-istanbul'),
16
+ createCoverageMap: require('istanbul-lib-coverage').createCoverageMap,
17
+ createContext: require('istanbul-lib-report')
18
+ .createContext,
19
+ reports: require('istanbul-reports'),
20
+ };
21
+ }
22
+ return _istanbul;
23
+ }
24
+ function matchesGlobs(filePath, globs) {
25
+ return globs.some((glob) => path.matchesGlob(filePath, glob));
26
+ }
27
+ function filterCoverageMap(coverageMap, cwd, config) {
28
+ let filtered = getIstanbul().createCoverageMap({});
29
+ for (let filePath of coverageMap.files()) {
30
+ let relative = path.relative(cwd, filePath);
31
+ if (config.include && config.include.length > 0) {
32
+ if (!matchesGlobs(relative, config.include))
33
+ continue;
34
+ }
35
+ if (config.exclude && config.exclude.length > 0) {
36
+ if (matchesGlobs(relative, config.exclude))
37
+ continue;
38
+ }
39
+ let fc = coverageMap.fileCoverageFor(filePath);
40
+ filtered.addFileCoverage({ ...fc.toJSON(), path: relative });
41
+ }
42
+ return filtered;
43
+ }
44
+ function checkThresholds(coverageMap, config) {
45
+ let { statements, lines, branches, functions } = config;
46
+ if (statements === undefined &&
47
+ lines === undefined &&
48
+ branches === undefined &&
49
+ functions === undefined)
50
+ return true;
51
+ let summary = coverageMap.getCoverageSummary();
52
+ let passed = true;
53
+ if (statements !== undefined) {
54
+ let pct = summary.statements.pct;
55
+ if (pct < statements) {
56
+ console.error(colors.red(`\nError: Coverage threshold not met (statements ${pct.toFixed(2)}% < ${statements}%)`));
57
+ passed = false;
58
+ }
59
+ }
60
+ if (lines !== undefined) {
61
+ let pct = summary.lines.pct;
62
+ if (pct < lines) {
63
+ console.error(colors.red(`\nError: Coverage threshold not met (lines ${pct.toFixed(2)}% < ${lines}%)`));
64
+ passed = false;
65
+ }
66
+ }
67
+ if (branches !== undefined) {
68
+ let pct = summary.branches.pct;
69
+ if (pct < branches) {
70
+ console.error(colors.red(`\nError: Coverage threshold not met (branches ${pct.toFixed(2)}% < ${branches}%)`));
71
+ passed = false;
72
+ }
73
+ }
74
+ if (functions !== undefined) {
75
+ let pct = summary.functions.pct;
76
+ if (pct < functions) {
77
+ console.error(colors.red(`\nError: Coverage threshold not met (functions ${pct.toFixed(2)}% < ${functions}%)`));
78
+ passed = false;
79
+ }
80
+ }
81
+ return passed;
82
+ }
83
+ async function writeIstanbulReports(coverageMap, cwd, outDir) {
84
+ await fsp.mkdir(outDir, { recursive: true });
85
+ let { createContext, reports } = getIstanbul();
86
+ let ctx = createContext({ coverageMap, dir: outDir });
87
+ console.log('\nCoverage report:');
88
+ reports.create('text').execute(ctx);
89
+ reports.create('lcovonly').execute(ctx);
90
+ console.log(`\nLCOV coverage written to ${path.relative(cwd, path.join(outDir, 'lcov.info'))}`);
91
+ }
92
+ // Convert a single V8 coverage entry to Istanbul format and merge it into the
93
+ // coverage map.
94
+ //
95
+ // V8 reports byte offsets against the JS bytes it actually instrumented. When
96
+ // the entry already carries that source (Playwright's `coverage.stopJSCoverage`
97
+ // returns it on each entry, including the inline source map), we hand it
98
+ // straight to v8-to-istanbul so the offsets line up exactly. The server path
99
+ // uses Node's `NODE_V8_COVERAGE` JSON, which doesn't include source — there we
100
+ // re-derive by re-running our esbuild transform on the original TS file.
101
+ async function addV8EntryToCoverageMap(coverageMap, filePath, functions, source) {
102
+ let { V8ToIstanbul } = getIstanbul();
103
+ let converter = new V8ToIstanbul(filePath, undefined, { source });
104
+ await converter.load();
105
+ converter.applyCoverage(functions);
106
+ coverageMap.merge(converter.toIstanbul());
107
+ return true;
108
+ }
109
+ function shouldExcludeFromCoverage(filePath, rootDir, testFiles) {
110
+ return (!filePath.startsWith(rootDir + path.sep) ||
111
+ filePath.includes(`${path.sep}node_modules${path.sep}`) ||
112
+ testFiles.has(filePath));
113
+ }
114
+ export async function collectServerCoverageMap(coverageDataDir, cwd, testFiles) {
115
+ let { createCoverageMap } = getIstanbul();
116
+ let coverageMap = createCoverageMap({});
117
+ let converted = 0;
118
+ let files;
119
+ try {
120
+ files = (await fsp.readdir(coverageDataDir)).filter((f) => f.startsWith('coverage-') && f.endsWith('.json'));
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ for (let file of files) {
126
+ let data = JSON.parse(await fsp.readFile(path.join(coverageDataDir, file), 'utf-8'));
127
+ let scriptCoverages = data.result ?? [];
128
+ for (let entry of scriptCoverages) {
129
+ if (!entry.url.startsWith('file://'))
130
+ continue;
131
+ let filePath;
132
+ try {
133
+ filePath = fileURLToPath(entry.url);
134
+ }
135
+ catch {
136
+ continue;
137
+ }
138
+ if (!filePath ||
139
+ !['.ts', '.tsx'].includes(path.extname(filePath)) ||
140
+ shouldExcludeFromCoverage(filePath, cwd, testFiles)) {
141
+ continue;
142
+ }
143
+ try {
144
+ // For server unit tests, we transform the TS with a module loader and V8 tracks
145
+ // coverage using byte offsets from the transformed JS. Re-transform with the
146
+ // same `esbuild` call here so offsets align, then pass the result with its
147
+ // inline source map to v8-to-istanbul.
148
+ let tsSource = await fsp.readFile(filePath, 'utf-8');
149
+ let { code } = await transformTypeScript(tsSource, filePath);
150
+ let success = await addV8EntryToCoverageMap(coverageMap, filePath, entry.functions, code);
151
+ if (success)
152
+ converted++;
153
+ }
154
+ catch (e) {
155
+ // Skip files that can't be converted
156
+ }
157
+ }
158
+ }
159
+ // Clean up raw V8 coverage JSON files now that we've processed them
160
+ //await Promise.all(files.map((f) => fsp.rm(path.join(coverageDataDir, f), { force: true })))
161
+ return converted > 0 ? coverageMap : null;
162
+ }
163
+ export async function collectCoverageMapFromPlaywright(entries, rootDir, testFiles, resolveRelativePath) {
164
+ let { createCoverageMap } = getIstanbul();
165
+ let coverageMap = createCoverageMap({});
166
+ let converted = 0;
167
+ for (let entry of entries) {
168
+ let filePath;
169
+ try {
170
+ let relativePath = await resolveRelativePath(new URL(entry.url).pathname);
171
+ if (!relativePath)
172
+ continue;
173
+ // Ignore entries outside the root dir, entries in node_modules, and test files
174
+ filePath = path.resolve(rootDir, relativePath);
175
+ if (shouldExcludeFromCoverage(filePath, rootDir, testFiles)) {
176
+ continue;
177
+ }
178
+ // Ensure file exists
179
+ await fsp.access(filePath);
180
+ }
181
+ catch {
182
+ continue;
183
+ }
184
+ if (!entry.source) {
185
+ throw new Error(`Entry for ${entry.url} is missing source, cannot convert coverage. Ensure the browser launched with Playwright's JS coverage enabled.`);
186
+ }
187
+ try {
188
+ let success = await addV8EntryToCoverageMap(coverageMap, filePath, entry.functions, entry.source);
189
+ if (success)
190
+ converted++;
191
+ }
192
+ catch {
193
+ // Skip files that can't be converted
194
+ }
195
+ }
196
+ return converted > 0 ? coverageMap : null;
197
+ }
198
+ export async function generateCombinedCoverageReport(maps, cwd, config) {
199
+ let { createCoverageMap } = getIstanbul();
200
+ let combined = createCoverageMap({});
201
+ for (let map of maps) {
202
+ if (map)
203
+ combined.merge(map);
204
+ }
205
+ if (combined.files().length === 0) {
206
+ console.log('No coverage data collected.');
207
+ return true;
208
+ }
209
+ let filtered = filterCoverageMap(combined, cwd, config);
210
+ await writeIstanbulReports(filtered, cwd, config.dir);
211
+ return checkThresholds(filtered, config);
212
+ }
@@ -1,27 +1,4 @@
1
- import type { Browser, BrowserContextOptions } from 'playwright';
2
- import type { CreateServerFunction } from './e2e-server.ts';
3
- export interface TestResult {
4
- name: string;
5
- suiteName: string;
6
- filePath?: string;
7
- status: 'passed' | 'failed' | 'skipped' | 'todo';
8
- error?: {
9
- message: string;
10
- stack?: string;
11
- };
12
- duration: number;
13
- }
14
- export interface TestResults {
15
- passed: number;
16
- failed: number;
17
- skipped: number;
18
- todo: number;
19
- tests: TestResult[];
20
- }
21
- export declare function runTests(options?: {
22
- createServer?: CreateServerFunction;
23
- browser?: Browser;
24
- open?: boolean;
25
- playwrightPageOptions?: BrowserContextOptions;
26
- }): Promise<TestResults>;
1
+ import { type CreateTestContextOptions } from './context.ts';
2
+ import type { TestResults } from './reporters/results.ts';
3
+ export declare function runTests(options?: Omit<CreateTestContextOptions, 'addE2ECoverageEntries'>): Promise<TestResults>;
27
4
  //# sourceMappingURL=executor.d.ts.map