@remix-run/test 0.0.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 (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +430 -2
  3. package/dist/app/client/entry.d.ts +2 -0
  4. package/dist/app/client/entry.d.ts.map +1 -0
  5. package/dist/app/client/entry.js +324 -0
  6. package/dist/app/client/iframe.d.ts +2 -0
  7. package/dist/app/client/iframe.d.ts.map +1 -0
  8. package/dist/app/client/iframe.js +22 -0
  9. package/dist/app/server.d.ts +6 -0
  10. package/dist/app/server.d.ts.map +1 -0
  11. package/dist/app/server.js +303 -0
  12. package/dist/cli-entry.d.ts +3 -0
  13. package/dist/cli-entry.d.ts.map +1 -0
  14. package/dist/cli-entry.js +14 -0
  15. package/dist/cli.d.ts +8 -0
  16. package/dist/cli.d.ts.map +1 -0
  17. package/dist/cli.js +305 -0
  18. package/dist/index.d.ts +6 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +2 -0
  21. package/dist/lib/colors.d.ts +2 -0
  22. package/dist/lib/colors.d.ts.map +1 -0
  23. package/dist/lib/colors.js +2 -0
  24. package/dist/lib/config.d.ts +91 -0
  25. package/dist/lib/config.d.ts.map +1 -0
  26. package/dist/lib/config.js +255 -0
  27. package/dist/lib/context.d.ts +93 -0
  28. package/dist/lib/context.d.ts.map +1 -0
  29. package/dist/lib/context.js +65 -0
  30. package/dist/lib/coverage-loader.d.ts +16 -0
  31. package/dist/lib/coverage-loader.d.ts.map +1 -0
  32. package/dist/lib/coverage-loader.js +20 -0
  33. package/dist/lib/coverage.d.ts +28 -0
  34. package/dist/lib/coverage.d.ts.map +1 -0
  35. package/dist/lib/coverage.js +212 -0
  36. package/dist/lib/executor.d.ts +4 -0
  37. package/dist/lib/executor.d.ts.map +1 -0
  38. package/dist/lib/executor.js +128 -0
  39. package/dist/lib/fake-timers.d.ts +6 -0
  40. package/dist/lib/fake-timers.d.ts.map +1 -0
  41. package/dist/lib/fake-timers.js +45 -0
  42. package/dist/lib/framework.d.ts +107 -0
  43. package/dist/lib/framework.d.ts.map +1 -0
  44. package/dist/lib/framework.js +198 -0
  45. package/dist/lib/import-module.d.ts +2 -0
  46. package/dist/lib/import-module.d.ts.map +1 -0
  47. package/dist/lib/import-module.js +29 -0
  48. package/dist/lib/mock.d.ts +52 -0
  49. package/dist/lib/mock.d.ts.map +1 -0
  50. package/dist/lib/mock.js +61 -0
  51. package/dist/lib/normalize.d.ts +2 -0
  52. package/dist/lib/normalize.d.ts.map +1 -0
  53. package/dist/lib/normalize.js +18 -0
  54. package/dist/lib/playwright.d.ts +15 -0
  55. package/dist/lib/playwright.d.ts.map +1 -0
  56. package/dist/lib/playwright.js +81 -0
  57. package/dist/lib/reporters/dot.d.ts +9 -0
  58. package/dist/lib/reporters/dot.d.ts.map +1 -0
  59. package/dist/lib/reporters/dot.js +56 -0
  60. package/dist/lib/reporters/files.d.ts +9 -0
  61. package/dist/lib/reporters/files.d.ts.map +1 -0
  62. package/dist/lib/reporters/files.js +71 -0
  63. package/dist/lib/reporters/index.d.ts +13 -0
  64. package/dist/lib/reporters/index.d.ts.map +1 -0
  65. package/dist/lib/reporters/index.js +18 -0
  66. package/dist/lib/reporters/results.d.ts +30 -0
  67. package/dist/lib/reporters/results.d.ts.map +1 -0
  68. package/dist/lib/reporters/results.js +1 -0
  69. package/dist/lib/reporters/spec.d.ts +9 -0
  70. package/dist/lib/reporters/spec.d.ts.map +1 -0
  71. package/dist/lib/reporters/spec.js +153 -0
  72. package/dist/lib/reporters/tap.d.ts +9 -0
  73. package/dist/lib/reporters/tap.d.ts.map +1 -0
  74. package/dist/lib/reporters/tap.js +54 -0
  75. package/dist/lib/runner-browser.d.ts +21 -0
  76. package/dist/lib/runner-browser.d.ts.map +1 -0
  77. package/dist/lib/runner-browser.js +117 -0
  78. package/dist/lib/runner.d.ts +14 -0
  79. package/dist/lib/runner.d.ts.map +1 -0
  80. package/dist/lib/runner.js +118 -0
  81. package/dist/lib/runtime.d.ts +2 -0
  82. package/dist/lib/runtime.d.ts.map +1 -0
  83. package/dist/lib/runtime.js +2 -0
  84. package/dist/lib/ts-transform.d.ts +4 -0
  85. package/dist/lib/ts-transform.d.ts.map +1 -0
  86. package/dist/lib/ts-transform.js +29 -0
  87. package/dist/lib/watcher.d.ts +5 -0
  88. package/dist/lib/watcher.d.ts.map +1 -0
  89. package/dist/lib/watcher.js +39 -0
  90. package/dist/lib/worker-e2e.d.ts +2 -0
  91. package/dist/lib/worker-e2e.d.ts.map +1 -0
  92. package/dist/lib/worker-e2e.js +49 -0
  93. package/dist/lib/worker.d.ts +2 -0
  94. package/dist/lib/worker.d.ts.map +1 -0
  95. package/dist/lib/worker.js +57 -0
  96. package/dist/test/coverage/fixture.d.ts +5 -0
  97. package/dist/test/coverage/fixture.d.ts.map +1 -0
  98. package/dist/test/coverage/fixture.js +32 -0
  99. package/dist/test/coverage/test-browser.d.ts +2 -0
  100. package/dist/test/coverage/test-browser.d.ts.map +1 -0
  101. package/dist/test/coverage/test-browser.js +24 -0
  102. package/dist/test/coverage/test-e2e.d.ts +2 -0
  103. package/dist/test/coverage/test-e2e.d.ts.map +1 -0
  104. package/dist/test/coverage/test-e2e.js +60 -0
  105. package/dist/test/coverage/test-unit.d.ts +2 -0
  106. package/dist/test/coverage/test-unit.d.ts.map +1 -0
  107. package/dist/test/coverage/test-unit.js +27 -0
  108. package/dist/test/framework.test.browser.d.ts +2 -0
  109. package/dist/test/framework.test.browser.d.ts.map +1 -0
  110. package/dist/test/framework.test.browser.js +107 -0
  111. package/dist/test/framework.test.e2e.d.ts +2 -0
  112. package/dist/test/framework.test.e2e.d.ts.map +1 -0
  113. package/dist/test/framework.test.e2e.js +34 -0
  114. package/package.json +79 -5
  115. package/src/app/client/entry.ts +353 -0
  116. package/src/app/client/iframe.ts +18 -0
  117. package/src/app/server.ts +336 -0
  118. package/src/cli-entry.ts +15 -0
  119. package/src/cli.ts +384 -0
  120. package/src/index.ts +16 -0
  121. package/src/lib/colors.ts +3 -0
  122. package/src/lib/config.ts +377 -0
  123. package/src/lib/context.ts +168 -0
  124. package/src/lib/coverage-loader.ts +31 -0
  125. package/src/lib/coverage.ts +320 -0
  126. package/src/lib/executor.ts +145 -0
  127. package/src/lib/fake-timers.ts +64 -0
  128. package/src/lib/framework.ts +251 -0
  129. package/src/lib/import-module.ts +29 -0
  130. package/src/lib/mock.ts +89 -0
  131. package/src/lib/normalize.ts +22 -0
  132. package/src/lib/playwright.ts +100 -0
  133. package/src/lib/reporters/dot.ts +58 -0
  134. package/src/lib/reporters/files.ts +77 -0
  135. package/src/lib/reporters/index.ts +27 -0
  136. package/src/lib/reporters/results.ts +29 -0
  137. package/src/lib/reporters/spec.ts +174 -0
  138. package/src/lib/reporters/tap.ts +58 -0
  139. package/src/lib/runner-browser.ts +165 -0
  140. package/src/lib/runner.ts +189 -0
  141. package/src/lib/runtime.ts +2 -0
  142. package/src/lib/ts-transform.ts +36 -0
  143. package/src/lib/watcher.ts +46 -0
  144. package/src/lib/worker-e2e.ts +54 -0
  145. package/src/lib/worker.ts +50 -0
  146. package/src/test/coverage/fixture.ts +34 -0
  147. package/src/test/coverage/test-browser.ts +29 -0
  148. package/src/test/coverage/test-e2e.ts +70 -0
  149. package/src/test/coverage/test-unit.ts +32 -0
  150. package/tsconfig.json +16 -0
@@ -0,0 +1,377 @@
1
+ import * as fsp from 'node:fs/promises'
2
+ import * as os from 'node:os'
3
+ import * as path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import * as util from 'node:util'
6
+ import type { PlaywrightTestConfig } from 'playwright/test'
7
+ import { importModule } from './import-module.ts'
8
+
9
+ export const IS_RUNNING_FROM_SRC = path.extname(new URL(import.meta.url).pathname) === '.ts'
10
+
11
+ /*
12
+ * The root directory for the test code. Coverage URLs are emitted as
13
+ * `/scripts/<rel-from-rootDir>` and resolved back via the same anchor.
14
+ *
15
+ * - In a published install: `process.cwd()`, since deps and user source all
16
+ * live under it.
17
+ * - In monorepo src mode: the monorepo root, computed by walking back from
18
+ * the resolved `@remix-run/test` source path. `process.cwd()` doesn't work
19
+ * here because workspace deps and node_modules live above the per-package
20
+ * cwd.
21
+ */
22
+ export function getBrowserTestRootDir(): string {
23
+ return IS_RUNNING_FROM_SRC
24
+ ? // Resolve to packages/test/src/index.ts and the pop 3 directories off to the repo root
25
+ path
26
+ .dirname(fileURLToPath(import.meta.resolve('@remix-run/test')))
27
+ .split(path.sep)
28
+ .slice(0, -3)
29
+ .join(path.sep)
30
+ : process.cwd()
31
+ }
32
+
33
+ // prettier-ignore
34
+ // Note: `description` is not a field used by parseArgs(), it's an additional field
35
+ // we use for `--help`
36
+ const cliOptions = {
37
+ 'browser.echo': {
38
+ type: 'boolean',
39
+ description: 'Echo browser console output to stdout',
40
+ },
41
+ 'browser.open': {
42
+ type: 'boolean',
43
+ description: 'Open browser window and keep open after tests finish',
44
+ },
45
+ 'glob.browser': {
46
+ type: 'string',
47
+ description: 'Glob pattern for browser test files',
48
+ },
49
+ 'glob.e2e': {
50
+ type: 'string',
51
+ description: 'Glob pattern for E2E test files',
52
+ },
53
+ 'glob.exclude': {
54
+ type: 'string',
55
+ description: 'Glob pattern for paths to exclude from discovery',
56
+ },
57
+ 'glob.test': {
58
+ type: 'string',
59
+ description: 'Glob pattern for all test files',
60
+ },
61
+ concurrency: {
62
+ type: 'string',
63
+ short: 'c',
64
+ description: 'Max number of concurrent test workers (default: os.availableParallelism())',
65
+ },
66
+ config: {
67
+ type: 'string',
68
+ description: 'Path to config file (default: remix-test.config.ts)',
69
+ },
70
+ coverage: {
71
+ type: 'boolean',
72
+ description: 'Enable or disable coverage collection (default: false)',
73
+ },
74
+ 'coverage.dir': {
75
+ type: 'string',
76
+ description: 'Directory to output coverage reports (default: .coverage)',
77
+ },
78
+ 'coverage.include': {
79
+ type: 'string',
80
+ multiple: true,
81
+ description: 'Glob pattern(s) for files to include in coverage',
82
+ },
83
+ 'coverage.exclude': {
84
+ type: 'string',
85
+ multiple: true,
86
+ description: 'Glob pattern(s) for files to exclude from coverage',
87
+ },
88
+ 'coverage.branches': {
89
+ type: 'string',
90
+ description: 'Branches coverage threshold percentage',
91
+ },
92
+ 'coverage.functions': {
93
+ type: 'string',
94
+ description: 'Functions coverage threshold percentage',
95
+ },
96
+ 'coverage.lines': {
97
+ type: 'string',
98
+ description: 'Lines coverage threshold percentage',
99
+ },
100
+ 'coverage.statements': {
101
+ type: 'string',
102
+ description: 'Statements coverage threshold percentage',
103
+ },
104
+ setup: {
105
+ type: 'string',
106
+ short: 's',
107
+ description: 'Path to a setup module exporting globalSetup/globalTeardown',
108
+ },
109
+ playwrightConfig: {
110
+ type: 'string',
111
+ description: 'Path to a Playwright config file',
112
+ },
113
+ project: {
114
+ type: 'string',
115
+ short: 'p',
116
+ description: 'Filter to a specific Playwright project (comma-separated)',
117
+ },
118
+ reporter: {
119
+ type: 'string',
120
+ short: 'r',
121
+ description: 'Test reporter: spec, files, tap, dot (default: spec)',
122
+ },
123
+ type: {
124
+ type: 'string',
125
+ short: 't',
126
+ description: 'Comma-separated test types to run (default: server,browser,e2e)',
127
+ },
128
+ watch: {
129
+ type: 'boolean',
130
+ short: 'w',
131
+ description: 'Re-run tests on file changes',
132
+ },
133
+ } as const
134
+
135
+ const defaultValues: ResolvedRemixTestConfig = {
136
+ browser: {
137
+ echo: false,
138
+ open: false,
139
+ },
140
+ concurrency: os.availableParallelism(),
141
+ coverage: {
142
+ dir: '.coverage',
143
+ include: undefined,
144
+ exclude: undefined,
145
+ statements: undefined,
146
+ lines: undefined,
147
+ branches: undefined,
148
+ functions: undefined,
149
+ },
150
+ glob: {
151
+ test: '**/*.test{,.e2e,.browser}.{ts,tsx}',
152
+ browser: '**/*.test.browser.{ts,tsx}',
153
+ e2e: '**/*.test.e2e.{ts,tsx}',
154
+ exclude: 'node_modules/**',
155
+ },
156
+ reporter: process.env.CI === 'true' ? 'files' : 'spec',
157
+ type: 'server,browser,e2e',
158
+ setup: undefined,
159
+ playwrightConfig: undefined,
160
+ project: undefined,
161
+ watch: false,
162
+ } as const
163
+
164
+ export interface RemixTestConfig {
165
+ /**
166
+ * Options for controlling the playwright browser
167
+ * - `browser.echo`: Echo browser console output to stdout (--browser.echo)
168
+ * - `browser.open`: Open browser window and keep open after test finish (--browser.open)
169
+ */
170
+ browser?: {
171
+ echo?: boolean
172
+ open?: boolean
173
+ }
174
+ /**
175
+ * Glob patterns to identify test files
176
+ * - `glob.test`: Glob pattern for all test files (--glob.test)
177
+ * - `glob.browser`: Glob pattern for the subset of browser test files (--glob.browser)
178
+ * - `glob.e2e`: Glob pattern for the subset of e2e test files (--glob.e2e)
179
+ * - `glob.exclude`: Glob pattern for paths to exclude from discovery (--glob.exclude)
180
+ */
181
+ glob?: {
182
+ test?: string
183
+ browser?: string
184
+ e2e?: string
185
+ exclude?: string
186
+ }
187
+ /** Max number of concurrent test workers (--concurrency) */
188
+ concurrency?: number | string
189
+ /**
190
+ * Coverage configuration. `true` enables with defaults; an object enables with settings;
191
+ * `false` disables. CLI `--coverage` flag overrides the boolean aspect.
192
+ */
193
+ coverage?:
194
+ | boolean
195
+ | {
196
+ dir?: string
197
+ include?: string[]
198
+ exclude?: string[]
199
+ statements?: number | string
200
+ lines?: number | string
201
+ branches?: number | string
202
+ functions?: number | string
203
+ }
204
+ /**
205
+ * Path to a module that exports `globalSetup` and/or `globalTeardown` functions,
206
+ * called once before and after the test run respectively. (--setup)
207
+ */
208
+ setup?: string
209
+ /**
210
+ * Playwright configuration — either a path to a playwright config file or an inline
211
+ * PlaywrightTestConfig object. CLI `--playwrightConfig` only accepts a file path.
212
+ */
213
+ playwrightConfig?: string | PlaywrightTestConfig
214
+ /** Filter tests to a specific playwright project or comma-separated list of projects (--project) */
215
+ project?: string
216
+ /** Test reporter (--reporter) */
217
+ reporter?: string
218
+ /** Comma-separated list of test types to run (--type) */
219
+ type?: string
220
+ /** Watch mode — re-run tests on file changes (--watch) */
221
+ watch?: boolean
222
+ }
223
+
224
+ export interface ResolvedRemixTestConfig {
225
+ browser: {
226
+ echo?: boolean
227
+ open?: boolean
228
+ }
229
+ concurrency: number
230
+ coverage:
231
+ | {
232
+ dir: string
233
+ include?: string[]
234
+ exclude?: string[]
235
+ statements?: number
236
+ lines?: number
237
+ branches?: number
238
+ functions?: number
239
+ }
240
+ | undefined
241
+ glob: {
242
+ test: string
243
+ browser: string
244
+ e2e: string
245
+ exclude: string
246
+ }
247
+ playwrightConfig: string | PlaywrightTestConfig | undefined
248
+ project: string | undefined
249
+ reporter: string
250
+ setup: string | undefined
251
+ type: string
252
+ watch: boolean
253
+ }
254
+
255
+ export async function loadConfig(args: string[] = process.argv.slice(2), cwd = process.cwd()) {
256
+ let parsed = parseCliArgs(args)
257
+ let fileConfig = await loadConfigFile(parsed.values.config, cwd)
258
+ let config = resolveConfig(fileConfig, parsed)
259
+ return config
260
+ }
261
+
262
+ export function getRemixTestHelpText(_target: NodeJS.WriteStream = process.stdout): string {
263
+ let lines = [
264
+ 'Usage: remix-test [glob] [options]',
265
+ '',
266
+ 'Arguments:',
267
+ ` glob Glob pattern for test files (default: "${defaultValues.glob.test}")`,
268
+ '',
269
+ 'Options:',
270
+ ]
271
+
272
+ for (let [long, opt] of Object.entries(cliOptions)) {
273
+ let short = 'short' in opt ? `/-${opt.short}` : ''
274
+ let label = opt.type === 'string' ? `--${long}${short} <value>` : `--${long}${short}`
275
+ lines.push(` ${label.padEnd(30)} ${opt.description}`)
276
+ }
277
+
278
+ lines.push(` ${'-h, --help'.padEnd(30)} Show this help message`)
279
+
280
+ return lines.join('\n')
281
+ }
282
+
283
+ function parseCliArgs(args: string[]) {
284
+ return util.parseArgs({ args, options: cliOptions, allowPositionals: true })
285
+ }
286
+
287
+ function resolveConfig(
288
+ fileConfig: RemixTestConfig,
289
+ { values: cliValues, positionals }: ReturnType<typeof parseCliArgs>,
290
+ ): ResolvedRemixTestConfig {
291
+ let fileCoverage = typeof fileConfig.coverage === 'boolean' ? {} : fileConfig.coverage || {}
292
+ return {
293
+ glob: {
294
+ test:
295
+ positionals[0] ??
296
+ cliValues['glob.test'] ??
297
+ fileConfig.glob?.test ??
298
+ defaultValues.glob.test,
299
+ browser: cliValues['glob.browser'] ?? fileConfig.glob?.browser ?? defaultValues.glob.browser,
300
+ e2e: cliValues['glob.e2e'] ?? fileConfig.glob?.e2e ?? defaultValues.glob.e2e,
301
+ exclude: cliValues['glob.exclude'] ?? fileConfig.glob?.exclude ?? defaultValues.glob.exclude,
302
+ },
303
+ browser: {
304
+ echo: cliValues['browser.echo'] ?? fileConfig.browser?.echo ?? defaultValues.browser.echo,
305
+ open: cliValues['browser.open'] ?? fileConfig.browser?.open ?? defaultValues.browser.open,
306
+ },
307
+ concurrency: Number(
308
+ cliValues.concurrency ?? fileConfig.concurrency ?? defaultValues.concurrency,
309
+ ),
310
+ coverage:
311
+ cliValues.coverage === true || !!fileConfig.coverage
312
+ ? {
313
+ dir: cliValues['coverage.dir'] ?? fileCoverage.dir ?? defaultValues.coverage!.dir,
314
+ include:
315
+ cliValues['coverage.include'] ??
316
+ fileCoverage.include ??
317
+ defaultValues.coverage!.include,
318
+ exclude:
319
+ cliValues['coverage.exclude'] ??
320
+ fileCoverage.exclude ??
321
+ defaultValues.coverage!.exclude,
322
+ statements:
323
+ cliValues['coverage.statements'] !== undefined
324
+ ? Number(cliValues['coverage.statements'])
325
+ : fileCoverage.statements !== undefined
326
+ ? Number(fileCoverage.statements)
327
+ : undefined,
328
+ lines:
329
+ cliValues['coverage.lines'] !== undefined
330
+ ? Number(cliValues['coverage.lines'])
331
+ : fileCoverage.lines !== undefined
332
+ ? Number(fileCoverage.lines)
333
+ : undefined,
334
+ branches:
335
+ cliValues['coverage.branches'] !== undefined
336
+ ? Number(cliValues['coverage.branches'])
337
+ : fileCoverage.branches !== undefined
338
+ ? Number(fileCoverage.branches)
339
+ : undefined,
340
+ functions:
341
+ cliValues['coverage.functions'] !== undefined
342
+ ? Number(cliValues['coverage.functions'])
343
+ : fileCoverage.functions !== undefined
344
+ ? Number(fileCoverage.functions)
345
+ : undefined,
346
+ }
347
+ : undefined,
348
+ setup: cliValues.setup ?? fileConfig.setup ?? defaultValues.setup,
349
+ playwrightConfig:
350
+ cliValues.playwrightConfig ?? fileConfig.playwrightConfig ?? defaultValues.playwrightConfig,
351
+ project: cliValues.project ?? fileConfig.project ?? defaultValues.project,
352
+ reporter: cliValues.reporter ?? fileConfig.reporter ?? defaultValues.reporter,
353
+ type: cliValues.type ?? fileConfig.type ?? defaultValues.type,
354
+ watch: cliValues.watch ?? fileConfig.watch ?? defaultValues.watch,
355
+ }
356
+ }
357
+
358
+ async function loadConfigFile(
359
+ configPath: string | undefined,
360
+ cwd: string,
361
+ ): Promise<RemixTestConfig> {
362
+ let candidates = configPath
363
+ ? [path.resolve(cwd, configPath)]
364
+ : [path.join(cwd, 'remix-test.config.ts'), path.join(cwd, 'remix-test.config.js')]
365
+
366
+ for (let candidate of candidates) {
367
+ try {
368
+ await fsp.access(candidate)
369
+ let mod = await importModule(candidate, import.meta)
370
+ return mod.default ?? mod
371
+ } catch {
372
+ // not found or failed to load — try next
373
+ }
374
+ }
375
+
376
+ return {}
377
+ }
@@ -0,0 +1,168 @@
1
+ import type { Browser, Page } from 'playwright'
2
+ import type { V8CoverageEntry } from './coverage.ts'
3
+ import { createFakeTimers, type FakeTimers } from './fake-timers.ts'
4
+ import { mock, type MockCall, type MockContext, type MockFunction } from './mock.ts'
5
+ import type { getPlaywrightPageOptions } from './playwright.ts'
6
+
7
+ /**
8
+ * The shape `t.serve()` consumes. Matches the result of `createTestServer`
9
+ * from `@remix-run/node-fetch-server/test`, but any object with a `baseUrl`
10
+ * and async `close()` works.
11
+ */
12
+ export interface TestServer {
13
+ baseUrl: string
14
+ close(): Promise<void>
15
+ }
16
+
17
+ /**
18
+ * Test Context providing utilities for testing via remix-test. The context is
19
+ * passed as the first argument to the {@link test}/{@link it} functions.
20
+ *
21
+ * @example
22
+ * describe('my test suite', () => {
23
+ * it('my test case', async (t) => {
24
+ * let mockFn = t.mock.fn(() => 'mocked value')
25
+ * // ...
26
+ * })
27
+ * })
28
+ */
29
+ export interface TestContext {
30
+ /**
31
+ * Registers a cleanup function to be called after the test completes.
32
+ *
33
+ * @param {() => void} fn - The cleanup function to execute
34
+ * @returns {void}
35
+ */
36
+ after(fn: () => void): void
37
+
38
+ /**
39
+ * Mock tracker for the current test. Mirrors the shape of Node's
40
+ * `t.mock`. Method mocks created here are auto-restored on test completion.
41
+ */
42
+ mock: {
43
+ /**
44
+ * Creates a mock function with an optional implementation.
45
+ *
46
+ * @template T - The function type to be mocked
47
+ * @param {T} [impl] - Optional custom implementation for the mock
48
+ * @returns {MockFunction<T>} A mock function instance
49
+ */
50
+ fn<T extends (...args: any[]) => any>(impl?: T): MockFunction<T>
51
+
52
+ /**
53
+ * Replaces `obj[methodName]` with a mock and records every call. The
54
+ * original method is restored automatically after the test completes.
55
+ *
56
+ * @template T - The object type
57
+ * @template K - The method key of the object
58
+ * @param {T} obj - The object to mock
59
+ * @param {K} methodName - The method name to mock
60
+ * @param {Function} [impl] - Optional implementation override (must be a function)
61
+ * @returns {MockFunction} A mock function instance for the mocked method
62
+ */
63
+ method<T extends object, K extends keyof T>(
64
+ obj: T,
65
+ methodName: K,
66
+ impl?: Function,
67
+ ): MockFunction
68
+ }
69
+
70
+ /**
71
+ * Activates fake timers for testing time-dependent code.
72
+ *
73
+ * @returns {FakeTimers} A fake timers instance for controlling time
74
+ */
75
+ useFakeTimers(): FakeTimers
76
+
77
+ /**
78
+ * Wires a running test server up to a Playwright page so the test can drive
79
+ * it. The server is closed automatically when the test ends. Pair with
80
+ * `createTestServer` from `@remix-run/node-fetch-server/test` (or any other
81
+ * source of a `{ baseUrl, close }` handle) to spin up the server first.
82
+ *
83
+ * @param server - The running server the page should target
84
+ * @returns A `Page` whose `baseURL` is set to `server.baseUrl`.
85
+ */
86
+ serve(server: TestServer): Promise<Page>
87
+ }
88
+
89
+ export interface CreateTestContextOptions {
90
+ addE2ECoverageEntries: (value: { entries: V8CoverageEntry[]; baseUrl: string }) => void
91
+ browser: Browser
92
+ coverage: boolean
93
+ open: boolean
94
+ playwrightPageOptions: ReturnType<typeof getPlaywrightPageOptions>
95
+ }
96
+
97
+ export function createTestContext(options?: CreateTestContextOptions): {
98
+ testContext: TestContext
99
+ cleanup(): Promise<void>
100
+ } {
101
+ let cleanups: Array<() => void | Promise<void>> = []
102
+
103
+ let testContext: TestContext = {
104
+ mock: {
105
+ fn: mock.fn,
106
+ method(obj, methodName, impl) {
107
+ let mockFn = mock.method(obj, methodName, impl as any)
108
+ if (mockFn.mock.restore) cleanups.push(mockFn.mock.restore)
109
+ return mockFn
110
+ },
111
+ },
112
+ after(fn) {
113
+ cleanups.push(fn)
114
+ },
115
+ useFakeTimers() {
116
+ let timers = createFakeTimers()
117
+ cleanups.push(timers.restore)
118
+ return timers
119
+ },
120
+ async serve(server) {
121
+ if (!options || !options.browser) {
122
+ throw new Error('t.serve() is only available in E2E test suites')
123
+ }
124
+
125
+ let page = await options.browser.newPage({
126
+ ...options.playwrightPageOptions,
127
+ baseURL: server.baseUrl,
128
+ })
129
+ if (options.playwrightPageOptions?.navigationTimeout != null) {
130
+ page.setDefaultNavigationTimeout(options.playwrightPageOptions.navigationTimeout)
131
+ }
132
+ if (options.playwrightPageOptions?.actionTimeout != null) {
133
+ page.setDefaultTimeout(options.playwrightPageOptions.actionTimeout)
134
+ }
135
+
136
+ let coverageEnabled = options.coverage && options.browser.browserType().name() === 'chromium'
137
+ if (coverageEnabled) {
138
+ await page.coverage.startJSCoverage({ resetOnNavigation: false })
139
+ cleanups.push(async () => {
140
+ let entries = await page.coverage.stopJSCoverage()
141
+ options.addE2ECoverageEntries?.({
142
+ entries: entries as unknown as V8CoverageEntry[],
143
+ baseUrl: server.baseUrl,
144
+ })
145
+ })
146
+ }
147
+
148
+ cleanups.push(async () => {
149
+ if (!options.open) {
150
+ await page.close()
151
+ }
152
+ await server.close()
153
+ })
154
+
155
+ return page
156
+ },
157
+ }
158
+
159
+ return {
160
+ testContext,
161
+ async cleanup() {
162
+ for (let fn of cleanups) await fn()
163
+ cleanups.length = 0
164
+ },
165
+ }
166
+ }
167
+
168
+ export type { MockCall, MockContext, MockFunction }
@@ -0,0 +1,31 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { transformTypeScript } from './ts-transform.ts'
4
+
5
+ // Custom ESM loader hook for TypeScript files.
6
+ //
7
+ // Replaces tsx's minified transformation with an un-minified esbuild transform
8
+ // that preserves line structure. This ensures V8 coverage byte offsets map
9
+ // cleanly to TypeScript source lines via the inline source map, giving
10
+ // accurate per-line coverage rather than collapsing multiple statements onto
11
+ // a single minified line.
12
+
13
+ export async function load(
14
+ url: string,
15
+ context: { format?: string },
16
+ nextLoad: (
17
+ url: string,
18
+ context: { format?: string },
19
+ ) => Promise<{ format: string; source: string }>,
20
+ ) {
21
+ let cleanUrl = url.includes('?') ? url.slice(0, url.indexOf('?')) : url
22
+ if (!cleanUrl.endsWith('.ts') && !cleanUrl.endsWith('.tsx')) {
23
+ return nextLoad(url, context)
24
+ }
25
+
26
+ let filePath = fileURLToPath(cleanUrl)
27
+ let source = await readFile(filePath, 'utf-8')
28
+ let { code } = await transformTypeScript(source, filePath)
29
+
30
+ return { format: 'module', source: code, shortCircuit: true }
31
+ }