@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
@@ -0,0 +1,165 @@
1
+ import * as path from 'node:path'
2
+ import type { Browser, Page, Request } from 'playwright'
3
+ import { colors } from './colors.ts'
4
+ import { getBrowserTestRootDir } from './config.ts'
5
+ import {
6
+ collectCoverageMapFromPlaywright,
7
+ type CoverageMap,
8
+ type V8CoverageEntry,
9
+ } from './coverage.ts'
10
+ import {
11
+ getBrowserLauncher,
12
+ getPlaywrightLaunchOptions,
13
+ getPlaywrightPageOptions,
14
+ type PlaywrightUseOpts,
15
+ } from './playwright.ts'
16
+ import type { Reporter } from './reporters/index.ts'
17
+ import type { TestResults } from './reporters/results.ts'
18
+
19
+ // The harness reports each test result with `filePath` set to the
20
+ // `/scripts/<rel>` URL the iframe loaded. Reporters expect a real filesystem
21
+ // path so they can compute `path.relative(cwd, ...)` cleanly; otherwise they
22
+ // produce noisy `../../../scripts/...` strings.
23
+ function urlPathToFilePath(urlPath: string, rootDir: string): string {
24
+ if (!urlPath.startsWith('/scripts/')) return urlPath
25
+ return path.resolve(rootDir, urlPath.slice('/scripts/'.length))
26
+ }
27
+
28
+ export interface TestRunOptions {
29
+ baseUrl: string
30
+ console?: boolean
31
+ coverage?: boolean
32
+ open?: boolean
33
+ playwrightUseOpts?: PlaywrightUseOpts
34
+ projectName?: string
35
+ reporter: Reporter
36
+ // Test file paths so coverage collection can skip them when mapping V8
37
+ // entries back to filesystem files.
38
+ testFiles?: string[]
39
+ }
40
+
41
+ export async function runBrowserTests(options: TestRunOptions): Promise<{
42
+ results: TestResults
43
+ coverageMap: CoverageMap | null
44
+ close: () => Promise<void>
45
+ disconnected: Promise<void>
46
+ }> {
47
+ let envLabel = options.projectName ? `browser:${options.projectName}` : 'browser'
48
+ let browser: Browser | undefined
49
+ let page: Page | undefined
50
+ let close = async () => {
51
+ await page?.close()
52
+ await browser?.close()
53
+ browser = undefined
54
+ page = undefined
55
+ }
56
+ let results: TestResults
57
+ let coverageMap: CoverageMap | null = null
58
+
59
+ try {
60
+ browser = await getBrowserLauncher(options.playwrightUseOpts).launch(
61
+ getPlaywrightLaunchOptions(options.playwrightUseOpts),
62
+ )
63
+ page = await browser.newPage(getPlaywrightPageOptions(options.playwrightUseOpts))
64
+
65
+ if (options.console) {
66
+ page.on('console', (msg) => console.log(`${colors.dim('[browser console]')} ${msg.text()}`))
67
+ }
68
+
69
+ // Playwright's JS coverage is Chromium-only. Start before navigation so
70
+ // the harness scripts and test modules are instrumented from first parse.
71
+ let coverageEnabled = options.coverage && browser.browserType().name() === 'chromium'
72
+ if (coverageEnabled) {
73
+ await page.coverage.startJSCoverage({ resetOnNavigation: false })
74
+ }
75
+
76
+ let totalPassed = 0
77
+ let totalFailed = 0
78
+ let totalSkipped = 0
79
+ let totalTodo = 0
80
+ let rootDir = getBrowserTestRootDir()
81
+
82
+ await page.route('**/file-results', async (route) => {
83
+ let results = route.request().postDataJSON() as TestResults
84
+ for (let test of results.tests) {
85
+ if (test.filePath) test.filePath = urlPathToFilePath(test.filePath, rootDir)
86
+ }
87
+ options.reporter.onResult(results, envLabel)
88
+ totalPassed += results.passed
89
+ totalFailed += results.failed
90
+ totalSkipped += results.skipped
91
+ totalTodo += results.todo
92
+ await route.fulfill({ status: 200 })
93
+ })
94
+
95
+ // Fail the tests if any /scripts/ request fails (harness scripts, test
96
+ // modules, or their transitive imports — all served via the same prefix).
97
+ let errorPromise = new Promise((_, reject) => {
98
+ let isScriptRequest = (request: Request) =>
99
+ new URL(request.url()).pathname.startsWith('/scripts/')
100
+ page!.on('response', (response) => {
101
+ if (!response.ok() && isScriptRequest(response.request())) {
102
+ reject(new Error(`Failed to load script: ${response.request().url()}`))
103
+ }
104
+ })
105
+ page!.on('requestfailed', (request) => {
106
+ if (isScriptRequest(request)) {
107
+ reject(new Error(`Failed to load script: ${request.url()}`))
108
+ }
109
+ })
110
+ })
111
+
112
+ // Prevent unhandled rejection if we fail before setting up the listener
113
+ errorPromise.catch(() => {})
114
+
115
+ await page.goto(options.baseUrl)
116
+ await Promise.race([page.waitForFunction('window.__testsDone'), errorPromise])
117
+
118
+ if (coverageEnabled) {
119
+ let entries = (await page.coverage.stopJSCoverage()) as unknown as V8CoverageEntry[]
120
+ if (entries.length > 0) {
121
+ coverageMap = await collectCoverageMapFromPlaywright(
122
+ entries,
123
+ getBrowserTestRootDir(),
124
+ new Set(options.testFiles ?? []),
125
+ async (urlPath) =>
126
+ urlPath.startsWith('/scripts/') ? urlPath.slice('/scripts/'.length) : null,
127
+ )
128
+ }
129
+ }
130
+
131
+ results = {
132
+ passed: totalPassed,
133
+ failed: totalFailed,
134
+ skipped: totalSkipped,
135
+ todo: totalTodo,
136
+ tests: [],
137
+ }
138
+ } catch (error) {
139
+ console.error('Browser tests failed to run:', error)
140
+ results = {
141
+ passed: 0,
142
+ failed: 1,
143
+ skipped: 0,
144
+ todo: 0,
145
+ tests: [],
146
+ }
147
+ }
148
+
149
+ if (options.open) {
150
+ return {
151
+ results,
152
+ coverageMap,
153
+ close,
154
+ disconnected: new Promise((r) => browser!.on('disconnected', () => r())),
155
+ }
156
+ } else {
157
+ await close()
158
+ return {
159
+ results,
160
+ coverageMap,
161
+ close,
162
+ disconnected: Promise.resolve(),
163
+ }
164
+ }
165
+ }
package/src/lib/runner.ts CHANGED
@@ -1,12 +1,22 @@
1
+ import * as fsp from 'node:fs/promises'
1
2
  import * as path from 'node:path'
2
3
  import { pathToFileURL } from 'node:url'
3
4
  import { Worker } from 'node:worker_threads'
4
- import type { TestResults } from './executor.ts'
5
+ import { IS_RUNNING_FROM_SRC } from './config.ts'
6
+ import {
7
+ collectCoverageMapFromPlaywright,
8
+ collectServerCoverageMap,
9
+ type CoverageConfig,
10
+ type CoverageMap,
11
+ type V8CoverageEntry,
12
+ } from './coverage.ts'
5
13
  import { type PlaywrightUseOpts } from './playwright.ts'
6
14
  import type { Reporter } from './reporters/index.ts'
7
- import type { Counts } from './utils.ts'
15
+ import type { Counts, TestResults } from './reporters/results.ts'
8
16
 
9
- const ext = path.extname(import.meta.url)
17
+ // Ensure we load the right file whether we're running in the monorepo (TS) or
18
+ // from a published package (JS)
19
+ const ext = IS_RUNNING_FROM_SRC ? '.ts' : '.js'
10
20
  const workerUrl = new URL(`./worker${ext}`, import.meta.url)
11
21
  const workerE2EUrl = new URL(`./worker-e2e${ext}`, import.meta.url)
12
22
 
@@ -16,12 +26,16 @@ export async function runServerTests(
16
26
  concurrency: number,
17
27
  type: 'server' | 'e2e',
18
28
  options: {
29
+ cwd?: string
19
30
  open?: boolean
20
31
  playwrightUseOpts?: PlaywrightUseOpts
21
32
  projectName?: string
33
+ coverage?: CoverageConfig
22
34
  } = {},
23
- ): Promise<Counts> {
35
+ ): Promise<Counts & { coverageMap: CoverageMap | null }> {
24
36
  let counts: Counts = { passed: 0, failed: 0, skipped: 0, todo: 0 }
37
+ let coverageMap: CoverageMap | null = null
38
+ let cwd = options.cwd ?? process.cwd()
25
39
  let envLabel = options.projectName ? `${type}:${options.projectName}` : type
26
40
 
27
41
  function accumulate(results: TestResults, file: string) {
@@ -36,26 +50,60 @@ export async function runServerTests(
36
50
  }
37
51
 
38
52
  if (type === 'e2e') {
53
+ let allBrowserCoverageEntries: Array<{ entries: V8CoverageEntry[]; baseUrl: string }> = []
54
+
39
55
  await runInConcurrentWorkers(
40
56
  files,
41
57
  concurrency,
42
58
  (file) =>
43
- runFileInWorker(file, type, (results) => accumulate(results, file), {
44
- ...options,
45
- playwrightUseOpts: options.playwrightUseOpts,
46
- }),
59
+ runFileInWorker(
60
+ file,
61
+ type,
62
+ (results) => {
63
+ accumulate(results, file)
64
+ if (results.e2eBrowserCoverageEntries) {
65
+ allBrowserCoverageEntries.push(...results.e2eBrowserCoverageEntries)
66
+ }
67
+ },
68
+ {
69
+ ...options,
70
+ playwrightUseOpts: options.playwrightUseOpts,
71
+ },
72
+ ),
47
73
  () => counts.failed++,
48
74
  )
75
+
76
+ if (options.coverage && allBrowserCoverageEntries.length > 0) {
77
+ coverageMap = await collectCoverageMapFromPlaywright(
78
+ allBrowserCoverageEntries.flatMap((e) => e.entries),
79
+ cwd,
80
+ new Set(files),
81
+ async (urlPath) => (urlPath.startsWith('/') ? urlPath.slice(1) : urlPath),
82
+ )
83
+ }
49
84
  } else {
85
+ let coverageDataDir: string | undefined
86
+ if (options.coverage) {
87
+ coverageDataDir = path.resolve(cwd, options.coverage.dir)
88
+ await fsp.mkdir(coverageDataDir, { recursive: true })
89
+ process.env.NODE_V8_COVERAGE = coverageDataDir
90
+ }
91
+
50
92
  await runInConcurrentWorkers(
51
93
  files,
52
94
  concurrency,
53
- (file) => runFileInWorker(file, type, (results) => accumulate(results, file)),
95
+ (file) => runFileInWorker(file, type, (results) => accumulate(results, file), options),
54
96
  () => counts.failed++,
55
97
  )
98
+
99
+ if (coverageDataDir) {
100
+ delete process.env.NODE_V8_COVERAGE
101
+ let serverMap = await collectServerCoverageMap(coverageDataDir, cwd, new Set(files))
102
+ coverageMap = serverMap
103
+ }
56
104
  }
57
105
 
58
- return { ...counts }
106
+ return { ...counts, coverageMap }
59
107
  }
60
108
 
61
109
  async function runInConcurrentWorkers(
@@ -106,6 +154,8 @@ function runFileInWorker(
106
154
  type: 'server' | 'e2e',
107
155
  onResults: (results: TestResults) => void,
108
156
  options: {
157
+ cwd?: string
158
+ coverage?: CoverageConfig
109
159
  open?: boolean
110
160
  playwrightUseOpts?: PlaywrightUseOpts
111
161
  } = {},
@@ -117,6 +167,7 @@ function runFileInWorker(
117
167
  workerData: {
118
168
  file: pathToFileURL(file).href,
119
169
  type,
170
+ coverage: options.coverage,
120
171
  open: options.open,
121
172
  playwrightUseOpts: options.playwrightUseOpts,
122
173
  },
@@ -125,6 +176,7 @@ function runFileInWorker(
125
176
  workerData: {
126
177
  file: pathToFileURL(file).href,
127
178
  type,
179
+ coverage: options.coverage,
128
180
  },
129
181
  })
130
182
  worker.once('message', (msg: TestResults) => onResults(msg))
@@ -0,0 +1,2 @@
1
+ // https://bun.com/docs/guides/util/detect-bun
2
+ export const IS_BUN = typeof process.versions.bun === 'string'
@@ -0,0 +1,36 @@
1
+ import { transform, type TsconfigRaw } from 'esbuild'
2
+ import { getTsconfig, type TsConfigResult } from 'get-tsconfig'
3
+ import * as path from 'node:path'
4
+
5
+ const tsconfigCache = new Map<string, TsConfigResult | null>()
6
+
7
+ /*
8
+ * Transform a TypeScript file to JavaScript using esbuild with an inline
9
+ * source map and no minification. Used by the coverage ESM loader hook (so V8
10
+ * instruments readable JS), the coverage collector (so byte offsets can be
11
+ * re-derived and mapped back to TypeScript lines), and the browser harness
12
+ * server (so the bytes V8 sees in the browser match what the collector
13
+ * re-derives). Identical inputs must produce identical outputs across all
14
+ * call sites or coverage offsets won't line up.
15
+ *
16
+ * Compiler options (notably JSX) are taken from the nearest `tsconfig.json`
17
+ * walking up from the file's directory, so each project picks up its own
18
+ * `jsxImportSource` etc. Discovery results are cached by directory.
19
+ */
20
+ export async function transformTypeScript(
21
+ source: string,
22
+ filePath: string,
23
+ ): Promise<{ code: string }> {
24
+ let loader: 'ts' | 'tsx' = filePath.endsWith('.tsx') ? 'tsx' : 'ts'
25
+
26
+ let tsConfig = getTsconfig(path.dirname(filePath), 'tsconfig.json', tsconfigCache)
27
+
28
+ let result = await transform(source, {
29
+ loader,
30
+ sourcemap: 'inline',
31
+ sourcesContent: true,
32
+ sourcefile: filePath,
33
+ tsconfigRaw: { compilerOptions: tsConfig?.config.compilerOptions ?? {} },
34
+ })
35
+ return { code: result.code }
36
+ }
@@ -1,15 +1,15 @@
1
1
  import { workerData, parentPort } from 'node:worker_threads'
2
- import { tsImport } from 'tsx/esm/api'
3
- import { createServer } from './e2e-server.ts'
4
- import { runTests, type TestResults } from './executor.ts'
2
+ import { runTests } from './executor.ts'
3
+ import { importModule } from './import-module.ts'
5
4
  import {
6
5
  getBrowserLauncher,
7
6
  getPlaywrightLaunchOptions,
8
7
  getPlaywrightPageOptions,
9
8
  } from './playwright.ts'
9
+ import type { TestResults } from './reporters/results.ts'
10
10
 
11
11
  try {
12
- await tsImport(workerData.file, import.meta.url)
12
+ await importModule(workerData.file, import.meta)
13
13
 
14
14
  let launcher = await getBrowserLauncher(workerData.playwrightUseOpts)
15
15
  let opts = getPlaywrightLaunchOptions(workerData.playwrightUseOpts)
@@ -17,9 +17,9 @@ try {
17
17
  try {
18
18
  let results = await runTests({
19
19
  browser,
20
- createServer,
21
20
  open: workerData.open,
22
21
  playwrightPageOptions: getPlaywrightPageOptions(workerData.playwrightUseOpts),
22
+ coverage: workerData.coverage,
23
23
  })
24
24
  parentPort!.postMessage(results)
25
25
  if (workerData.open) {
@@ -29,6 +29,7 @@ try {
29
29
  } finally {
30
30
  await browser.close()
31
31
  }
32
+ process.exit(0)
32
33
  } catch (e) {
33
34
  let results: TestResults = {
34
35
  passed: 0,
@@ -49,4 +50,5 @@ try {
49
50
  ],
50
51
  }
51
52
  parentPort!.postMessage(results)
53
+ process.exit(0)
52
54
  }
package/src/lib/worker.ts CHANGED
@@ -1,12 +1,31 @@
1
- import { workerData, parentPort } from 'node:worker_threads'
2
- import { tsImport } from 'tsx/esm/api'
3
- import { runTests, type TestResults } from './executor.ts'
1
+ import * as mod from 'node:module'
2
+ import * as path from 'node:path'
3
+ import { parentPort, workerData } from 'node:worker_threads'
4
+ import { runTests } from './executor.ts'
5
+ import { importModule } from './import-module.ts'
6
+ import type { TestResults } from './reporters/results.ts'
7
+ import { IS_BUN } from './runtime.ts'
8
+ import { IS_RUNNING_FROM_SRC } from './config.ts'
4
9
 
5
10
  try {
6
- await tsImport(workerData.file, import.meta.url)
11
+ // When coverage is enabled in Node, we use a coverage-friendly TypeScript loader which
12
+ // replaces tsx's minified transformation with a non-minified esbuild transform
13
+ // so V8 coverage byte offsets align with readable source lines. This hook runs
14
+ // before the inherited tsx hook (hooks are LIFO), so it intercepts .ts imports and
15
+ // short-circuits before tsx transforms them.
16
+ if (workerData.coverage && !IS_BUN) {
17
+ // Ensure we load the right file whether we're running in the monorepo (TS) or
18
+ // from a published package (JS)
19
+ let ext = IS_RUNNING_FROM_SRC ? '.ts' : '.js'
20
+ mod.register(new URL(`./coverage-loader${ext}`, import.meta.url), import.meta.url)
21
+ await import(workerData.file)
22
+ } else {
23
+ await importModule(workerData.file, import.meta)
24
+ }
7
25
 
8
26
  let results = await runTests()
9
27
  parentPort!.postMessage(results)
28
+ process.exit(0)
10
29
  } catch (e) {
11
30
  let results: TestResults = {
12
31
  passed: 0,
@@ -27,4 +46,5 @@ try {
27
46
  ],
28
47
  }
29
48
  parentPort!.postMessage(results)
49
+ process.exit(0)
30
50
  }
@@ -0,0 +1,34 @@
1
+ // This file exists solely to validate coverage accuracy.
2
+ // Each function has a known expected coverage profile based on
3
+ // which paths the associated tests exercise.
4
+
5
+ // Fully covered — both statements and the single branch
6
+ export function add(a: number, b: number): number {
7
+ return a + b
8
+ }
9
+
10
+ // Partially covered — only the `n > 0` branch is tested
11
+ export function classify(n: number): string {
12
+ if (n > 0) {
13
+ return 'positive'
14
+ } else if (n < 0) {
15
+ return 'negative'
16
+ } else {
17
+ return 'zero'
18
+ }
19
+ }
20
+
21
+ // Never called — 0% across the board
22
+ export function uncalledFunction(): string {
23
+ let result = 'never'
24
+ result += ' reached'
25
+ return result
26
+ }
27
+
28
+ // Partially covered — only the truthy `name` branch is tested
29
+ export function greet(name?: string): string {
30
+ if (name) {
31
+ return `Hello, ${name}!`
32
+ }
33
+ return 'Hello, stranger!'
34
+ }
@@ -0,0 +1,29 @@
1
+ import * as assert from '@remix-run/assert'
2
+ import { describe, it } from '../../lib/framework.ts'
3
+ import { add, classify, greet } from './fixture.ts'
4
+
5
+ // Expected coverage for coverage-fixture.ts (same as the server/e2e fixture
6
+ // tests):
7
+ //
8
+ // add — 100% functions, statements, lines, branches
9
+ // classify — function covered, but only the `n > 0` branch is hit
10
+ // (the `n < 0` and `else` branches are uncovered)
11
+ // uncalledFunction — 0% across the board (never imported)
12
+ // greet — function covered, but only the truthy `name` branch is hit
13
+ // (the fallback `Hello, stranger!` line is uncovered)
14
+
15
+ describe('browser coverage fixture', () => {
16
+ it('exercises some but not all code paths in the browser', () => {
17
+ assert.equal(add(2, 3), 5)
18
+ assert.equal(add(-1, 1), 0)
19
+
20
+ assert.equal(classify(42), 'positive')
21
+ assert.equal(classify(1), 'positive')
22
+ // deliberately NOT testing classify(-1) or classify(0)
23
+
24
+ assert.equal(greet('World'), 'Hello, World!')
25
+ // deliberately NOT testing greet() without an argument
26
+
27
+ // deliberately NOT importing or calling uncalledFunction
28
+ })
29
+ })
@@ -0,0 +1,70 @@
1
+ import * as assert from '@remix-run/assert'
2
+ import { createTestServer } from '@remix-run/node-fetch-server/test'
3
+ import * as fsp from 'node:fs/promises'
4
+ import * as path from 'node:path'
5
+ import { describe, it } from '../../lib/framework.ts'
6
+ import { transformTypeScript } from '../../lib/ts-transform.ts'
7
+
8
+ // Expected coverage for coverage/fixture.ts (same as the server fixture test):
9
+ //
10
+ // add — 100% functions, statements, lines, branches
11
+ // classify — function covered, but only the `n > 0` branch is hit
12
+ // uncalledFunction — 0% across the board (never called)
13
+ // greet — function covered, but only the truthy `name` branch is hit
14
+
15
+ describe('e2e coverage fixture', () => {
16
+ it('exercises some but not all code paths in the browser', async (t) => {
17
+ // Compile the fixture TypeScript to browser-ready JS
18
+ let fixturePath = path.resolve(import.meta.dirname, './fixture.ts')
19
+ let fixtureSource = await fsp.readFile(fixturePath, 'utf-8')
20
+ let { code: fixtureJs } = await transformTypeScript(fixtureSource, fixturePath)
21
+
22
+ let handler: (request: Request) => Response = (req) => {
23
+ let url = new URL(req.url)
24
+
25
+ if (url.pathname === '/') {
26
+ return new Response(
27
+ [
28
+ `<!doctype html>`,
29
+ `<html>`,
30
+ `<body>`,
31
+ ` <div id="result"></div>`,
32
+ ` <script type="module">`,
33
+ ` import { add, classify, greet } from '/src/test/coverage/fixture.ts'`,
34
+ ` // Exercise the same paths as the server fixture test:`,
35
+ ` // - add: fully covered`,
36
+ ` // - classify: only positive branch`,
37
+ ` // - greet: only with a name`,
38
+ ` // - uncalledFunction: never imported`,
39
+ ` let results = [`,
40
+ ` add(2, 3),`,
41
+ ` add(-1, 1),`,
42
+ ` classify(42),`,
43
+ ` classify(1),`,
44
+ ` greet('World'),`,
45
+ ` ]`,
46
+ ` document.getElementById('result').textContent = results.join(',')`,
47
+ ` </script>`,
48
+ `</body>`,
49
+ `</html>`,
50
+ ].join('\n'),
51
+ { headers: { 'Content-Type': 'text/html' } },
52
+ )
53
+ }
54
+
55
+ // Serve the compiled fixture at the path the import expects
56
+ if (url.pathname === '/src/test/coverage/fixture.ts') {
57
+ return new Response(fixtureJs, {
58
+ headers: { 'Content-Type': 'application/javascript' },
59
+ })
60
+ }
61
+
62
+ return new Response('Not found', { status: 404 })
63
+ }
64
+ let page = await t.serve(await createTestServer(handler))
65
+
66
+ await page.goto('/')
67
+ let result = await page.locator('#result').textContent()
68
+ assert.equal(result, '5,0,positive,positive,Hello, World!')
69
+ })
70
+ })
@@ -0,0 +1,32 @@
1
+ import * as assert from '@remix-run/assert'
2
+ import { describe, it } from '../../lib/framework.ts'
3
+ import { add, classify, greet } from './fixture.ts'
4
+
5
+ // Expected coverage for coverage-fixture.ts:
6
+ //
7
+ // add — 100% functions, statements, lines, branches
8
+ // classify — function covered, but only the `n > 0` branch is hit
9
+ // (the `n < 0` and `else` branches are uncovered)
10
+ // uncalledFunction — 0% across the board (never imported)
11
+ // greet — function covered, but only the truthy `name` branch is hit
12
+ // (the fallback `Hello, stranger!` line is uncovered)
13
+
14
+ describe('coverage/fixture.ts', () => {
15
+ it('add returns the sum', () => {
16
+ assert.equal(add(2, 3), 5)
17
+ assert.equal(add(-1, 1), 0)
18
+ })
19
+
20
+ it('classify identifies positive numbers only', () => {
21
+ assert.equal(classify(42), 'positive')
22
+ assert.equal(classify(1), 'positive')
23
+ // deliberately NOT testing classify(-1) or classify(0)
24
+ })
25
+
26
+ // deliberately NOT importing or calling uncalledFunction
27
+
28
+ it('greet with a name only', () => {
29
+ assert.equal(greet('World'), 'Hello, World!')
30
+ // deliberately NOT testing greet() without an argument
31
+ })
32
+ })
package/tsconfig.json CHANGED
@@ -2,13 +2,15 @@
2
2
  "compilerOptions": {
3
3
  "strict": true,
4
4
  "lib": ["ES2024", "DOM", "DOM.Iterable"],
5
+ "types": ["node", "dom-navigation"],
5
6
  "module": "ES2022",
6
7
  "moduleResolution": "Bundler",
7
8
  "target": "ESNext",
8
9
  "allowImportingTsExtensions": true,
9
10
  "rewriteRelativeImportExtensions": true,
10
11
  "verbatimModuleSyntax": true,
12
+ "skipLibCheck": true,
11
13
  "jsx": "react-jsx",
12
- "jsxImportSource": "@remix-run/component"
14
+ "jsxImportSource": "@remix-run/ui"
13
15
  }
14
16
  }
@@ -1,11 +0,0 @@
1
- export interface CreateServerFunction {
2
- (handler: (req: Request) => Promise<Response>): Promise<{
3
- baseUrl: string;
4
- close(): Promise<void>;
5
- }>;
6
- }
7
- export declare function createServer(handler: (req: Request) => Promise<Response>): Promise<{
8
- baseUrl: string;
9
- close(): Promise<void>;
10
- }>;
11
- //# sourceMappingURL=e2e-server.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"e2e-server.d.ts","sourceRoot":"","sources":["../../src/lib/e2e-server.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,oBAAoB;IACnC,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,GAAG,OAAO,CAAC;QACtD,OAAO,EAAE,MAAM,CAAA;QACf,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;KACvB,CAAC,CAAA;CACH;AAED,wBAAgB,YAAY,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,GAAG,OAAO,CAAC;IAClF,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CACvB,CAAC,CAcD"}
@@ -1,15 +0,0 @@
1
- import * as http from 'node:http';
2
- import { createRequestListener } from '@remix-run/node-fetch-server';
3
- export function createServer(handler) {
4
- return new Promise((resolve, reject) => {
5
- let server = http.createServer(createRequestListener(handler));
6
- server.listen(0, '127.0.0.1', () => {
7
- let addr = server.address();
8
- resolve({
9
- baseUrl: `http://127.0.0.1:${addr.port}`,
10
- close: () => new Promise((r, rj) => server.close((e) => (e ? rj(e) : r()))),
11
- });
12
- });
13
- server.on('error', reject);
14
- });
15
- }
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=framework.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"framework.test.d.ts","sourceRoot":"","sources":["../../src/lib/framework.test.tsx"],"names":[],"mappings":""}
@@ -1 +0,0 @@
1
- {"version":3,"file":"framework.test.e2e.d.ts","sourceRoot":"","sources":["../../src/lib/framework.test.e2e.tsx"],"names":[],"mappings":""}