@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
@@ -0,0 +1,107 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@remix-run/ui/jsx-runtime";
2
+ import decamelize from 'decamelize';
3
+ import * as assert from '@remix-run/assert';
4
+ import { describe, it } from '@remix-run/test';
5
+ import { on } from '@remix-run/ui';
6
+ import { render } from '@remix-run/ui/test';
7
+ describe('Counter', () => {
8
+ function Counter(handle) {
9
+ let count = handle.props.count ?? 0;
10
+ return () => (_jsxs("div", { children: [
11
+ _jsx("h3", { children: "Counter" }), _jsxs("div", { children: [
12
+ _jsx("button", { "data-action": "decrement", mix: [
13
+ on('click', () => {
14
+ count--;
15
+ handle.update();
16
+ }),
17
+ ], children: "-" }), _jsx("span", { "data-testid": "count", style: { fontSize: '24px', minWidth: '2ch', textAlign: 'center' }, children: count }), _jsx("button", { "data-action": "increment", mix: [
18
+ on('click', () => {
19
+ count++;
20
+ handle.update();
21
+ }),
22
+ ], children: "+" })
23
+ ] })
24
+ ] }));
25
+ }
26
+ it('renders with initial count of 0 when not specified', (t) => {
27
+ let { $, cleanup } = render(_jsx(Counter, {}));
28
+ t.after(cleanup);
29
+ assert.equal(Number($('[data-testid="count"]').textContent), 0);
30
+ });
31
+ it('renders with a provided initial count', (t) => {
32
+ let { $, cleanup } = render(_jsx(Counter, { count: 5 }));
33
+ t.after(cleanup);
34
+ assert.equal(Number($('[data-testid="count"]').textContent), 5);
35
+ });
36
+ it('increments the count', async (t) => {
37
+ let { $, act, cleanup } = render(_jsx(Counter, {}));
38
+ t.after(cleanup);
39
+ await act(() => $('[data-action="increment"]')?.click());
40
+ assert.equal(Number($('[data-testid="count"]').textContent), 1);
41
+ await act(() => $('[data-action="increment"]')?.click());
42
+ assert.equal(Number($('[data-testid="count"]').textContent), 2);
43
+ await act(() => $('[data-action="increment"]')?.click());
44
+ assert.equal(Number($('[data-testid="count"]').textContent), 3);
45
+ });
46
+ it('decrements the count', async (t) => {
47
+ let { $, act, cleanup } = render(_jsx(Counter, { count: 3 }));
48
+ t.after(cleanup);
49
+ await act(() => $('[data-action="decrement"]')?.click());
50
+ assert.equal(Number($('[data-testid="count"]').textContent), 2);
51
+ await act(() => $('[data-action="decrement"]')?.click());
52
+ assert.equal(Number($('[data-testid="count"]').textContent), 1);
53
+ await act(() => $('[data-action="decrement"]')?.click());
54
+ assert.equal(Number($('[data-testid="count"]').textContent), 0);
55
+ });
56
+ });
57
+ describe('FieldLabel (using decamelize)', () => {
58
+ // Demonstrates that ESM third-party libraries are importable from test modules
59
+ function FieldLabel(_handle) {
60
+ return (props) => (_jsx("span", { "data-testid": "label", children: decamelize(props.name, { separator: ' ' }) }));
61
+ }
62
+ it('renders a single word unchanged', (t) => {
63
+ let { $, cleanup } = render(_jsx(FieldLabel, { name: "name" }));
64
+ t.after(cleanup);
65
+ assert.equal($('[data-testid="label"]')?.textContent, 'name');
66
+ });
67
+ it('converts camelCase to spaced words', (t) => {
68
+ let { $, cleanup } = render(_jsx(FieldLabel, { name: "firstName" }));
69
+ t.after(cleanup);
70
+ assert.equal($('[data-testid="label"]')?.textContent, 'first name');
71
+ });
72
+ it('handles multiple humps', (t) => {
73
+ let { $, cleanup } = render(_jsx(FieldLabel, { name: "dateOfBirth" }));
74
+ t.after(cleanup);
75
+ assert.equal($('[data-testid="label"]')?.textContent, 'date of birth');
76
+ });
77
+ });
78
+ describe('DOM Tests', () => {
79
+ it('can interact with DOM', async () => {
80
+ let div = document.createElement('div');
81
+ div.textContent = 'Hello';
82
+ assert.equal(div.textContent, 'Hello');
83
+ });
84
+ it('can test fetch API', async () => {
85
+ let response = await fetch('data:text/plain,hello');
86
+ let text = await response.text();
87
+ assert.equal(text, 'hello');
88
+ });
89
+ it.skip('skip: can skip tests', () => {
90
+ assert.equal(true, false);
91
+ });
92
+ it.todo('todo: can mark tests as todo');
93
+ });
94
+ describe('render/cleanup', () => {
95
+ it('cleanup removes the container from the DOM', () => {
96
+ let { container, cleanup } = render(_jsx("div", { "data-testid": "manual", children: "hello" }));
97
+ assert.equal(document.body.contains(container), true);
98
+ cleanup();
99
+ assert.equal(document.body.contains(container), false);
100
+ });
101
+ });
102
+ describe.skip('skip: Skipped Test Suite', () => {
103
+ it('would fail', () => {
104
+ assert.equal(true, false);
105
+ });
106
+ });
107
+ describe.todo('todo: Test Suite');
@@ -0,0 +1 @@
1
+ {"version":3,"file":"framework.test.e2e.d.ts","sourceRoot":"","sources":["../../src/test/framework.test.e2e.tsx"],"names":[],"mappings":""}
@@ -0,0 +1,34 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@remix-run/ui/jsx-runtime";
2
+ import * as assert from 'node:assert/strict';
3
+ import { renderToString } from '@remix-run/ui/server';
4
+ import { createTestServer } from '@remix-run/node-fetch-server/test';
5
+ import { describe, it } from "../lib/framework.js";
6
+ const html = async (n) => new Response(await renderToString(n), {
7
+ headers: { 'Content-Type': 'text/html' },
8
+ });
9
+ describe('e2e tests', () => {
10
+ it('runs playwright against a fetch handler', async (t) => {
11
+ function Doc(handle) {
12
+ return () => (_jsxs("html", { children: [
13
+ _jsx("head", { children: _jsx("title", { children: "Test" }) }), _jsx("body", { children: handle.props.children })
14
+ ] }));
15
+ }
16
+ let handler = (request) => {
17
+ let url = new URL(request.url);
18
+ if (url.pathname === '/') {
19
+ return html(_jsxs(Doc, { children: [
20
+ _jsx("h1", { children: "Hello Remix" }), _jsx("a", { href: "/about", children: "About" })
21
+ ] }));
22
+ }
23
+ if (url.pathname === '/about') {
24
+ return html(_jsx(Doc, { children: _jsx("h1", { children: "About Remix" }) }));
25
+ }
26
+ return new Response('Not found', { status: 404 });
27
+ };
28
+ let page = await t.serve(await createTestServer(handler));
29
+ await page.goto('/');
30
+ assert.equal(await page.locator('h1').textContent(), 'Hello Remix');
31
+ await page.click('[href="/about"]');
32
+ assert.equal(await page.locator('h1').textContent(), 'About Remix');
33
+ });
34
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@remix-run/test",
3
- "version": "0.1.0",
4
- "description": "A browser-based test framework for Remix components",
3
+ "version": "0.3.0",
4
+ "description": "A test framework for JavaScript and TypeScript projects",
5
5
  "author": "Shopify Inc.",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -19,8 +19,11 @@
19
19
  "!src/**/*.test.*"
20
20
  ],
21
21
  "type": "module",
22
+ "engines": {
23
+ "node": ">=24.3.0"
24
+ },
22
25
  "bin": {
23
- "remix-test": "./dist/cli.js"
26
+ "remix-test": "./dist/cli-entry.js"
24
27
  },
25
28
  "exports": {
26
29
  ".": {
@@ -28,13 +31,23 @@
28
31
  "default": "./dist/index.js"
29
32
  },
30
33
  "./package.json": "./package.json",
31
- "./cli": "./dist/cli.js"
34
+ "./cli": {
35
+ "types": "./dist/cli.d.ts",
36
+ "default": "./dist/cli.js"
37
+ }
32
38
  },
33
39
  "dependencies": {
40
+ "es-module-lexer": "^2.0.0",
41
+ "esbuild": "^0.27.1",
42
+ "get-tsconfig": "^4.13.6",
43
+ "istanbul-lib-coverage": "^3.2.2",
44
+ "istanbul-lib-report": "^3.0.1",
45
+ "istanbul-reports": "^3.2.0",
46
+ "magic-string": "^0.30.21",
47
+ "source-map-js": "^1.2.1",
34
48
  "tsx": "^4.21.0",
35
- "@remix-run/component": "0.7.0",
36
- "@remix-run/fetch-router": "0.18.1",
37
- "@remix-run/node-fetch-server": "0.13.0"
49
+ "v8-to-istanbul": "^9.3.0",
50
+ "@remix-run/terminal": "^0.1.0"
38
51
  },
39
52
  "peerDependencies": {
40
53
  "playwright": "^1.59.0"
@@ -45,10 +58,17 @@
45
58
  }
46
59
  },
47
60
  "devDependencies": {
61
+ "@types/dom-navigation": "1.0.6",
62
+ "@types/istanbul-lib-coverage": "^2.0.6",
63
+ "@types/istanbul-lib-report": "^3.0.3",
64
+ "@types/istanbul-reports": "^3.0.4",
48
65
  "@types/node": "^24.6.0",
49
66
  "@typescript/native-preview": "7.0.0-dev.20251125.1",
67
+ "decamelize": "^6.0.1",
50
68
  "playwright": "^1.59.0",
51
- "@remix-run/assert": "0.1.0"
69
+ "@remix-run/node-fetch-server": "^0.13.1",
70
+ "@remix-run/assert": "^0.2.0",
71
+ "@remix-run/ui": "^0.1.1"
52
72
  },
53
73
  "keywords": [
54
74
  "testing",
@@ -61,7 +81,8 @@
61
81
  "scripts": {
62
82
  "build": "tsgo -p tsconfig.build.json",
63
83
  "clean": "git clean -fdX",
64
- "test": "node src/cli.ts",
84
+ "test": "node src/cli-entry.ts",
85
+ "test:bun": "bun --bun src/cli-entry.ts --type server",
65
86
  "typecheck": "tsgo --noEmit"
66
87
  }
67
88
  }
@@ -0,0 +1,357 @@
1
+ import { normalizeLine } from '../../lib/normalize.ts'
2
+ import type { TestResult, TestResults } from '../../lib/reporters/results.ts'
3
+
4
+ interface TestsSetup {
5
+ testPaths: string[]
6
+ baseDir: string
7
+ }
8
+
9
+ type FileResults = TestResults & { tests: Array<TestResult & { filePath: string }> }
10
+
11
+ const STYLES = `
12
+ .rt-container {
13
+ font-family: monospace;
14
+ padding: 16px;
15
+ max-width: 900px;
16
+ }
17
+ .rt-summary {
18
+ margin-bottom: 16px;
19
+ line-height: 1.6;
20
+ }
21
+ .rt-summary-row {
22
+ display: block;
23
+ }
24
+ .rt-info {
25
+ color: #0ea5e9;
26
+ }
27
+ .rt-indent {
28
+ margin-left: 16px;
29
+ margin-top: 4px;
30
+ }
31
+ .rt-suite-details {
32
+ margin-bottom: 8px;
33
+ }
34
+ .rt-suite-summary {
35
+ cursor: pointer;
36
+ padding: 2px 0;
37
+ user-select: none;
38
+ }
39
+ .rt-suite-icon {
40
+ margin-left: 6px;
41
+ }
42
+ .rt-test-item {
43
+ padding: 3px 18px;
44
+ }
45
+ .rt-test-duration {
46
+ color: #999;
47
+ font-size: 0.85em;
48
+ }
49
+ .rt-error-pre {
50
+ margin: 4px 0 4px 16px;
51
+ padding: 8px 12px;
52
+ font-size: 12px;
53
+ color: #dc2626;
54
+ background: #fff5f5;
55
+ border-left: 3px solid #dc2626;
56
+ white-space: pre-wrap;
57
+ word-break: break-word;
58
+ }
59
+ .rt-error-stack {
60
+ color: #999;
61
+ margin-top: 6px;
62
+ }
63
+ .rt-button {
64
+ margin-top: 8px;
65
+ padding: 6px 12px;
66
+ cursor: pointer;
67
+ }
68
+ .rt-stack-link {
69
+ color: inherit;
70
+ text-decoration: underline;
71
+ text-decoration-color: #aaa;
72
+ }
73
+ .rt-passed {
74
+ color: #16a34a;
75
+ }
76
+ .rt-failed {
77
+ color: #dc2626;
78
+ }
79
+ .rt-muted {
80
+ color: #666;
81
+ }
82
+ .rt-todo {
83
+ color: #a16207;
84
+ }
85
+ `
86
+
87
+ const styleEl = document.createElement('style')
88
+ styleEl.textContent = STYLES
89
+ document.head.appendChild(styleEl)
90
+
91
+ const setupEl = document.getElementById('test-setup')
92
+ if (!setupEl?.textContent) {
93
+ throw new Error('Test runner: missing #test-setup payload')
94
+ }
95
+ const setup = JSON.parse(setupEl.textContent) as TestsSetup
96
+ const root = document.getElementById('test-root')
97
+ if (!root) {
98
+ throw new Error('Test runner: missing #test-root mount point')
99
+ }
100
+ mountTests(root, setup)
101
+
102
+ function mountTests(host: HTMLElement, setup: TestsSetup): void {
103
+ let startTime = performance.now()
104
+ let totals = { passed: 0, failed: 0, skipped: 0, todo: 0 }
105
+
106
+ let container = el('div', { id: 'test-status', className: 'rt-container' })
107
+ host.appendChild(container)
108
+
109
+ let summary = el('div', { className: 'rt-summary' })
110
+ container.appendChild(summary)
111
+
112
+ let testsRow = summaryRow()
113
+ let passRow = summaryRow()
114
+ let failRow = summaryRow()
115
+ let skippedRow = summaryRow()
116
+ let todoRow = summaryRow()
117
+ let durationRow = summaryRow()
118
+ summary.append(testsRow.el, passRow.el, failRow.el)
119
+
120
+ let suitesContainer = el('div')
121
+ container.appendChild(suitesContainer)
122
+
123
+ function renderSummary(done: boolean) {
124
+ let total = totals.passed + totals.failed + totals.skipped + totals.todo
125
+ testsRow.text(`tests ${total}`)
126
+ passRow.text(`pass ${totals.passed}`)
127
+ failRow.text(`fail ${totals.failed}`)
128
+ if (totals.skipped > 0) {
129
+ if (!skippedRow.el.parentNode) summary.appendChild(skippedRow.el)
130
+ skippedRow.text(`skipped ${totals.skipped}`)
131
+ }
132
+ if (totals.todo > 0) {
133
+ if (!todoRow.el.parentNode) summary.appendChild(todoRow.el)
134
+ todoRow.text(`todo ${totals.todo}`)
135
+ }
136
+ if (done) {
137
+ if (!durationRow.el.parentNode) summary.appendChild(durationRow.el)
138
+ durationRow.text(`duration_ms ${(performance.now() - startTime).toFixed(5)}`)
139
+ }
140
+ }
141
+
142
+ function appendFileSuites(fileResults: FileResults) {
143
+ let suiteMap = new Map<string, TestResult[]>()
144
+ for (let test of fileResults.tests) {
145
+ let suite = test.suiteName || 'Tests'
146
+ if (!suiteMap.has(suite)) suiteMap.set(suite, [])
147
+ suiteMap.get(suite)!.push(test)
148
+ }
149
+ for (let [suiteName, tests] of suiteMap) {
150
+ suitesContainer.appendChild(buildSuite(suiteName, tests, setup.baseDir))
151
+ }
152
+ }
153
+
154
+ function appendRerunButton() {
155
+ let button = el('button', { className: 'rt-button', textContent: 'Re-run' })
156
+ button.type = 'button'
157
+ button.addEventListener('click', () => window.location.reload())
158
+ container.appendChild(button)
159
+ }
160
+
161
+ renderSummary(false)
162
+
163
+ void (async () => {
164
+ for (let testFile of setup.testPaths) {
165
+ let fileResults = await runInIframe(testFile)
166
+ await fetch('/file-results', {
167
+ method: 'POST',
168
+ headers: { 'Content-Type': 'application/json' },
169
+ body: JSON.stringify(fileResults),
170
+ })
171
+ totals.passed += fileResults.passed
172
+ totals.failed += fileResults.failed
173
+ totals.skipped += fileResults.skipped
174
+ totals.todo += fileResults.todo
175
+ appendFileSuites(fileResults)
176
+ renderSummary(false)
177
+ }
178
+ renderSummary(true)
179
+ appendRerunButton()
180
+ ;(window as unknown as { __testsDone?: boolean }).__testsDone = true
181
+ })()
182
+ }
183
+
184
+ function runInIframe(testFile: string): Promise<FileResults> {
185
+ return new Promise((resolve) => {
186
+ let iframe = document.createElement('iframe')
187
+ iframe.src = `/iframe?file=${encodeURIComponent(testFile)}`
188
+ // Make the iframe as big so we don't get unintentional scrolling in test UIs
189
+ let parentBody = iframe.contentWindow?.document.body
190
+ iframe.width = Math.max(parentBody?.scrollWidth ?? 0, 800).toString()
191
+ iframe.height = Math.max(Math.round((parentBody?.scrollHeight ?? 0) / 2), 400).toString()
192
+ document.body.appendChild(iframe)
193
+
194
+ function onMessage(event: MessageEvent) {
195
+ if (event.source !== iframe.contentWindow) return
196
+ window.removeEventListener('message', onMessage)
197
+ // Hide instead of remove so when coverage is enabled the iframe remains attached
198
+ // so V8 retains its scripts and Playwright can collect coverage at run end.
199
+ iframe.style.display = 'none'
200
+ if (event.data.type === 'test-results') {
201
+ let { passed, failed, skipped, todo, tests } = event.data.results as TestResults
202
+ resolve({
203
+ passed,
204
+ failed,
205
+ skipped,
206
+ todo,
207
+ tests: tests.map((t) => ({ ...t, filePath: testFile })),
208
+ })
209
+ } else {
210
+ let { message, stack } = event.data.error
211
+ resolve({
212
+ passed: 0,
213
+ failed: 1,
214
+ skipped: 0,
215
+ todo: 0,
216
+ tests: [
217
+ {
218
+ name: '',
219
+ suiteName: testFile,
220
+ filePath: testFile,
221
+ status: 'failed',
222
+ error: { message, stack },
223
+ duration: 0,
224
+ },
225
+ ],
226
+ })
227
+ }
228
+ }
229
+
230
+ window.addEventListener('message', onMessage)
231
+ })
232
+ }
233
+
234
+ function buildSuite(suiteName: string, tests: TestResult[], baseDir: string): HTMLElement {
235
+ let suiteFailed = tests.some((t) => t.status === 'failed')
236
+ let suiteAllSkipped = tests.every((t) => t.status === 'skipped')
237
+ let suiteAllTodo = tests.every((t) => t.status === 'todo')
238
+ let stateClass = suiteFailed
239
+ ? 'rt-failed'
240
+ : suiteAllSkipped
241
+ ? 'rt-muted'
242
+ : suiteAllTodo
243
+ ? 'rt-todo'
244
+ : 'rt-passed'
245
+ let icon = suiteFailed ? '✗' : suiteAllSkipped ? '↓' : suiteAllTodo ? '…' : '✓'
246
+ let suffix = suiteAllSkipped ? ' # skipped' : suiteAllTodo ? ' # todo' : ''
247
+
248
+ let details = el('details', { className: 'rt-suite-details' })
249
+ if (suiteFailed) details.open = true
250
+
251
+ let summary = el('summary', { className: `rt-suite-summary ${stateClass}` })
252
+ summary.appendChild(
253
+ el('span', { className: 'rt-suite-icon', textContent: `${icon} ${suiteName}${suffix}` }),
254
+ )
255
+ details.appendChild(summary)
256
+
257
+ let body = el('div', { className: 'rt-indent' })
258
+ for (let test of tests) {
259
+ let item = buildTestItem(test, baseDir)
260
+ if (item) body.appendChild(el('div', { className: 'rt-test-item' }, item))
261
+ }
262
+ details.appendChild(body)
263
+ return details
264
+ }
265
+
266
+ function buildTestItem(test: TestResult, baseDir: string): HTMLElement | null {
267
+ if (test.status === 'passed') {
268
+ let row = el('div', { className: 'rt-passed' })
269
+ row.append(`✓ ${test.name} `)
270
+ row.appendChild(
271
+ el('span', {
272
+ className: 'rt-test-duration',
273
+ textContent: `(${test.duration.toFixed(2)}ms)`,
274
+ }),
275
+ )
276
+ return row
277
+ }
278
+ if (test.status === 'failed') {
279
+ let row = el('div', { className: 'rt-failed' })
280
+ row.append(`✗ ${test.name} `)
281
+ row.appendChild(
282
+ el('span', {
283
+ className: 'rt-test-duration',
284
+ textContent: `(${test.duration.toFixed(2)}ms)`,
285
+ }),
286
+ )
287
+ if (test.error) {
288
+ let pre = el('pre', { className: 'rt-error-pre' })
289
+ pre.append(test.error.message)
290
+ if (test.error.stack) {
291
+ let stackDiv = el('div', { className: 'rt-error-stack' })
292
+ stackDiv.appendChild(buildStack(test.error.stack, baseDir))
293
+ pre.appendChild(stackDiv)
294
+ }
295
+ row.appendChild(pre)
296
+ }
297
+ return row
298
+ }
299
+ if (test.status === 'skipped' && test.name) {
300
+ return el('div', { className: 'rt-muted', textContent: `↓ ${test.name} # skipped` })
301
+ }
302
+ if (test.status === 'todo' && test.name) {
303
+ return el('div', { className: 'rt-todo', textContent: `… ${test.name} # todo` })
304
+ }
305
+ return null
306
+ }
307
+
308
+ function buildStack(stack: string, baseDir: string): DocumentFragment {
309
+ let frameLocRe = /([^():\s][^():]*\.[jt]sx?):(\d+):(\d+)/
310
+ let frag = document.createDocumentFragment()
311
+ for (let raw of stack.split('\n')) {
312
+ let isTestModule = raw.includes('/@test/')
313
+ let line = normalizeLine(raw)
314
+ let match = isTestModule ? frameLocRe.exec(line) : null
315
+ let div = document.createElement('div')
316
+ if (match) {
317
+ let [full, file, row, col] = match
318
+ let abs = `${baseDir}/${file}`
319
+ let href = `vscode://file/${abs}:${row}:${col}`
320
+ div.append(line.slice(0, match.index))
321
+ let a = el('a', { className: 'rt-stack-link', textContent: full })
322
+ a.href = href
323
+ div.appendChild(a)
324
+ div.append(line.slice(match.index + full.length))
325
+ } else {
326
+ div.textContent = line
327
+ }
328
+ frag.appendChild(div)
329
+ }
330
+ return frag
331
+ }
332
+
333
+ function summaryRow() {
334
+ let row = el('span', { className: 'rt-summary-row' })
335
+ let icon = el('span', { className: 'rt-info', textContent: 'ℹ' })
336
+ let textNode = document.createTextNode('')
337
+ row.append(icon, ' ', textNode)
338
+ return {
339
+ el: row,
340
+ text(s: string) {
341
+ textNode.data = ' ' + s
342
+ },
343
+ }
344
+ }
345
+
346
+ function el<K extends keyof HTMLElementTagNameMap>(
347
+ tag: K,
348
+ props?: { id?: string; className?: string; textContent?: string },
349
+ ...children: Array<Node | string>
350
+ ): HTMLElementTagNameMap[K] {
351
+ let node = document.createElement(tag)
352
+ if (props?.id) node.id = props.id
353
+ if (props?.className) node.className = props.className
354
+ if (props?.textContent != null) node.textContent = props.textContent
355
+ if (children.length) node.append(...children)
356
+ return node
357
+ }
@@ -0,0 +1,18 @@
1
+ import { runTests } from '../../lib/executor.ts'
2
+
3
+ const params = new URLSearchParams(location.search)
4
+ const testFile = params.get('file')!
5
+
6
+ try {
7
+ await import(testFile)
8
+ let results = await runTests()
9
+ window.parent.postMessage({ type: 'test-results', results }, '*')
10
+ } catch (error: any) {
11
+ window.parent.postMessage(
12
+ {
13
+ type: 'test-error',
14
+ error: { message: error?.message ?? String(error), stack: error?.stack },
15
+ },
16
+ '*',
17
+ )
18
+ }