@remix-run/test 0.1.0 → 0.3.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 (159) hide show
  1. package/README.md +161 -50
  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 +328 -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 +319 -140
  17. package/dist/index.d.ts +2 -1
  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 +59 -14
  23. package/dist/lib/config.d.ts.map +1 -1
  24. package/dist/lib/config.js +181 -38
  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 +13 -0
  38. package/dist/lib/fake-timers.d.ts.map +1 -0
  39. package/dist/lib/fake-timers.js +64 -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 +38 -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 +12 -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 +12 -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 +12 -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 +11 -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 +123 -0
  70. package/dist/lib/runner.d.ts +24 -2
  71. package/dist/lib/runner.d.ts.map +1 -1
  72. package/dist/lib/runner.js +216 -38
  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-file.d.ts +11 -0
  80. package/dist/lib/worker-e2e-file.d.ts.map +1 -0
  81. package/dist/lib/worker-e2e-file.js +69 -0
  82. package/dist/lib/worker-e2e.js +11 -46
  83. package/dist/lib/worker-process.d.ts +2 -0
  84. package/dist/lib/worker-process.d.ts.map +1 -0
  85. package/dist/lib/worker-process.js +55 -0
  86. package/dist/lib/worker-results.d.ts +3 -0
  87. package/dist/lib/worker-results.d.ts.map +1 -0
  88. package/dist/lib/worker-results.js +20 -0
  89. package/dist/lib/worker-server.d.ts +10 -0
  90. package/dist/lib/worker-server.d.ts.map +1 -0
  91. package/dist/lib/worker-server.js +113 -0
  92. package/dist/lib/worker.js +7 -28
  93. package/dist/test/coverage/fixture.d.ts +5 -0
  94. package/dist/test/coverage/fixture.d.ts.map +1 -0
  95. package/dist/test/coverage/fixture.js +32 -0
  96. package/dist/test/coverage/test-browser.d.ts +2 -0
  97. package/dist/test/coverage/test-browser.d.ts.map +1 -0
  98. package/dist/test/coverage/test-browser.js +24 -0
  99. package/dist/test/coverage/test-e2e.d.ts +2 -0
  100. package/dist/test/coverage/test-e2e.d.ts.map +1 -0
  101. package/dist/test/coverage/test-e2e.js +60 -0
  102. package/dist/test/coverage/test-unit.d.ts +2 -0
  103. package/dist/test/coverage/test-unit.d.ts.map +1 -0
  104. package/dist/test/coverage/test-unit.js +27 -0
  105. package/dist/test/framework.test.browser.d.ts +2 -0
  106. package/dist/test/framework.test.browser.d.ts.map +1 -0
  107. package/dist/test/framework.test.browser.js +107 -0
  108. package/dist/test/framework.test.e2e.d.ts.map +1 -0
  109. package/dist/test/framework.test.e2e.js +34 -0
  110. package/package.json +30 -9
  111. package/src/app/client/entry.ts +357 -0
  112. package/src/app/client/iframe.ts +18 -0
  113. package/src/app/server.ts +336 -0
  114. package/src/cli-entry.ts +15 -0
  115. package/src/cli.ts +382 -145
  116. package/src/index.ts +2 -1
  117. package/src/lib/colors.ts +3 -0
  118. package/src/lib/config.ts +266 -54
  119. package/src/lib/context.ts +59 -17
  120. package/src/lib/coverage-loader.ts +31 -0
  121. package/src/lib/coverage.ts +320 -0
  122. package/src/lib/executor.ts +18 -35
  123. package/src/lib/fake-timers.ts +89 -0
  124. package/src/lib/import-module.ts +39 -0
  125. package/src/lib/{utils.ts → normalize.ts} +0 -18
  126. package/src/lib/playwright.ts +5 -7
  127. package/src/lib/reporters/dot.ts +12 -2
  128. package/src/lib/reporters/files.ts +12 -2
  129. package/src/lib/reporters/index.ts +4 -5
  130. package/src/lib/reporters/results.ts +29 -0
  131. package/src/lib/reporters/spec.ts +12 -2
  132. package/src/lib/reporters/tap.ts +11 -2
  133. package/src/lib/runner-browser.ts +171 -0
  134. package/src/lib/runner.ts +308 -53
  135. package/src/lib/runtime.ts +2 -0
  136. package/src/lib/ts-transform.ts +36 -0
  137. package/src/lib/worker-e2e-file.ts +98 -0
  138. package/src/lib/worker-e2e.ts +14 -49
  139. package/src/lib/worker-process.ts +69 -0
  140. package/src/lib/worker-results.ts +22 -0
  141. package/src/lib/worker-server.ts +123 -0
  142. package/src/lib/worker.ts +8 -28
  143. package/src/test/coverage/fixture.ts +34 -0
  144. package/src/test/coverage/test-browser.ts +29 -0
  145. package/src/test/coverage/test-e2e.ts +70 -0
  146. package/src/test/coverage/test-unit.ts +32 -0
  147. package/tsconfig.json +3 -1
  148. package/dist/lib/e2e-server.d.ts +0 -11
  149. package/dist/lib/e2e-server.d.ts.map +0 -1
  150. package/dist/lib/e2e-server.js +0 -15
  151. package/dist/lib/framework.test.d.ts +0 -2
  152. package/dist/lib/framework.test.d.ts.map +0 -1
  153. package/dist/lib/framework.test.e2e.d.ts.map +0 -1
  154. package/dist/lib/framework.test.e2e.js +0 -29
  155. package/dist/lib/framework.test.js +0 -283
  156. package/dist/lib/utils.d.ts +0 -16
  157. package/dist/lib/utils.d.ts.map +0 -1
  158. package/src/lib/e2e-server.ts +0 -28
  159. /package/dist/{lib → test}/framework.test.e2e.d.ts +0 -0
package/README.md CHANGED
@@ -1,13 +1,15 @@
1
1
  # `test`
2
2
 
3
- A test framework for Remix applications
3
+ A test framework for JavaScript and TypeScript projects.
4
4
 
5
5
  ## Features
6
6
 
7
7
  - `describe`/`it` test structure with `before`/`after`/`beforeEach`/`afterEach` hooks
8
8
  - Server-side unit testing
9
9
  - Playwright E2E testing via `t.serve`
10
+ - In-browser component testing (pair with `render` from `remix/ui/test`)
10
11
  - Mock functions and method spies via `t.mock.fn` / `t.mock.method`
12
+ - Unified code coverage reporting across unit and E2E tests
11
13
  - Watch mode
12
14
  - Config file support (`remix-test.config.ts`)
13
15
 
@@ -36,16 +38,24 @@ describe('My Test Suite', () => {
36
38
  Run tests with the CLI:
37
39
 
38
40
  ```sh
39
- remix-test
41
+ remix test
40
42
  ```
41
43
 
42
- By default, `remix-test` discovers all files matching `**/*.test.{ts,tsx}`. Pass a glob as the first positional argument to override:
44
+ By default, `remix test` discovers all files matching `**/*.test{,.e2e}.{ts,tsx}`. Pass one or more globs as positional arguments to override:
43
45
 
44
46
  ```sh
45
- remix-test "src/**/*.test.ts"
47
+ remix test "src/**/*.test.ts"
48
+ remix test "src/**/*.test.ts" "tests/**/*.test.tsx"
46
49
  ```
47
50
 
48
- Or, you may control via the `glob.test` config field/CLI arg.
51
+ Or, you may control via the `glob.test` config field/CLI arg. Each `glob.*` field accepts a single string or an array of patterns, and `--glob.*` flags can be repeated on the CLI.
52
+
53
+ If you install `@remix-run/test` directly instead of the umbrella `remix` package, the same runner is available as `remix-test`:
54
+
55
+ ```sh
56
+ npm i @remix-run/test
57
+ remix-test
58
+ ```
49
59
 
50
60
  ### Config File
51
61
 
@@ -67,10 +77,32 @@ export default {
67
77
  // Max number of concurrent test workers (default `os.availableParallelism()`)
68
78
  concurrency: 2,
69
79
 
80
+ // Pool for server and E2E test files ("forks", "threads")
81
+ pool: 'forks',
82
+
83
+ // Code coverage options
84
+ coverage: {
85
+ // Enable coverage reporting
86
+ enabled: true,
87
+ // Output directory (default: ".coverage")
88
+ dir: '.coverage',
89
+ // Glob pattern(s) to include/exclude
90
+ include: 'src/**',
91
+ exclude: 'src/**/*.test.ts',
92
+ // Minimum thresholds (%)
93
+ statements: 80,
94
+ lines: 80,
95
+ branches: 80,
96
+ functions: 80,
97
+ },
98
+
99
+ // Glob pattern(s) identifying test files
70
100
  glob: {
71
- // Glob pattern identifying all test files (default: "**/*.test?(.e2e).{ts,tsx}")
72
- test: '**/*.test?(.e2e).ts',
73
- // Global pattern identifying the subset of E2E test files{ts,tsx}")
101
+ // All test files (default: "**/*.test{,.browser,.e2e}.{ts,tsx}").
102
+ test: '**/*.test{,.browser,.e2e}.ts',
103
+ // Browser test files (default: "**/*.test.browser.{ts,tsx}")
104
+ browser: '**/*.test.browser.ts',
105
+ // E2E test files (default: "**/*.test.e2e.{ts,tsx}")
74
106
  e2e: '**/*.test.e2e.ts',
75
107
  },
76
108
 
@@ -87,7 +119,7 @@ export default {
87
119
  },
88
120
  },
89
121
 
90
- // Comma-separated list of playwright projects to run E2E tests for
122
+ // Playwright project(s) to run E2E tests for
91
123
  project: 'chromium',
92
124
 
93
125
  // Test reporter ("spec", "files", "tap", "dot")
@@ -96,8 +128,8 @@ export default {
96
128
  // Path to a setup module (see Setup section below)
97
129
  setup: './test/setup.ts',
98
130
 
99
- // Comma-separated list of test types to run ("server", "e2e")
100
- type: 'server,e2e',
131
+ // Test type(s) to run ("server", "browser", "e2e")
132
+ type: ['server', 'browser', 'e2e'],
101
133
 
102
134
  // Watch for file changes and re-run
103
135
  watch: false,
@@ -109,24 +141,34 @@ export default {
109
141
  You can point to a different config file location with the `--config` flag:
110
142
 
111
143
  ```sh
112
- remix-test --config ./tests/config.ts
144
+ remix test --config ./tests/config.ts
113
145
  ```
114
146
 
115
147
  You may also specify any config field as a CLI flag which will take precedence over config file values:
116
148
 
117
- | Flag | Short |
118
- | --------------------------- | ----- |
119
- | `--browser.echo` | |
120
- | `--browser.open` | |
121
- | `--concurrency <n>` | `-c` |
122
- | `--glob.test` | |
123
- | `--glob.e2e` | |
124
- | `--playwrightConfig <path>` | |
125
- | `--project <name>` | `-p` |
126
- | `--reporter <name>` | `-r` |
127
- | `--setup <path>` | `-s` |
128
- | `--type <name>` | `-t` |
129
- | `--watch` | `-w` |
149
+ | Flag | Short |
150
+ | --------------------------- | --------- | --- |
151
+ | `--browser.echo` | |
152
+ | `--browser.open` | |
153
+ | `--concurrency <n>` | `-c` |
154
+ | `--coverage` | |
155
+ | `--coverage.dir <path>` | |
156
+ | `--coverage.include` | |
157
+ | `--coverage.exclude` | |
158
+ | `--coverage.statements` | |
159
+ | `--coverage.lines` | |
160
+ | `--coverage.branches` | |
161
+ | `--coverage.functions` | |
162
+ | `--glob.test` | |
163
+ | `--glob.browser` | |
164
+ | `--glob.e2e` | |
165
+ | `--playwrightConfig <path>` | |
166
+ | `--pool <forks | threads>` | |
167
+ | `--project <name>` | `-p` |
168
+ | `--reporter <name>` | `-r` |
169
+ | `--setup <path>` | `-s` |
170
+ | `--type <name>` | `-t` |
171
+ | `--watch` | `-w` |
130
172
 
131
173
  ### Setup
132
174
 
@@ -172,11 +214,30 @@ suite('My Test Suite', () => {
172
214
  })
173
215
  ```
174
216
 
217
+ ### Programmatic runner
218
+
219
+ `@remix-run/test/cli` exports `runRemixTest()` for tools that want to run the test runner without
220
+ exiting the current process:
221
+
222
+ ```ts
223
+ import { runRemixTest } from '@remix-run/test/cli'
224
+
225
+ let exitCode = await runRemixTest({
226
+ argv: ['--type', 'server'],
227
+ cwd: process.cwd(),
228
+ })
229
+ ```
230
+
231
+ `runRemixTest()` returns the runner exit code. The `remix test` and `remix-test` bin wrappers call
232
+ `process.exit()` with that code when the run finishes so open workers, browsers, or project handles
233
+ cannot keep the CLI alive.
234
+
175
235
  ### Test Context
176
236
 
177
237
  Each test callback receives a `TestContext` (`t`) as its first argument with helpful test utilities.
178
238
 
179
239
  ```ts
240
+ // from 'remix/test'
180
241
  interface TestContext {
181
242
  // Register a cleanup function to run after the test completes
182
243
  after(fn: () => void): void
@@ -194,8 +255,11 @@ interface TestContext {
194
255
  ): MockFunction
195
256
  }
196
257
 
197
- // E2E only: start a server with the given request handler, returns a Playwright Page
198
- serve(handler: (req: Request) => Promise<Response>): Promise<Page>
258
+ // Replace global timer functions with controllable fakes
259
+ useFakeTimers(): FakeTimers
260
+
261
+ // E2E only: connect a running test server to a Playwright Page
262
+ serve(server: { baseUrl: string; close(): Promise<void> }): Promise<Page>
199
263
  }
200
264
  ```
201
265
 
@@ -230,14 +294,40 @@ it('cleanup', (t) => {
230
294
  })
231
295
  ```
232
296
 
297
+ #### Fake Timers
298
+
299
+ `t.useFakeTimers()` replaces the global timer functions (`setTimeout`, `setInterval`, etc.) with controllable fakes that are automatically restored after the test. It works in any test environment — server unit tests, browser tests, or E2E setup code.
300
+
301
+ ```ts
302
+ it('debounces a callback', (t) => {
303
+ let timers = t.useFakeTimers()
304
+ let calls = 0
305
+ let debounced = debounce(() => calls++, 300)
306
+
307
+ debounced()
308
+ timers.advance(299)
309
+ assert.equal(calls, 0)
310
+ timers.advance(1)
311
+ assert.equal(calls, 1)
312
+ })
313
+ ```
314
+
315
+ | Method | Description |
316
+ | ------------- | --------------------------------------------------------------------------- |
317
+ | `advance(ms)` | Advance the clock by `ms` milliseconds, firing any elapsed timers |
318
+ | `restore()` | Restore the original timer functions (called automatically after each test) |
319
+
233
320
  #### E2E
234
321
 
235
- In E2E test files, `t.serve()` starts an HTTP server and returns a Playwright `Page`. See [E2E Testing](#e2e-testing) for details.
322
+ In E2E test files, `t.serve()` connects a running test server to a Playwright `Page`. See [E2E Testing](#e2e-testing) for details.
236
323
 
237
324
  ```ts
325
+ import { createTestServer } from 'remix/node-fetch-server/test'
326
+
238
327
  it('navigates to home', async (t) => {
239
328
  let router = createRouter()
240
- let page = await t.serve(router.fetch)
329
+ let server = await createTestServer(router.fetch)
330
+ let page = await t.serve(server)
241
331
  await page.goto('/')
242
332
  })
243
333
  ```
@@ -254,21 +344,60 @@ let spy = mock.method(console, 'log')
254
344
  spy.mock.restore?.()
255
345
  ```
256
346
 
347
+ ### Browser Testing
348
+
349
+ Browser tests run components in an actual browser environment via Playwright and are discovered by the `**/*.test.browser.{ts,tsx}` glob pattern (configurable via `glob.browser`). They use the same `describe`/`it` API as unit tests. Each in-browser test suite runs in an isolated `iframe` so it has access to its own `document` instance.
350
+
351
+ #### `render()`
352
+
353
+ `render`, exported from `remix/ui/test`, mounts a component into the DOM and returns a `RenderResult`:
354
+
355
+ ```ts
356
+ import * as assert from 'remix/assert'
357
+ import { describe, it } from 'remix/test'
358
+ import { render } from 'remix/ui/test'
359
+ import { Counter } from './counter.tsx'
360
+
361
+ describe('Counter', () => {
362
+ it('increments on click', async (t) => {
363
+ let { $, act, cleanup } = render(<Counter />)
364
+ t.after(cleanup)
365
+
366
+ assert.equal($('[data-count]')?.textContent, '0')
367
+ await act(() => $('[data-action="increment"]')?.click())
368
+ assert.equal($('[data-count]')?.textContent, '1')
369
+ })
370
+ })
371
+ ```
372
+
373
+ `RenderResult` provides:
374
+
375
+ | Property/Method | Description |
376
+ | --------------- | ----------------------------------------------------------------------- |
377
+ | `container` | The `HTMLElement` the component is mounted into |
378
+ | `root` | The Remix `VirtualRoot` the component is rendered in |
379
+ | `$(selector)` | Alias for `container.querySelector()` |
380
+ | `$$(selector)` | Alias for `container.querySelectorAll()` |
381
+ | `act(fn)` | Runs `fn` and flushes pending component updates |
382
+ | `cleanup()` | Unmounts and removes the container (pass to `t.after` for auto-cleanup) |
383
+
257
384
  ### E2E Testing
258
385
 
259
- E2E tests use [Playwright](https://playwright.dev) and are discovered by the `**/*.test.e2e.{ts,tsx}` glob pattern (configurable via `glob.e2e`). They use the same `describe`/`it` API as unit tests.
386
+ End-to-end (E2E) tests use [Playwright](https://playwright.dev) and are discovered by the `**/*.test.e2e.{ts,tsx}` glob pattern (configurable via `glob.e2e`). They use the same `describe`/`it` API as unit tests.
260
387
 
261
- E2E tests receive `t.serve()` on the test context, which starts an HTTP server with the given request handler and returns a Playwright [`Page`](https://playwright.dev/docs/api/class-page). The server and page are automatically closed after each test.
388
+ E2E tests receive `t.serve()` on the test context, which accepts a running test server and returns a Playwright [`Page`](https://playwright.dev/docs/api/class-page) whose `baseURL` points at that server. The server and page are automatically closed after each test.
262
389
 
263
390
  ```ts
264
391
  import * as assert from 'remix/assert'
392
+ import { createTestServer } from 'remix/node-fetch-server/test'
265
393
  import { describe, it } from 'remix/test'
266
394
  import { createRouter } from './router.ts'
267
395
 
268
396
  describe('checkout', () => {
269
397
  it('adds an item to the cart', async (t) => {
270
398
  let router = createRouter()
271
- let page = await t.serve(router.fetch)
399
+ let server = await createTestServer(router.fetch)
400
+ let page = await t.serve(server)
272
401
 
273
402
  await page.goto('/')
274
403
  await page.getByRole('button', { name: 'Add to Cart' }).click()
@@ -303,24 +432,6 @@ export default {
303
432
 
304
433
  Set `browser.open: true` to keep the browser open after tests finish — useful for debugging failures.
305
434
 
306
- ### Assertions
307
-
308
- `remix/test` re-exports `remix/assert`. See the [`@remix-run/assert` README](../assert/README.md) for full API documentation.
309
-
310
- ```ts
311
- import * as assert from 'remix/assert'
312
-
313
- assert.ok(value)
314
- assert.equal(actual, expected)
315
- assert.notEqual(actual, expected)
316
- assert.deepEqual(actual, expected)
317
- assert.notDeepEqual(actual, expected)
318
- assert.match(string, regexp)
319
- assert.throws(fn)
320
- await assert.rejects(asyncFn)
321
- assert.fail('message')
322
- ```
323
-
324
435
  ## License
325
436
 
326
437
  See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=entry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entry.d.ts","sourceRoot":"","sources":["../../../src/app/client/entry.ts"],"names":[],"mappings":""}
@@ -0,0 +1,328 @@
1
+ import { normalizeLine } from "../../lib/normalize.js";
2
+ const STYLES = `
3
+ .rt-container {
4
+ font-family: monospace;
5
+ padding: 16px;
6
+ max-width: 900px;
7
+ }
8
+ .rt-summary {
9
+ margin-bottom: 16px;
10
+ line-height: 1.6;
11
+ }
12
+ .rt-summary-row {
13
+ display: block;
14
+ }
15
+ .rt-info {
16
+ color: #0ea5e9;
17
+ }
18
+ .rt-indent {
19
+ margin-left: 16px;
20
+ margin-top: 4px;
21
+ }
22
+ .rt-suite-details {
23
+ margin-bottom: 8px;
24
+ }
25
+ .rt-suite-summary {
26
+ cursor: pointer;
27
+ padding: 2px 0;
28
+ user-select: none;
29
+ }
30
+ .rt-suite-icon {
31
+ margin-left: 6px;
32
+ }
33
+ .rt-test-item {
34
+ padding: 3px 18px;
35
+ }
36
+ .rt-test-duration {
37
+ color: #999;
38
+ font-size: 0.85em;
39
+ }
40
+ .rt-error-pre {
41
+ margin: 4px 0 4px 16px;
42
+ padding: 8px 12px;
43
+ font-size: 12px;
44
+ color: #dc2626;
45
+ background: #fff5f5;
46
+ border-left: 3px solid #dc2626;
47
+ white-space: pre-wrap;
48
+ word-break: break-word;
49
+ }
50
+ .rt-error-stack {
51
+ color: #999;
52
+ margin-top: 6px;
53
+ }
54
+ .rt-button {
55
+ margin-top: 8px;
56
+ padding: 6px 12px;
57
+ cursor: pointer;
58
+ }
59
+ .rt-stack-link {
60
+ color: inherit;
61
+ text-decoration: underline;
62
+ text-decoration-color: #aaa;
63
+ }
64
+ .rt-passed {
65
+ color: #16a34a;
66
+ }
67
+ .rt-failed {
68
+ color: #dc2626;
69
+ }
70
+ .rt-muted {
71
+ color: #666;
72
+ }
73
+ .rt-todo {
74
+ color: #a16207;
75
+ }
76
+ `;
77
+ const styleEl = document.createElement('style');
78
+ styleEl.textContent = STYLES;
79
+ document.head.appendChild(styleEl);
80
+ const setupEl = document.getElementById('test-setup');
81
+ if (!setupEl?.textContent) {
82
+ throw new Error('Test runner: missing #test-setup payload');
83
+ }
84
+ const setup = JSON.parse(setupEl.textContent);
85
+ const root = document.getElementById('test-root');
86
+ if (!root) {
87
+ throw new Error('Test runner: missing #test-root mount point');
88
+ }
89
+ mountTests(root, setup);
90
+ function mountTests(host, setup) {
91
+ let startTime = performance.now();
92
+ let totals = { passed: 0, failed: 0, skipped: 0, todo: 0 };
93
+ let container = el('div', { id: 'test-status', className: 'rt-container' });
94
+ host.appendChild(container);
95
+ let summary = el('div', { className: 'rt-summary' });
96
+ container.appendChild(summary);
97
+ let testsRow = summaryRow();
98
+ let passRow = summaryRow();
99
+ let failRow = summaryRow();
100
+ let skippedRow = summaryRow();
101
+ let todoRow = summaryRow();
102
+ let durationRow = summaryRow();
103
+ summary.append(testsRow.el, passRow.el, failRow.el);
104
+ let suitesContainer = el('div');
105
+ container.appendChild(suitesContainer);
106
+ function renderSummary(done) {
107
+ let total = totals.passed + totals.failed + totals.skipped + totals.todo;
108
+ testsRow.text(`tests ${total}`);
109
+ passRow.text(`pass ${totals.passed}`);
110
+ failRow.text(`fail ${totals.failed}`);
111
+ if (totals.skipped > 0) {
112
+ if (!skippedRow.el.parentNode)
113
+ summary.appendChild(skippedRow.el);
114
+ skippedRow.text(`skipped ${totals.skipped}`);
115
+ }
116
+ if (totals.todo > 0) {
117
+ if (!todoRow.el.parentNode)
118
+ summary.appendChild(todoRow.el);
119
+ todoRow.text(`todo ${totals.todo}`);
120
+ }
121
+ if (done) {
122
+ if (!durationRow.el.parentNode)
123
+ summary.appendChild(durationRow.el);
124
+ durationRow.text(`duration_ms ${(performance.now() - startTime).toFixed(5)}`);
125
+ }
126
+ }
127
+ function appendFileSuites(fileResults) {
128
+ let suiteMap = new Map();
129
+ for (let test of fileResults.tests) {
130
+ let suite = test.suiteName || 'Tests';
131
+ if (!suiteMap.has(suite))
132
+ suiteMap.set(suite, []);
133
+ suiteMap.get(suite).push(test);
134
+ }
135
+ for (let [suiteName, tests] of suiteMap) {
136
+ suitesContainer.appendChild(buildSuite(suiteName, tests, setup.baseDir));
137
+ }
138
+ }
139
+ function appendRerunButton() {
140
+ let button = el('button', { className: 'rt-button', textContent: 'Re-run' });
141
+ button.type = 'button';
142
+ button.addEventListener('click', () => window.location.reload());
143
+ container.appendChild(button);
144
+ }
145
+ renderSummary(false);
146
+ void (async () => {
147
+ for (let testFile of setup.testPaths) {
148
+ let fileResults = await runInIframe(testFile);
149
+ await fetch('/file-results', {
150
+ method: 'POST',
151
+ headers: { 'Content-Type': 'application/json' },
152
+ body: JSON.stringify(fileResults),
153
+ });
154
+ totals.passed += fileResults.passed;
155
+ totals.failed += fileResults.failed;
156
+ totals.skipped += fileResults.skipped;
157
+ totals.todo += fileResults.todo;
158
+ appendFileSuites(fileResults);
159
+ renderSummary(false);
160
+ }
161
+ renderSummary(true);
162
+ appendRerunButton();
163
+ window.__testsDone = true;
164
+ })();
165
+ }
166
+ function runInIframe(testFile) {
167
+ return new Promise((resolve) => {
168
+ let iframe = document.createElement('iframe');
169
+ iframe.src = `/iframe?file=${encodeURIComponent(testFile)}`;
170
+ // Make the iframe as big so we don't get unintentional scrolling in test UIs
171
+ let parentBody = iframe.contentWindow?.document.body;
172
+ iframe.width = Math.max(parentBody?.scrollWidth ?? 0, 800).toString();
173
+ iframe.height = Math.max(Math.round((parentBody?.scrollHeight ?? 0) / 2), 400).toString();
174
+ document.body.appendChild(iframe);
175
+ function onMessage(event) {
176
+ if (event.source !== iframe.contentWindow)
177
+ return;
178
+ window.removeEventListener('message', onMessage);
179
+ // Hide instead of remove so when coverage is enabled the iframe remains attached
180
+ // so V8 retains its scripts and Playwright can collect coverage at run end.
181
+ iframe.style.display = 'none';
182
+ if (event.data.type === 'test-results') {
183
+ let { passed, failed, skipped, todo, tests } = event.data.results;
184
+ resolve({
185
+ passed,
186
+ failed,
187
+ skipped,
188
+ todo,
189
+ tests: tests.map((t) => ({ ...t, filePath: testFile })),
190
+ });
191
+ }
192
+ else {
193
+ let { message, stack } = event.data.error;
194
+ resolve({
195
+ passed: 0,
196
+ failed: 1,
197
+ skipped: 0,
198
+ todo: 0,
199
+ tests: [
200
+ {
201
+ name: '',
202
+ suiteName: testFile,
203
+ filePath: testFile,
204
+ status: 'failed',
205
+ error: { message, stack },
206
+ duration: 0,
207
+ },
208
+ ],
209
+ });
210
+ }
211
+ }
212
+ window.addEventListener('message', onMessage);
213
+ });
214
+ }
215
+ function buildSuite(suiteName, tests, baseDir) {
216
+ let suiteFailed = tests.some((t) => t.status === 'failed');
217
+ let suiteAllSkipped = tests.every((t) => t.status === 'skipped');
218
+ let suiteAllTodo = tests.every((t) => t.status === 'todo');
219
+ let stateClass = suiteFailed
220
+ ? 'rt-failed'
221
+ : suiteAllSkipped
222
+ ? 'rt-muted'
223
+ : suiteAllTodo
224
+ ? 'rt-todo'
225
+ : 'rt-passed';
226
+ let icon = suiteFailed ? '✗' : suiteAllSkipped ? '↓' : suiteAllTodo ? '…' : '✓';
227
+ let suffix = suiteAllSkipped ? ' # skipped' : suiteAllTodo ? ' # todo' : '';
228
+ let details = el('details', { className: 'rt-suite-details' });
229
+ if (suiteFailed)
230
+ details.open = true;
231
+ let summary = el('summary', { className: `rt-suite-summary ${stateClass}` });
232
+ summary.appendChild(el('span', { className: 'rt-suite-icon', textContent: `${icon} ${suiteName}${suffix}` }));
233
+ details.appendChild(summary);
234
+ let body = el('div', { className: 'rt-indent' });
235
+ for (let test of tests) {
236
+ let item = buildTestItem(test, baseDir);
237
+ if (item)
238
+ body.appendChild(el('div', { className: 'rt-test-item' }, item));
239
+ }
240
+ details.appendChild(body);
241
+ return details;
242
+ }
243
+ function buildTestItem(test, baseDir) {
244
+ if (test.status === 'passed') {
245
+ let row = el('div', { className: 'rt-passed' });
246
+ row.append(`✓ ${test.name} `);
247
+ row.appendChild(el('span', {
248
+ className: 'rt-test-duration',
249
+ textContent: `(${test.duration.toFixed(2)}ms)`,
250
+ }));
251
+ return row;
252
+ }
253
+ if (test.status === 'failed') {
254
+ let row = el('div', { className: 'rt-failed' });
255
+ row.append(`✗ ${test.name} `);
256
+ row.appendChild(el('span', {
257
+ className: 'rt-test-duration',
258
+ textContent: `(${test.duration.toFixed(2)}ms)`,
259
+ }));
260
+ if (test.error) {
261
+ let pre = el('pre', { className: 'rt-error-pre' });
262
+ pre.append(test.error.message);
263
+ if (test.error.stack) {
264
+ let stackDiv = el('div', { className: 'rt-error-stack' });
265
+ stackDiv.appendChild(buildStack(test.error.stack, baseDir));
266
+ pre.appendChild(stackDiv);
267
+ }
268
+ row.appendChild(pre);
269
+ }
270
+ return row;
271
+ }
272
+ if (test.status === 'skipped' && test.name) {
273
+ return el('div', { className: 'rt-muted', textContent: `↓ ${test.name} # skipped` });
274
+ }
275
+ if (test.status === 'todo' && test.name) {
276
+ return el('div', { className: 'rt-todo', textContent: `… ${test.name} # todo` });
277
+ }
278
+ return null;
279
+ }
280
+ function buildStack(stack, baseDir) {
281
+ let frameLocRe = /([^():\s][^():]*\.[jt]sx?):(\d+):(\d+)/;
282
+ let frag = document.createDocumentFragment();
283
+ for (let raw of stack.split('\n')) {
284
+ let isTestModule = raw.includes('/@test/');
285
+ let line = normalizeLine(raw);
286
+ let match = isTestModule ? frameLocRe.exec(line) : null;
287
+ let div = document.createElement('div');
288
+ if (match) {
289
+ let [full, file, row, col] = match;
290
+ let abs = `${baseDir}/${file}`;
291
+ let href = `vscode://file/${abs}:${row}:${col}`;
292
+ div.append(line.slice(0, match.index));
293
+ let a = el('a', { className: 'rt-stack-link', textContent: full });
294
+ a.href = href;
295
+ div.appendChild(a);
296
+ div.append(line.slice(match.index + full.length));
297
+ }
298
+ else {
299
+ div.textContent = line;
300
+ }
301
+ frag.appendChild(div);
302
+ }
303
+ return frag;
304
+ }
305
+ function summaryRow() {
306
+ let row = el('span', { className: 'rt-summary-row' });
307
+ let icon = el('span', { className: 'rt-info', textContent: 'ℹ' });
308
+ let textNode = document.createTextNode('');
309
+ row.append(icon, ' ', textNode);
310
+ return {
311
+ el: row,
312
+ text(s) {
313
+ textNode.data = ' ' + s;
314
+ },
315
+ };
316
+ }
317
+ function el(tag, props, ...children) {
318
+ let node = document.createElement(tag);
319
+ if (props?.id)
320
+ node.id = props.id;
321
+ if (props?.className)
322
+ node.className = props.className;
323
+ if (props?.textContent != null)
324
+ node.textContent = props.textContent;
325
+ if (children.length)
326
+ node.append(...children);
327
+ return node;
328
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=iframe.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"iframe.d.ts","sourceRoot":"","sources":["../../../src/app/client/iframe.ts"],"names":[],"mappings":""}
@@ -0,0 +1,22 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { runTests } from "../../lib/executor.js";
10
+ const params = new URLSearchParams(location.search);
11
+ const testFile = params.get('file');
12
+ try {
13
+ await import(__rewriteRelativeImportExtension(testFile));
14
+ let results = await runTests();
15
+ window.parent.postMessage({ type: 'test-results', results }, '*');
16
+ }
17
+ catch (error) {
18
+ window.parent.postMessage({
19
+ type: 'test-error',
20
+ error: { message: error?.message ?? String(error), stack: error?.stack },
21
+ }, '*');
22
+ }