tape-six 1.14.0 → 1.15.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.
- package/README.md +6 -6
- package/index.js +53 -44
- package/package.json +4 -4
- package/src/State.js +3 -3
- package/src/driver/TestWorker.d.ts +54 -0
- package/src/driver/TestWorker.js +274 -0
- package/src/driver/bootstrap.d.ts +27 -0
- package/src/driver/bootstrap.js +44 -0
- package/src/driver/cli.d.ts +40 -0
- package/src/driver/cli.js +358 -0
- package/src/reporters/TTYReporter.js +4 -2
- package/src/reporters/TapReporter.js +32 -8
- package/src/server.d.ts +27 -1
- package/src/server.js +21 -0
- package/src/utils/EventServer.d.ts +10 -6
- package/src/utils/config.d.ts +22 -6
- package/src/utils/config.js +5 -1
- package/src/utils/yamlFormatter.js +7 -3
package/README.md
CHANGED
|
@@ -46,7 +46,7 @@ _(The examples below show actual output of test functions. In real life, success
|
|
|
46
46
|
Running a test file directly:
|
|
47
47
|
|
|
48
48
|
```txt
|
|
49
|
-
$ node tests/test-console.js
|
|
49
|
+
$ node tests/manual/test-console.js
|
|
50
50
|
○ console test
|
|
51
51
|
log: log #1
|
|
52
52
|
✓ pass - 1.542ms
|
|
@@ -63,8 +63,8 @@ $ node tests/test-console.js
|
|
|
63
63
|
Running a test suite:
|
|
64
64
|
|
|
65
65
|
```txt
|
|
66
|
-
$ npx tape6 tests/test-console.js tests/test-eval.js
|
|
67
|
-
○ FILE: /tests/test-console.js
|
|
66
|
+
$ npx tape6 tests/manual/test-console.js tests/test-eval.js
|
|
67
|
+
○ FILE: /tests/manual/test-console.js
|
|
68
68
|
○ console test
|
|
69
69
|
log: log #1
|
|
70
70
|
✓ pass - 0.338ms
|
|
@@ -75,7 +75,7 @@ $ npx tape6 tests/test-console.js tests/test-eval.js
|
|
|
75
75
|
log: log #3
|
|
76
76
|
err: error #2
|
|
77
77
|
✓ console test 2 0 - 1.286ms
|
|
78
|
-
✓ FILE: /tests/test-console.js 2 0 - 7.411ms
|
|
78
|
+
✓ FILE: /tests/manual/test-console.js 2 0 - 7.411ms
|
|
79
79
|
○ FILE: /tests/test-eval.js
|
|
80
80
|
○ OK test
|
|
81
81
|
✓ 1 < 2 - 1.28ms
|
|
@@ -427,6 +427,8 @@ Test output can be controlled by flags. See [Supported flags](https://github.com
|
|
|
427
427
|
|
|
428
428
|
The most recent releases:
|
|
429
429
|
|
|
430
|
+
- 1.15.0 _New: an HTTP request recorder. Bug fixes._
|
|
431
|
+
- 1.14.1 _Bug fixes. Updated dependencies._
|
|
430
432
|
- 1.14.0 _Internal housekeeping. Removed the Deno 2.9.0 workaround shim._
|
|
431
433
|
- 1.13.0 _TypeScript declarations for the `EventServer`._
|
|
432
434
|
- 1.12.0 _`tape6-server` is now pluggable, embeddable server for hermetic integration tests, runtime plugin registration, and an opt-in HTTP/2 mode._
|
|
@@ -455,5 +457,3 @@ The most recent releases:
|
|
|
455
457
|
- 1.5.0 _Internal refactoring (moved state to reporters), added type identification of values in the DOM and TTY reporters, multiple minor fixes._
|
|
456
458
|
|
|
457
459
|
The full release notes are in the wiki: [Release notes](https://github.com/uhop/tape-six/wiki/Release-notes).
|
|
458
|
-
|
|
459
|
-
For more info consult full [release notes](https://github.com/uhop/tape-six/wiki/Release-notes).
|
package/index.js
CHANGED
|
@@ -166,6 +166,8 @@ const init = async () => {
|
|
|
166
166
|
}
|
|
167
167
|
reporter ||= new TapReporter({
|
|
168
168
|
useJson: true,
|
|
169
|
+
// flat TAP stream: nested-test ids repeat, consumers need 1..N
|
|
170
|
+
renumberAsserts: true,
|
|
169
171
|
originalConsole,
|
|
170
172
|
hasColors
|
|
171
173
|
});
|
|
@@ -225,60 +227,67 @@ const getTestFileName = ({isBrowser, isBun, isDeno, isNode}) => {
|
|
|
225
227
|
let settings = null;
|
|
226
228
|
|
|
227
229
|
const testRunner = async () => {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
230
|
+
// ref'd keep-alive — a bare run holds no other ref'd handle, so a test awaiting
|
|
231
|
+
// only an unref'd timer (AbortSignal.timeout on Node/Bun) would exit 0 mid-suite
|
|
232
|
+
const keepAlive = setInterval(() => {}, 2 ** 31 - 1);
|
|
233
|
+
try {
|
|
234
|
+
if (!settings) {
|
|
235
|
+
await selectTimer();
|
|
236
|
+
settings = await init();
|
|
237
|
+
}
|
|
232
238
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
239
|
+
const {isBrowser, isCli} = settings,
|
|
240
|
+
reporter = getReporter(),
|
|
241
|
+
testFileName = getTestFileName(settings);
|
|
236
242
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
243
|
+
reporter.report({
|
|
244
|
+
type: 'test',
|
|
245
|
+
test: 0,
|
|
246
|
+
name: testFileName ? 'FILE: /' + testFileName : ''
|
|
247
|
+
});
|
|
242
248
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
249
|
+
reporter.state.beforeAll = getBeforeAll();
|
|
250
|
+
clearBeforeAll();
|
|
251
|
+
reporter.state.afterAll = getAfterAll();
|
|
252
|
+
clearAfterAll();
|
|
253
|
+
reporter.state.beforeEach = getBeforeEach();
|
|
254
|
+
clearBeforeEach();
|
|
255
|
+
reporter.state.afterEach = getAfterEach();
|
|
256
|
+
clearAfterEach();
|
|
257
|
+
reporter.state.isBeforeAllUsed = false;
|
|
252
258
|
|
|
253
|
-
|
|
259
|
+
const currentState = reporter.state;
|
|
254
260
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
261
|
+
for (;;) {
|
|
262
|
+
const tests = getTests();
|
|
263
|
+
if (!tests.length) break;
|
|
264
|
+
clearTests();
|
|
265
|
+
const canContinue = await runTests(tests);
|
|
266
|
+
if (!canContinue) break;
|
|
267
|
+
await new Promise(resolve => defer(resolve));
|
|
268
|
+
}
|
|
263
269
|
|
|
264
|
-
|
|
270
|
+
await currentState?.runAfterAll();
|
|
265
271
|
|
|
266
|
-
|
|
272
|
+
const runHasFailed = reporter.state && reporter.state.failed > 0;
|
|
267
273
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
+
reporter.report({
|
|
275
|
+
type: 'end',
|
|
276
|
+
test: 0,
|
|
277
|
+
name: testFileName ? 'FILE: /' + testFileName : '',
|
|
278
|
+
fail: runHasFailed
|
|
279
|
+
});
|
|
274
280
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
281
|
+
if (!getConfiguredFlag() && runHasFailed) {
|
|
282
|
+
if (isCli) {
|
|
283
|
+
process.exitCode = 1;
|
|
284
|
+
}
|
|
278
285
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
286
|
+
if (isBrowser && typeof __tape6_reportResults == 'function') {
|
|
287
|
+
__tape6_reportResults(runHasFailed ? 'failure' : 'success');
|
|
288
|
+
}
|
|
289
|
+
} finally {
|
|
290
|
+
clearInterval(keepAlive);
|
|
282
291
|
}
|
|
283
292
|
};
|
|
284
293
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tape-six",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.0",
|
|
4
4
|
"description": "TAP-inspired unit test library for Node, Deno, Bun, and browsers. ES modules, TypeScript, zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -101,8 +101,8 @@
|
|
|
101
101
|
}
|
|
102
102
|
},
|
|
103
103
|
"devDependencies": {
|
|
104
|
-
"@types/node": "^26.1.
|
|
105
|
-
"prettier": "^3.9.
|
|
106
|
-
"typescript": "^
|
|
104
|
+
"@types/node": "^26.1.1",
|
|
105
|
+
"prettier": "^3.9.5",
|
|
106
|
+
"typescript": "^7.0.2"
|
|
107
107
|
}
|
|
108
108
|
}
|
package/src/State.js
CHANGED
|
@@ -186,9 +186,9 @@ export class State {
|
|
|
186
186
|
typeof event.diffTime !== 'number' && (event.diffTime = event.time - this.startTime);
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
-
// reporters read event.at / event.stackList
|
|
190
|
-
// walking on passing assertions is wasted work
|
|
191
|
-
if (
|
|
189
|
+
// reporters read event.at / event.stackList for displayed failures (todo
|
|
190
|
+
// included) — stack walking on passing assertions is wasted work
|
|
191
|
+
if (event.fail && !event.skip) {
|
|
192
192
|
if (
|
|
193
193
|
event.type === 'assert' &&
|
|
194
194
|
(event.operator === 'error' || event.operator === 'exception') &&
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import EventServer, {EventServerOptions, EventServerReporter} from '../utils/EventServer.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Base class for driver-backed browser workers (`tape-six-puppeteer`,
|
|
5
|
+
* `tape-six-playwright`): owns the whole shared task lifecycle — per-task
|
|
6
|
+
* BrowserContext + Page with completion driven by the page `close` event, the
|
|
7
|
+
* `__tape6_reporter` / `__tape6_error` wiring, iframe injection via
|
|
8
|
+
* `driver/bootstrap`, cooperative drain (`tape6-terminate`) with a
|
|
9
|
+
* `graceTimeout` force-kill, and cleanup.
|
|
10
|
+
*
|
|
11
|
+
* Subclasses supply the four driver-adapter members below. Driver handles
|
|
12
|
+
* (browser, context, page) are `any` — they belong to the driver's API, not
|
|
13
|
+
* to this contract. The base standardizes on the single-object
|
|
14
|
+
* `page.evaluate(fn, arg)` convention, which both Puppeteer and Playwright
|
|
15
|
+
* accept.
|
|
16
|
+
*/
|
|
17
|
+
export class TestWorker extends EventServer {
|
|
18
|
+
constructor(reporter: EventServerReporter, numberOfTasks?: number, options?: EventServerOptions);
|
|
19
|
+
|
|
20
|
+
/** Engines this driver can launch; index 0 is the default. Adapter member. */
|
|
21
|
+
readonly supportedBrowsers: string[];
|
|
22
|
+
/** The driver's page-level error event: `'error'` (Puppeteer) or `'pageerror'` (Playwright). Adapter member. */
|
|
23
|
+
readonly pageErrorEvent: string;
|
|
24
|
+
/**
|
|
25
|
+
* Launch the named engine and return the driver's browser handle. Owns
|
|
26
|
+
* driver-specific launch options and the install-remediation error message.
|
|
27
|
+
* Adapter member.
|
|
28
|
+
*/
|
|
29
|
+
launchBrowser(name: string, options: {insecure: boolean}): Promise<any>;
|
|
30
|
+
/**
|
|
31
|
+
* Create an isolated browsing context on the launched browser. `insecure`
|
|
32
|
+
* asks to accept the tape6 self-signed certificate (h2 mode) wherever the
|
|
33
|
+
* driver takes that flag. Adapter member.
|
|
34
|
+
*/
|
|
35
|
+
newContext(browser: any, options: {insecure: boolean}): Promise<any>;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Diagnostic sink for task-setup failures (defaults to `console.error`).
|
|
39
|
+
* Overridable — e.g. tests collect instead of printing.
|
|
40
|
+
*/
|
|
41
|
+
logError(...args: any[]): void;
|
|
42
|
+
|
|
43
|
+
/** `true` when `options.serverUrl` is `https:` — the h2 / TLS mode. */
|
|
44
|
+
readonly insecure: boolean;
|
|
45
|
+
|
|
46
|
+
/** The launched driver browser handle, `null` before launch / after cleanup. */
|
|
47
|
+
browser: any;
|
|
48
|
+
|
|
49
|
+
makeTask(fileName: string): string;
|
|
50
|
+
destroyTask(id: string, reason?: string): void;
|
|
51
|
+
cleanup(): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export default TestWorker;
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// @ts-self-types="./TestWorker.d.ts"
|
|
2
|
+
|
|
3
|
+
import {isStopTest} from '../State.js';
|
|
4
|
+
import EventServer from '../utils/EventServer.js';
|
|
5
|
+
import {
|
|
6
|
+
htmlTestUrl,
|
|
7
|
+
iframeId,
|
|
8
|
+
supportedTestFileRe,
|
|
9
|
+
terminateMessage,
|
|
10
|
+
testPageSrcdoc
|
|
11
|
+
} from './bootstrap.js';
|
|
12
|
+
|
|
13
|
+
export class TestWorker extends EventServer {
|
|
14
|
+
#ready;
|
|
15
|
+
constructor(reporter, numberOfTasks, options) {
|
|
16
|
+
super(reporter, numberOfTasks, options);
|
|
17
|
+
this.counter = 0;
|
|
18
|
+
this.browser = null;
|
|
19
|
+
this.tasks = {};
|
|
20
|
+
this.graceTimers = {};
|
|
21
|
+
// deferred a microtask: subclass field initializers (the adapter members)
|
|
22
|
+
// run only after super() returns — #init must not read them earlier
|
|
23
|
+
this.#ready = Promise.resolve().then(() => this.#init());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// the driver adapter — subclasses supply these four members
|
|
27
|
+
get supportedBrowsers() {
|
|
28
|
+
throw new Error('TestWorker subclass must define supportedBrowsers');
|
|
29
|
+
}
|
|
30
|
+
get pageErrorEvent() {
|
|
31
|
+
throw new Error('TestWorker subclass must define pageErrorEvent');
|
|
32
|
+
}
|
|
33
|
+
async launchBrowser() {
|
|
34
|
+
throw new Error('TestWorker subclass must implement launchBrowser(name, {insecure})');
|
|
35
|
+
}
|
|
36
|
+
async newContext() {
|
|
37
|
+
throw new Error('TestWorker subclass must implement newContext(browser, {insecure})');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// diagnostic sink — embedders/tests may override (the default is fine in driver bins)
|
|
41
|
+
logError(...args) {
|
|
42
|
+
console.error(...args);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get insecure() {
|
|
46
|
+
return /^https:/i.test(this.options.serverUrl || '');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async #init() {
|
|
50
|
+
const name = this.options.browser || this.supportedBrowsers[0];
|
|
51
|
+
if (!this.supportedBrowsers.includes(name)) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`Unsupported browser "${name}". Supported: ${this.supportedBrowsers.join(', ')}.`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
this.browser = await this.launchBrowser(name, {insecure: this.insecure});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
makeTask(fileName) {
|
|
60
|
+
const id = String(++this.counter);
|
|
61
|
+
if (!supportedTestFileRe.test(fileName)) {
|
|
62
|
+
this.report(id, {
|
|
63
|
+
name: 'unsupported file type: ' + fileName,
|
|
64
|
+
test: 0,
|
|
65
|
+
marker: new Error(),
|
|
66
|
+
operator: 'error',
|
|
67
|
+
fail: true
|
|
68
|
+
});
|
|
69
|
+
this.report(id, {type: 'terminated', test: 0, name: 'FILE: /' + fileName});
|
|
70
|
+
this.close(id);
|
|
71
|
+
return id;
|
|
72
|
+
}
|
|
73
|
+
this.#ready
|
|
74
|
+
.then(() => this.#runTask(id, fileName))
|
|
75
|
+
.catch(error => {
|
|
76
|
+
// Launch/setup failure (e.g. the engine isn't installed): no page
|
|
77
|
+
// exists, so no 'close' event can drive completion — and without a
|
|
78
|
+
// reported failure the run would exit 0 (a false pass).
|
|
79
|
+
this.logError('Failed to run test:', fileName, error);
|
|
80
|
+
try {
|
|
81
|
+
this.report(id, {
|
|
82
|
+
name: error && error.message ? error.message : String(error),
|
|
83
|
+
test: 0,
|
|
84
|
+
marker: new Error(),
|
|
85
|
+
operator: 'error',
|
|
86
|
+
fail: true
|
|
87
|
+
});
|
|
88
|
+
} catch (reportError) {
|
|
89
|
+
if (!isStopTest(reportError)) throw reportError;
|
|
90
|
+
}
|
|
91
|
+
this.close(id);
|
|
92
|
+
});
|
|
93
|
+
return id;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Each task runs in its own BrowserContext + Page (full origin/storage
|
|
97
|
+
// isolation), so the Node-side driver can force-kill a hung test by closing
|
|
98
|
+
// the context — the backstop in-page JS can't provide for itself. The test
|
|
99
|
+
// still runs in an iframe inside that page, so the proven srcdoc / importmap
|
|
100
|
+
// injection and the window.parent.__tape6_reporter data plane are unchanged.
|
|
101
|
+
//
|
|
102
|
+
// Completion is driven by the page 'close' event, never directly from a
|
|
103
|
+
// reported event: a normal end, a cooperative drain, and a force-kill all end
|
|
104
|
+
// in the context being closed, so close(id) fires exactly once per task down
|
|
105
|
+
// every path — including the hung-test kill that emits no event at all.
|
|
106
|
+
async #runTask(id, fileName) {
|
|
107
|
+
let context, page;
|
|
108
|
+
try {
|
|
109
|
+
context = await this.newContext(this.browser, {insecure: this.insecure});
|
|
110
|
+
page = await context.newPage();
|
|
111
|
+
} catch (error) {
|
|
112
|
+
this.logError('Failed to open context for:', fileName, error);
|
|
113
|
+
if (context) context.close().catch(() => {});
|
|
114
|
+
this.close(id);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
this.tasks[id] = {context, page};
|
|
118
|
+
|
|
119
|
+
page.on('close', () => {
|
|
120
|
+
this.#clearGrace(id);
|
|
121
|
+
if (this.tasks[id]) {
|
|
122
|
+
delete this.tasks[id];
|
|
123
|
+
this.close(id);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
page.on(this.pageErrorEvent, e => console.error(e));
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
await page.exposeFunction('__tape6_reporter', (taskId, event) => {
|
|
130
|
+
try {
|
|
131
|
+
this.report(taskId, event);
|
|
132
|
+
if ((event.type === 'end' && event.test === 0) || event.type === 'terminated') {
|
|
133
|
+
this.destroyTask(taskId, 'done');
|
|
134
|
+
}
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (!isStopTest(error)) throw error;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await page.exposeFunction('__tape6_error', (taskId, error) => {
|
|
141
|
+
if (error) {
|
|
142
|
+
this.report(taskId, {
|
|
143
|
+
type: 'comment',
|
|
144
|
+
name: 'fail to load: ' + (error.message || 'Worker error'),
|
|
145
|
+
test: 0
|
|
146
|
+
});
|
|
147
|
+
try {
|
|
148
|
+
this.report(taskId, {
|
|
149
|
+
name: String(error),
|
|
150
|
+
test: 0,
|
|
151
|
+
marker: new Error(),
|
|
152
|
+
operator: 'error',
|
|
153
|
+
fail: true,
|
|
154
|
+
data: {actual: error}
|
|
155
|
+
});
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (!isStopTest(error)) throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
this.destroyTask(taskId, 'done');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// navigate to the server so the iframe inherits the correct origin
|
|
164
|
+
await page.goto(this.options.serverUrl + '/--tests', {waitUntil: 'load'});
|
|
165
|
+
await page.evaluate(() => {
|
|
166
|
+
document.documentElement.innerHTML = '<head></head><body></body>';
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// forward console messages only after the page is set up
|
|
170
|
+
page.on('console', msg =>
|
|
171
|
+
console[typeof console[msg.type()] == 'function' ? msg.type() : 'log'](msg.text())
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
await this.#runInIframe(id, page, fileName);
|
|
175
|
+
|
|
176
|
+
// A stop/bail (or deadline) can fire while this context is still being
|
|
177
|
+
// created — its destroyTask hits a not-yet-tracked task and no-ops. Catch
|
|
178
|
+
// up now that the iframe exists so a just-started task still aborts.
|
|
179
|
+
if (this.stopRequested) this.destroyTask(id, 'failOnce');
|
|
180
|
+
} catch (error) {
|
|
181
|
+
this.logError('Failed to set up test:', fileName, error);
|
|
182
|
+
this.destroyTask(id, 'done');
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async #runInIframe(id, page, fileName) {
|
|
187
|
+
const flags = this.options.flags || '',
|
|
188
|
+
domId = iframeId(id);
|
|
189
|
+
|
|
190
|
+
if (/\.html?$/i.test(fileName)) {
|
|
191
|
+
const url = htmlTestUrl(fileName, {id, flags});
|
|
192
|
+
await page.evaluate(
|
|
193
|
+
({id, domId, url}) => {
|
|
194
|
+
const iframe = document.createElement('iframe');
|
|
195
|
+
iframe.id = domId;
|
|
196
|
+
iframe.src = url;
|
|
197
|
+
iframe.onerror = error => /** @type {*} */ (window).__tape6_error(id, error);
|
|
198
|
+
document.body.append(iframe);
|
|
199
|
+
},
|
|
200
|
+
{id, domId, url}
|
|
201
|
+
);
|
|
202
|
+
} else {
|
|
203
|
+
const srcdoc = testPageSrcdoc(fileName, {id, flags, importmap: this.options.importmap});
|
|
204
|
+
await page.evaluate(
|
|
205
|
+
({domId, srcdoc}) => {
|
|
206
|
+
const iframe = document.createElement('iframe');
|
|
207
|
+
iframe.id = domId;
|
|
208
|
+
iframe.srcdoc = srcdoc;
|
|
209
|
+
document.body.append(iframe);
|
|
210
|
+
},
|
|
211
|
+
{domId, srcdoc}
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Control plane. EventServer calls this with reason ∈ done | failOnce | timeout.
|
|
217
|
+
destroyTask(id, reason = 'done') {
|
|
218
|
+
if (reason === 'done') {
|
|
219
|
+
this.#kill(id);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (this.graceTimers[id]) return; // already draining
|
|
223
|
+
const task = this.tasks[id];
|
|
224
|
+
if (!task) return;
|
|
225
|
+
// Cooperative drain: post `tape6-terminate` into the running test's iframe so
|
|
226
|
+
// it unwinds at the next assertion (StopTest) and its cleanup hooks run. If
|
|
227
|
+
// it doesn't exit within graceTimeout, force-kill by closing the context —
|
|
228
|
+
// the real Node-side kill an in-page iframe can't perform on itself.
|
|
229
|
+
task.page
|
|
230
|
+
.evaluate(
|
|
231
|
+
({domId, message}) => {
|
|
232
|
+
const iframe = /** @type {*} */ (document.getElementById(domId));
|
|
233
|
+
iframe?.contentWindow?.postMessage(message, '*');
|
|
234
|
+
},
|
|
235
|
+
{domId: iframeId(id), message: terminateMessage(reason)}
|
|
236
|
+
)
|
|
237
|
+
.catch(() => {});
|
|
238
|
+
this.graceTimers[id] = setTimeout(() => this.#kill(id), this.graceTimeout);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Idempotent: the page 'close' handler clears tracking and calls close(id),
|
|
242
|
+
// so a second call (e.g. base close() -> destroyTask('done')) finds no task
|
|
243
|
+
// and returns.
|
|
244
|
+
#kill(id) {
|
|
245
|
+
this.#clearGrace(id);
|
|
246
|
+
const task = this.tasks[id];
|
|
247
|
+
if (!task) return;
|
|
248
|
+
task.context.close().catch(() => {});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
#clearGrace(id) {
|
|
252
|
+
const grace = this.graceTimers[id];
|
|
253
|
+
if (grace) {
|
|
254
|
+
clearTimeout(grace);
|
|
255
|
+
delete this.graceTimers[id];
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async cleanup() {
|
|
260
|
+
for (const id of Object.keys(this.graceTimers)) {
|
|
261
|
+
clearTimeout(this.graceTimers[id]);
|
|
262
|
+
}
|
|
263
|
+
this.graceTimers = {};
|
|
264
|
+
// Drop task tracking first so the page 'close' events fired by browser.close()
|
|
265
|
+
// below are no-ops (the run has already finished by the time cleanup runs).
|
|
266
|
+
this.tasks = {};
|
|
267
|
+
if (this.browser) {
|
|
268
|
+
await this.browser.close();
|
|
269
|
+
this.browser = null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export default TestWorker;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The in-page half of the browser-worker contract: the strings and shapes a
|
|
3
|
+
* driver (or the standalone web app) injects into a test page. Browser-safe —
|
|
4
|
+
* pure string building, no platform imports.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Test files a browser worker can run: `.js`, `.mjs`, `.htm`, `.html`. */
|
|
8
|
+
export const supportedTestFileRe: RegExp;
|
|
9
|
+
|
|
10
|
+
/** DOM id of the iframe hosting task `id`: `test-iframe-<id>`. */
|
|
11
|
+
export function iframeId(id: string): string;
|
|
12
|
+
|
|
13
|
+
/** The cooperative-drain message posted into a running test's iframe. */
|
|
14
|
+
export function terminateMessage(reason: string): {type: 'tape6-terminate'; reason: string};
|
|
15
|
+
|
|
16
|
+
/** URL (path + query) that loads an `.html` test file as a task. */
|
|
17
|
+
export function htmlTestUrl(fileName: string, options: {id: string; flags?: string}): string;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Complete srcdoc HTML that runs a JS test file as a task: injects the
|
|
21
|
+
* importmap, the `__tape6_id` / `__tape6_testFileName` / `__tape6_flags`
|
|
22
|
+
* globals, and a module-script loader wired to `__tape6_error`.
|
|
23
|
+
*/
|
|
24
|
+
export function testPageSrcdoc(
|
|
25
|
+
fileName: string,
|
|
26
|
+
options: {id: string; flags?: string; importmap?: object | null}
|
|
27
|
+
): string;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// @ts-self-types="./bootstrap.d.ts"
|
|
2
|
+
|
|
3
|
+
// The in-page half of the browser-worker contract lives here: index.js talks
|
|
4
|
+
// back through __tape6_reporter / __tape6_error / __tape6_reportResults, and
|
|
5
|
+
// the iframe transport listens for the tape6-terminate message.
|
|
6
|
+
|
|
7
|
+
export const supportedTestFileRe = /\.(?:js|mjs|htm|html)$/i;
|
|
8
|
+
|
|
9
|
+
export const iframeId = id => 'test-iframe-' + id;
|
|
10
|
+
|
|
11
|
+
export const terminateMessage = reason => ({type: 'tape6-terminate', reason});
|
|
12
|
+
|
|
13
|
+
export const htmlTestUrl = (fileName, {id, flags = ''} = {}) => {
|
|
14
|
+
const search = new URLSearchParams({id, 'test-file-name': fileName});
|
|
15
|
+
if (flags) search.set('flags', flags);
|
|
16
|
+
return '/' + fileName + '?' + search.toString();
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const testPageSrcdoc = (fileName, {id, flags = '', importmap = null} = {}) =>
|
|
20
|
+
'<!doctype html>' +
|
|
21
|
+
'<html lang="en"><head>' +
|
|
22
|
+
'<meta charset="utf-8" />' +
|
|
23
|
+
(importmap ? '<script type="importmap">' + JSON.stringify(importmap) + '<\/script>' : '') +
|
|
24
|
+
'<script type="module">' +
|
|
25
|
+
'window.__tape6_id = ' +
|
|
26
|
+
JSON.stringify(id) +
|
|
27
|
+
';' +
|
|
28
|
+
'window.__tape6_testFileName = ' +
|
|
29
|
+
JSON.stringify(fileName) +
|
|
30
|
+
';' +
|
|
31
|
+
'window.__tape6_flags = ' +
|
|
32
|
+
JSON.stringify(flags) +
|
|
33
|
+
';' +
|
|
34
|
+
'const s = document.createElement("script");' +
|
|
35
|
+
's.setAttribute("type", "module");' +
|
|
36
|
+
's.src = "/' +
|
|
37
|
+
fileName +
|
|
38
|
+
'";' +
|
|
39
|
+
's.onerror = error => window.parent.__tape6_error(' +
|
|
40
|
+
JSON.stringify(id) +
|
|
41
|
+
', error && error.message || "Script load error");' +
|
|
42
|
+
'document.documentElement.appendChild(s);' +
|
|
43
|
+
'<\/script>' +
|
|
44
|
+
'</head><body></body></html>';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {TestWorker as DriverTestWorker} from './TestWorker.js';
|
|
2
|
+
import {EventServerOptions, EventServerReporter} from '../utils/EventServer.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the `--browsers` fan-out list against the singular `--browser`
|
|
6
|
+
* fallback: splits, honors `"all"`, dedupes, validates. Returns either the
|
|
7
|
+
* final list or the first unsupported name.
|
|
8
|
+
*/
|
|
9
|
+
export function resolveBrowsers(
|
|
10
|
+
listValue: string | undefined,
|
|
11
|
+
singular: string,
|
|
12
|
+
supportedBrowsers: string[]
|
|
13
|
+
): {browsers: string[]; badBrowser?: undefined} | {browsers?: undefined; badBrowser: string};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The whole driver-bin flow shared by `tape6-puppeteer` / `tape6-playwright`:
|
|
17
|
+
* option/env parsing (`--server-url`, `--browser`/`--browsers`, `--flags`,
|
|
18
|
+
* `--par`, `--start-server`, `--h2`, `--info`/`--self`/`--help`/`--version`),
|
|
19
|
+
* config + protocol resolution, the `controlFetch` readiness probe,
|
|
20
|
+
* `ensureServer` (self-launches this package's own `tape6-server`), the
|
|
21
|
+
* per-engine run loop, and summary / exit-code handling.
|
|
22
|
+
*
|
|
23
|
+
* A driver bin reduces to one call:
|
|
24
|
+
* `runDriverCli({packageUrl: import.meta.url, commandName, description, supportedBrowsers, TestWorker})`.
|
|
25
|
+
* By convention the bin lives in `bin/` — the version is read from
|
|
26
|
+
* `../package.json` relative to `packageUrl`.
|
|
27
|
+
*/
|
|
28
|
+
export function runDriverCli(config: {
|
|
29
|
+
packageUrl: string;
|
|
30
|
+
commandName: string;
|
|
31
|
+
description: string;
|
|
32
|
+
supportedBrowsers: string[];
|
|
33
|
+
TestWorker: new (
|
|
34
|
+
reporter: EventServerReporter,
|
|
35
|
+
numberOfTasks?: number,
|
|
36
|
+
options?: EventServerOptions
|
|
37
|
+
) => DriverTestWorker;
|
|
38
|
+
}): Promise<void>;
|
|
39
|
+
|
|
40
|
+
export default runDriverCli;
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
// @ts-self-types="./cli.d.ts"
|
|
2
|
+
|
|
3
|
+
import {readFileSync} from 'node:fs';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import {fileURLToPath} from 'node:url';
|
|
6
|
+
import {spawn} from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
getOptions,
|
|
10
|
+
getConfig,
|
|
11
|
+
initReporter,
|
|
12
|
+
showInfo,
|
|
13
|
+
printFlagOptions,
|
|
14
|
+
runtime
|
|
15
|
+
} from '../utils/config.js';
|
|
16
|
+
import {getReporter, setReporter} from '../test.js';
|
|
17
|
+
import {selectTimer} from '../utils/timer.js';
|
|
18
|
+
import {createControlFetch, isProtocolMismatch} from '../utils/controlFetch.js';
|
|
19
|
+
|
|
20
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
21
|
+
|
|
22
|
+
const getServerUrl = () => {
|
|
23
|
+
if (process.env.TAPE6_SERVER_URL) return process.env.TAPE6_SERVER_URL;
|
|
24
|
+
const host = process.env.HOST || 'localhost',
|
|
25
|
+
port = process.env.PORT || '3000';
|
|
26
|
+
return `http://${host}:${port}`;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// --browsers (fan-out) overrides --browser; the singular value is the
|
|
30
|
+
// one-element fallback, so a single validation covers both paths
|
|
31
|
+
export const resolveBrowsers = (listValue, singular, supportedBrowsers) => {
|
|
32
|
+
let browsers = String(listValue || '')
|
|
33
|
+
.split(',')
|
|
34
|
+
.map(name => name.trim())
|
|
35
|
+
.filter(Boolean);
|
|
36
|
+
if (browsers.includes('all')) browsers = [...supportedBrowsers];
|
|
37
|
+
browsers = [...new Set(browsers)];
|
|
38
|
+
if (!browsers.length) browsers = [singular];
|
|
39
|
+
const badBrowser = browsers.find(name => !supportedBrowsers.includes(name));
|
|
40
|
+
return badBrowser === undefined ? {browsers} : {badBrowser};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const runDriverCli = async ({
|
|
44
|
+
packageUrl,
|
|
45
|
+
commandName,
|
|
46
|
+
description,
|
|
47
|
+
supportedBrowsers,
|
|
48
|
+
TestWorker
|
|
49
|
+
}) => {
|
|
50
|
+
const rootFolder = process.cwd();
|
|
51
|
+
const controlFetch = createControlFetch(rootFolder);
|
|
52
|
+
|
|
53
|
+
const getVersion = () => {
|
|
54
|
+
// by convention the caller is bin/<commandName>.js — package.json is one up
|
|
55
|
+
const pkgPath = fileURLToPath(new URL('../package.json', packageUrl));
|
|
56
|
+
return JSON.parse(readFileSync(pkgPath, 'utf8')).version;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const showSelf = () => {
|
|
60
|
+
const self = new URL(packageUrl);
|
|
61
|
+
if (self.protocol === 'file:') {
|
|
62
|
+
console.log(fileURLToPath(self));
|
|
63
|
+
} else {
|
|
64
|
+
console.log(self);
|
|
65
|
+
}
|
|
66
|
+
process.exit(0);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const showVersion = () => {
|
|
70
|
+
console.log(commandName + ' ' + getVersion());
|
|
71
|
+
process.exit(0);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const showHelp = () => {
|
|
75
|
+
console.log(commandName + ' ' + getVersion() + ' — ' + description + '\n');
|
|
76
|
+
console.log('Usage: ' + commandName + ' [options] [patterns...]\n');
|
|
77
|
+
const options = [
|
|
78
|
+
['--flags, -f <flags>', 'Set reporter flags (env: TAPE6_FLAGS)'],
|
|
79
|
+
['--par, -p <n>', 'Set parallelism level (env: TAPE6_PAR)'],
|
|
80
|
+
[
|
|
81
|
+
'--browser, -b <name>',
|
|
82
|
+
`Browser engine: ${supportedBrowsers.join('|')} (env: TAPE6_BROWSER, default: ${supportedBrowsers[0]})`
|
|
83
|
+
],
|
|
84
|
+
[
|
|
85
|
+
'--browsers <list>',
|
|
86
|
+
'Run several engines: comma-separated or "all" (env: TAPE6_BROWSERS; overrides --browser)'
|
|
87
|
+
],
|
|
88
|
+
[
|
|
89
|
+
'--server-url, -u <url>',
|
|
90
|
+
'Server URL (env: TAPE6_SERVER_URL, default: http://localhost:3000)'
|
|
91
|
+
],
|
|
92
|
+
['--start-server', 'Auto-start tape6-server'],
|
|
93
|
+
['--h2', 'HTTP/2 mode: https server URL, --h2 self-launch (env: TAPE6_PROTOCOL=h2)'],
|
|
94
|
+
['--info', 'Show configuration info and exit'],
|
|
95
|
+
['--self', 'Print the path to this script and exit'],
|
|
96
|
+
['--help, -h', 'Show this help message and exit'],
|
|
97
|
+
['--version, -v', 'Show version and exit']
|
|
98
|
+
];
|
|
99
|
+
console.log('Options:');
|
|
100
|
+
const width = options.reduce((max, [flag]) => Math.max(max, flag.length), 0) + 2;
|
|
101
|
+
for (const [flag, desc] of options) {
|
|
102
|
+
console.log(' ' + flag.padEnd(width) + desc);
|
|
103
|
+
}
|
|
104
|
+
printFlagOptions();
|
|
105
|
+
process.exit(0);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const probeServer = async serverUrl => {
|
|
109
|
+
try {
|
|
110
|
+
const response = await controlFetch(serverUrl + '/--tests');
|
|
111
|
+
return response.ok ? 'ok' : 'down';
|
|
112
|
+
} catch (error) {
|
|
113
|
+
return isProtocolMismatch(error) ? 'mismatch' : 'down';
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const ensureServer = async (serverUrl, startServer, h2) => {
|
|
118
|
+
const mismatchMsg =
|
|
119
|
+
`Error: a TLS request to ${serverUrl} got a plaintext HTTP answer — ` +
|
|
120
|
+
'an h1 (non-TLS) server is listening there.\n' +
|
|
121
|
+
'Stop it, or drop --h2 / use an http: --server-url to match it.\n';
|
|
122
|
+
|
|
123
|
+
let status = await probeServer(serverUrl);
|
|
124
|
+
if (status === 'ok') return null;
|
|
125
|
+
|
|
126
|
+
if (!startServer) {
|
|
127
|
+
console.error(
|
|
128
|
+
status === 'mismatch'
|
|
129
|
+
? mismatchMsg
|
|
130
|
+
: `Error: tape6-server is not reachable at ${serverUrl}\n\n` +
|
|
131
|
+
'Start it manually:\n' +
|
|
132
|
+
` npx tape6-server${h2 ? ' --h2' : ''}\n\n` +
|
|
133
|
+
'Or re-run with --start-server:\n' +
|
|
134
|
+
` ${commandName} --start-server --flags FO\n`
|
|
135
|
+
);
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (status === 'mismatch') {
|
|
140
|
+
// chained runs: the previous run's h1 server child may still be releasing
|
|
141
|
+
// the port — give it a grace window before self-launching
|
|
142
|
+
const graceDeadline = Date.now() + 4000;
|
|
143
|
+
while (status === 'mismatch' && Date.now() < graceDeadline) {
|
|
144
|
+
await sleep(250);
|
|
145
|
+
status = await probeServer(serverUrl);
|
|
146
|
+
}
|
|
147
|
+
if (status === 'ok') return null;
|
|
148
|
+
if (status === 'mismatch') {
|
|
149
|
+
console.error(mismatchMsg);
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// this module ships inside tape-six — its own server bin is two levels up
|
|
155
|
+
const serverBin = fileURLToPath(new URL('../../bin/tape6-server.js', import.meta.url)),
|
|
156
|
+
serverParts = new URL(serverUrl),
|
|
157
|
+
host = serverParts.hostname,
|
|
158
|
+
port = serverParts.port || '3000',
|
|
159
|
+
// h2 server mode is Node-only (tape6-server refuses it elsewhere), so
|
|
160
|
+
// under Bun/Deno the h2 server child runs on PATH's node
|
|
161
|
+
execPath = h2 && runtime.name !== 'node' ? 'node' : process.execPath,
|
|
162
|
+
child = spawn(execPath, h2 ? [serverBin, '--h2'] : [serverBin], {
|
|
163
|
+
cwd: rootFolder,
|
|
164
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
165
|
+
detached: false,
|
|
166
|
+
env: {...process.env, HOST: host, PORT: port}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
let exited = false,
|
|
170
|
+
exitCode = null,
|
|
171
|
+
stderrData = '';
|
|
172
|
+
child.stderr.on('data', chunk => (stderrData += chunk));
|
|
173
|
+
child.on('error', error => {
|
|
174
|
+
exited = true;
|
|
175
|
+
exitCode = null;
|
|
176
|
+
stderrData += (error && error.message) || String(error);
|
|
177
|
+
});
|
|
178
|
+
child.on('exit', code => {
|
|
179
|
+
exited = true;
|
|
180
|
+
exitCode = code;
|
|
181
|
+
});
|
|
182
|
+
child.unref();
|
|
183
|
+
|
|
184
|
+
const startDeadline = Date.now() + 15000;
|
|
185
|
+
while (Date.now() < startDeadline) {
|
|
186
|
+
await sleep(500);
|
|
187
|
+
if (exited) {
|
|
188
|
+
console.error(
|
|
189
|
+
`Error: tape6-server exited with code ${exitCode} while starting on ${host}:${port}` +
|
|
190
|
+
(stderrData ? '\n' + stderrData.trim() : '')
|
|
191
|
+
);
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
status = await probeServer(serverUrl);
|
|
195
|
+
if (status === 'ok') return child;
|
|
196
|
+
if (status === 'mismatch') {
|
|
197
|
+
child.kill();
|
|
198
|
+
console.error(mismatchMsg);
|
|
199
|
+
process.exit(1);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
child.kill();
|
|
204
|
+
console.error(
|
|
205
|
+
`Error: tape6-server failed to start on ${host}:${port} (timed out after 15s)` +
|
|
206
|
+
(stderrData ? '\n' + stderrData.trim() : '')
|
|
207
|
+
);
|
|
208
|
+
process.exit(1);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const options = getOptions({
|
|
212
|
+
'--self': {fn: showSelf, isValueRequired: false},
|
|
213
|
+
'--start-server': {isValueRequired: false},
|
|
214
|
+
'--h2': {isValueRequired: false},
|
|
215
|
+
'--info': {isValueRequired: false},
|
|
216
|
+
'--server-url': {aliases: ['-u'], initialValue: getServerUrl(), isValueRequired: true},
|
|
217
|
+
'--browser': {
|
|
218
|
+
aliases: ['-b'],
|
|
219
|
+
initialValue: process.env.TAPE6_BROWSER || supportedBrowsers[0],
|
|
220
|
+
isValueRequired: true
|
|
221
|
+
},
|
|
222
|
+
'--browsers': {initialValue: process.env.TAPE6_BROWSERS || '', isValueRequired: true},
|
|
223
|
+
'--help': {aliases: ['-h'], fn: showHelp, isValueRequired: false},
|
|
224
|
+
'--version': {aliases: ['-v'], fn: showVersion, isValueRequired: false}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// mirrors tape6-server's flag > env > config resolution, so the URL scheme
|
|
228
|
+
// below agrees with the protocol a self-launched server will pick
|
|
229
|
+
let protocol = options.optionFlags['--h2'] === '' ? 'h2' : process.env.TAPE6_PROTOCOL || '';
|
|
230
|
+
if (!protocol) protocol = (await getConfig(rootFolder)).server?.protocol || 'h1';
|
|
231
|
+
|
|
232
|
+
let serverUrl = options.optionFlags['--server-url'].replace(/\/+$/, '');
|
|
233
|
+
if (protocol === 'h2') serverUrl = serverUrl.replace(/^http:/i, 'https:');
|
|
234
|
+
// an explicit https: --server-url means TLS regardless of --h2
|
|
235
|
+
const secure = /^https:/i.test(serverUrl);
|
|
236
|
+
|
|
237
|
+
options.flags.serverUrl = serverUrl;
|
|
238
|
+
options.flags.browser = options.optionFlags['--browser'];
|
|
239
|
+
|
|
240
|
+
const resolved = resolveBrowsers(
|
|
241
|
+
options.optionFlags['--browsers'],
|
|
242
|
+
options.flags.browser,
|
|
243
|
+
supportedBrowsers
|
|
244
|
+
);
|
|
245
|
+
if (resolved.badBrowser !== undefined) {
|
|
246
|
+
console.error(
|
|
247
|
+
`Error: unsupported browser "${resolved.badBrowser}". Choose one of: ${supportedBrowsers.join(', ')}.`
|
|
248
|
+
);
|
|
249
|
+
await new Promise(r => process.stderr.write('', r));
|
|
250
|
+
process.exitCode = 1;
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const browsers = resolved.browsers;
|
|
254
|
+
|
|
255
|
+
await Promise.all([initReporter(getReporter, setReporter, options.flags), selectTimer()]);
|
|
256
|
+
|
|
257
|
+
if (options.optionFlags['--info'] === '') {
|
|
258
|
+
showInfo(options, []);
|
|
259
|
+
await new Promise(r => process.stdout.write('', r));
|
|
260
|
+
process.exitCode = 0;
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const startServer = options.optionFlags['--start-server'] === '';
|
|
265
|
+
|
|
266
|
+
const serverChild = await ensureServer(serverUrl, startServer, secure);
|
|
267
|
+
|
|
268
|
+
console.log(
|
|
269
|
+
`Connected to ${serverUrl} (${serverChild ? 'self-launched' : 'external'}); ` +
|
|
270
|
+
(browsers.length > 1 ? `browsers: ${browsers.join(', ')}` : `browser: ${browsers[0]}`)
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const shutdown = code => {
|
|
274
|
+
serverChild?.kill();
|
|
275
|
+
process.exit(code);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
process.on('uncaughtException', (error, origin) => {
|
|
279
|
+
console.error('UNHANDLED ERROR:', origin, error);
|
|
280
|
+
shutdown(1);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
let files = [];
|
|
284
|
+
try {
|
|
285
|
+
if (options.files.length) {
|
|
286
|
+
const query = options.files.map(p => 'q=' + encodeURIComponent(p)).join('&');
|
|
287
|
+
const response = await controlFetch(serverUrl + '/--patterns?' + query);
|
|
288
|
+
if (response.ok) files = await response.json();
|
|
289
|
+
}
|
|
290
|
+
if (!files.length) {
|
|
291
|
+
const response = await controlFetch(serverUrl + '/--tests');
|
|
292
|
+
if (response.ok) files = await response.json();
|
|
293
|
+
}
|
|
294
|
+
} catch {}
|
|
295
|
+
|
|
296
|
+
if (!files.length) {
|
|
297
|
+
console.log('No test files found on the server.');
|
|
298
|
+
shutdown(1);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
let importmap = null;
|
|
302
|
+
try {
|
|
303
|
+
const response = await controlFetch(serverUrl + '/--importmap');
|
|
304
|
+
if (response.ok) importmap = await response.json();
|
|
305
|
+
} catch {}
|
|
306
|
+
|
|
307
|
+
const failedBrowsers = [];
|
|
308
|
+
for (let i = 0; i < browsers.length; ++i) {
|
|
309
|
+
const browser = browsers[i];
|
|
310
|
+
if (i) {
|
|
311
|
+
// fresh reporter per engine (correct per-engine counts and summary):
|
|
312
|
+
// initReporter() only constructs into an empty slot
|
|
313
|
+
setReporter(null);
|
|
314
|
+
await initReporter(getReporter, setReporter, options.flags);
|
|
315
|
+
}
|
|
316
|
+
if (browsers.length > 1) console.log(`\nBrowser: ${browser}`);
|
|
317
|
+
|
|
318
|
+
const reporter = getReporter(),
|
|
319
|
+
worker = new TestWorker(reporter, options.parallel, {
|
|
320
|
+
...options.flags,
|
|
321
|
+
browser,
|
|
322
|
+
serverUrl,
|
|
323
|
+
importmap
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
reporter.report({type: 'test', test: 0});
|
|
327
|
+
|
|
328
|
+
await new Promise(resolve => {
|
|
329
|
+
worker.done = () => resolve();
|
|
330
|
+
worker.execute(files);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const hasFailed = reporter.state && reporter.state.failed > 0;
|
|
334
|
+
|
|
335
|
+
reporter.report({
|
|
336
|
+
type: 'end',
|
|
337
|
+
test: 0,
|
|
338
|
+
fail: hasFailed
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
await worker.cleanup();
|
|
342
|
+
|
|
343
|
+
if (hasFailed) failedBrowsers.push(browser);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (browsers.length > 1) {
|
|
347
|
+
console.log(
|
|
348
|
+
'\nBrowsers: ' +
|
|
349
|
+
browsers.map(name => name + (failedBrowsers.includes(name) ? ' FAIL' : ' PASS')).join(', ')
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
serverChild?.kill();
|
|
354
|
+
await new Promise(r => process.stdout.write('', r));
|
|
355
|
+
process.exitCode = failedBrowsers.length ? 1 : 0;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
export default runDriverCli;
|
|
@@ -302,8 +302,10 @@ export class TTYReporter extends Reporter {
|
|
|
302
302
|
this.out(this.lowWhite(' actual: ') + this.formatValue(actual));
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
-
|
|
306
|
-
|
|
305
|
+
if (Array.isArray(event.stackList) && event.stackList.length) {
|
|
306
|
+
this.out(this.lowWhite(' stack: |-'));
|
|
307
|
+
event.stackList.forEach(line => this.out(this.lowWhite(' at ' + line)));
|
|
308
|
+
}
|
|
307
309
|
}
|
|
308
310
|
break;
|
|
309
311
|
}
|
|
@@ -30,18 +30,37 @@ const formatValue = value => {
|
|
|
30
30
|
return JSON.stringify(value);
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
+
// expected/actual arrive pre-serialized; parse for the signature strip
|
|
34
|
+
const parseValue = text => {
|
|
35
|
+
if (typeof text != 'string') return text;
|
|
36
|
+
try {
|
|
37
|
+
const value = JSON.parse(text);
|
|
38
|
+
if (value && value[signature] === signature) delete value[signature];
|
|
39
|
+
return value;
|
|
40
|
+
} catch {
|
|
41
|
+
return text;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const formatSerialized = text => {
|
|
46
|
+
const value = parseValue(text);
|
|
47
|
+
return typeof value == 'string' ? JSON.stringify(value) : formatValue(value);
|
|
48
|
+
};
|
|
49
|
+
|
|
33
50
|
export class TapReporter extends Reporter {
|
|
34
51
|
constructor({
|
|
35
52
|
write,
|
|
36
53
|
failOnce = false,
|
|
37
54
|
useJson = false,
|
|
38
|
-
|
|
55
|
+
// flat TAP output repeats nested/per-file ids — consumers need 1..N
|
|
56
|
+
renumberAsserts = true,
|
|
39
57
|
hasColors = true,
|
|
40
58
|
originalConsole
|
|
41
59
|
} = {}) {
|
|
42
60
|
super({failOnce});
|
|
43
61
|
this.console = originalConsole || console;
|
|
44
|
-
|
|
62
|
+
// the style arg must not reach writeOut — it prints every argument
|
|
63
|
+
this.write = write || (hasColors ? this.logger : text => this.writeOut(text));
|
|
45
64
|
this.renumberAsserts = renumberAsserts;
|
|
46
65
|
this.useJson = useJson;
|
|
47
66
|
this.assertCounter = 0;
|
|
@@ -163,10 +182,10 @@ export class TapReporter extends Reporter {
|
|
|
163
182
|
this.write(' operator: ' + event.operator, 'yaml');
|
|
164
183
|
this.write(' message: ' + formatValue(nameLines), 'yaml');
|
|
165
184
|
if (event.hasOwnProperty('expected')) {
|
|
166
|
-
this.write(' expected: ' +
|
|
185
|
+
this.write(' expected: ' + formatSerialized(event.expected), 'yaml');
|
|
167
186
|
}
|
|
168
187
|
if (event.hasOwnProperty('actual')) {
|
|
169
|
-
this.write(' actual: ' +
|
|
188
|
+
this.write(' actual: ' + formatSerialized(event.actual), 'yaml');
|
|
170
189
|
}
|
|
171
190
|
event.at && this.write(' at: ' + event.at, 'yaml');
|
|
172
191
|
} else {
|
|
@@ -176,8 +195,8 @@ export class TapReporter extends Reporter {
|
|
|
176
195
|
).forEach(line => this.write(line, 'yaml'));
|
|
177
196
|
yamlFormatter(
|
|
178
197
|
{
|
|
179
|
-
expected:
|
|
180
|
-
actual:
|
|
198
|
+
expected: parseValue(event.expected),
|
|
199
|
+
actual: parseValue(event.actual)
|
|
181
200
|
},
|
|
182
201
|
formatterOptions
|
|
183
202
|
).forEach(line => this.write(line, 'yaml'));
|
|
@@ -185,8 +204,13 @@ export class TapReporter extends Reporter {
|
|
|
185
204
|
this.write(line, 'yaml')
|
|
186
205
|
);
|
|
187
206
|
}
|
|
188
|
-
|
|
189
|
-
|
|
207
|
+
if (Array.isArray(event.stackList) && event.stackList.length) {
|
|
208
|
+
// quoted list, not a block scalar: prove's YAMLish reader has no |-
|
|
209
|
+
this.write(' stack:', 'yaml');
|
|
210
|
+
event.stackList.forEach(line =>
|
|
211
|
+
this.write(' - ' + JSON.stringify('at ' + line), 'yaml')
|
|
212
|
+
);
|
|
213
|
+
}
|
|
190
214
|
this.write(' ...', 'yaml');
|
|
191
215
|
}
|
|
192
216
|
}
|
package/src/server.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {RequestListener, Server} from 'node:http';
|
|
1
|
+
import type {IncomingMessage, RequestListener, Server, ServerResponse} from 'node:http';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Result of starting a server. Properties reflect the actual bound address
|
|
@@ -57,6 +57,32 @@ export function withServer<T>(
|
|
|
57
57
|
opts?: ServerOptions
|
|
58
58
|
): Promise<T>;
|
|
59
59
|
|
|
60
|
+
/** One request captured by a `record()` listener. */
|
|
61
|
+
export interface RecordedRequest {
|
|
62
|
+
method: string;
|
|
63
|
+
url: string;
|
|
64
|
+
/** Lower-cased header names (Node's normalization), copied to a plain object. */
|
|
65
|
+
headers: Record<string, string | string[] | undefined>;
|
|
66
|
+
/** The full request body, eagerly buffered as UTF-8 text (`''` when empty). */
|
|
67
|
+
body: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A request listener that records every request it serves into `requests`. */
|
|
71
|
+
export type RecordingListener = RequestListener & {requests: RecordedRequest[]};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Recording wrapper for the harness: returns a request listener that buffers
|
|
75
|
+
* each incoming request into a `RecordedRequest` on its `requests` array,
|
|
76
|
+
* then answers `204` — or delegates to `handler` when one is given.
|
|
77
|
+
*
|
|
78
|
+
* Eager by design: the body is fully drained before delegation, so `handler`
|
|
79
|
+
* reads `entry.body` (its third argument) — never the already-consumed `req`
|
|
80
|
+
* stream. Reset between tests with `rec.requests.length = 0`.
|
|
81
|
+
*/
|
|
82
|
+
export function record(
|
|
83
|
+
handler?: (req: IncomingMessage, res: ServerResponse, entry: RecordedRequest) => void
|
|
84
|
+
): RecordingListener;
|
|
85
|
+
|
|
60
86
|
/**
|
|
61
87
|
* Hook-registering helper for suite-shared servers. Registers `beforeAll` to
|
|
62
88
|
* start the server and `afterAll` to close it; returns a live-getter context
|
package/src/server.js
CHANGED
|
@@ -41,6 +41,27 @@ export const withServer = async (serverHandler, clientHandler, opts) => {
|
|
|
41
41
|
}
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
+
export const record = handler => {
|
|
45
|
+
const recorder = (req, res) => {
|
|
46
|
+
req.setEncoding('utf8');
|
|
47
|
+
let body = '';
|
|
48
|
+
req.on('data', chunk => (body += chunk));
|
|
49
|
+
req.on('end', () => {
|
|
50
|
+
const entry = {method: req.method, url: req.url, headers: {...req.headers}, body};
|
|
51
|
+
recorder.requests.push(entry);
|
|
52
|
+
if (handler) {
|
|
53
|
+
// eager: the body is already drained — handlers read entry.body, not req
|
|
54
|
+
handler(req, res, entry);
|
|
55
|
+
} else {
|
|
56
|
+
res.statusCode = 204;
|
|
57
|
+
res.end();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
recorder.requests = [];
|
|
62
|
+
return recorder;
|
|
63
|
+
};
|
|
64
|
+
|
|
44
65
|
export const setupServer = (serverHandler, opts) => {
|
|
45
66
|
let lifecycle = null;
|
|
46
67
|
beforeAll(async () => {
|
|
@@ -10,10 +10,11 @@
|
|
|
10
10
|
export type DestroyReason = 'done' | 'failOnce' | 'timeout';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* A test event routed to the reporter
|
|
14
|
-
*
|
|
13
|
+
* A test event routed to the reporter. `type` / `test` are the protocol
|
|
14
|
+
* spine (declared — sisters branch on them); the rest is a loose bag
|
|
15
|
+
* (`name`, `marker`, `operator`, `fail`, `data`, ...). See TESTING.md.
|
|
15
16
|
*/
|
|
16
|
-
export type TestEvent = {[key: string]: unknown};
|
|
17
|
+
export type TestEvent = {type: string; test?: number; [key: string]: unknown};
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* The reporter surface `EventServer` needs. Structural — every tape-six
|
|
@@ -21,13 +22,16 @@ export type TestEvent = {[key: string]: unknown};
|
|
|
21
22
|
*/
|
|
22
23
|
export interface EventServerReporter {
|
|
23
24
|
report(event: TestEvent, suppressStopTest?: boolean): void;
|
|
24
|
-
|
|
25
|
+
// no index signature: class-typed states (`State`) have none and must remain assignable;
|
|
26
|
+
// declared keys = the State-owned surface sisters read (proc: `failed`)
|
|
27
|
+
state?: {stopTest?: boolean; failed?: number} | null;
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
/**
|
|
28
31
|
* Options consumed by the base class. Runners pass their own keys through
|
|
29
32
|
* (`flags`, `importmap`, `serverUrl`, `browser`, ...) — hence the index
|
|
30
|
-
* signature
|
|
33
|
+
* signature; `any`, not `unknown`, so checked-JS siblings read pass-through
|
|
34
|
+
* keys without casts (2026-07-10 decision, applies to all sidecar bags).
|
|
31
35
|
*/
|
|
32
36
|
export interface EventServerOptions {
|
|
33
37
|
/**
|
|
@@ -40,7 +44,7 @@ export interface EventServerOptions {
|
|
|
40
44
|
* `TAPE6_WORKER_TIMEOUT` via `getOptions()`.
|
|
41
45
|
*/
|
|
42
46
|
workerTimeout?: number;
|
|
43
|
-
[key: string]:
|
|
47
|
+
[key: string]: any;
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
/**
|
package/src/utils/config.d.ts
CHANGED
|
@@ -21,13 +21,29 @@ export const printFlagOptions: () => void;
|
|
|
21
21
|
*/
|
|
22
22
|
export const resolvePatterns: (rootFolder: string, patterns: string[]) => Promise<string[]>;
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The `tape6.server` section — core-owned (`src/test-server.js` reads it);
|
|
26
|
+
* declared so driver siblings read it cast-free. Still a user-authored bag,
|
|
27
|
+
* hence the open index.
|
|
28
|
+
*/
|
|
29
|
+
export interface Tape6ServerConfig {
|
|
30
|
+
protocol?: 'h1' | 'h2';
|
|
31
|
+
plugins?: (string | {module: string; name?: string; prefix?: string})[];
|
|
32
|
+
remotePlugins?: boolean;
|
|
33
|
+
webAppPath?: string;
|
|
34
|
+
cert?: string;
|
|
35
|
+
key?: string;
|
|
36
|
+
[key: string]: any;
|
|
37
|
+
}
|
|
38
|
+
|
|
24
39
|
/** The `tape6` config bag: test-set patterns keyed by environment, plus the importmap. */
|
|
25
40
|
export interface Tape6Config {
|
|
26
41
|
tests?: string | string[];
|
|
27
42
|
cli?: string | string[];
|
|
28
43
|
browser?: string | string[];
|
|
29
44
|
importmap?: {imports: Record<string, string>};
|
|
30
|
-
|
|
45
|
+
server?: Tape6ServerConfig;
|
|
46
|
+
[key: string]: any;
|
|
31
47
|
}
|
|
32
48
|
|
|
33
49
|
/** Read `tape6.json`, else `package.json#tape6`, else the default patterns. */
|
|
@@ -74,19 +90,19 @@ export interface ArgOption {
|
|
|
74
90
|
initialValue?: string | null;
|
|
75
91
|
isValueRequired?: boolean;
|
|
76
92
|
/** Custom handler; mutate `flags` directly. */
|
|
77
|
-
fn?: (flags: Record<string,
|
|
93
|
+
fn?: (flags: Record<string, any>, name: string, value: string) => void;
|
|
78
94
|
}
|
|
79
95
|
|
|
80
96
|
/** A bare function is shorthand for `{fn, isValueRequired: true}`. */
|
|
81
97
|
export type ArgOptions = Record<
|
|
82
98
|
string,
|
|
83
|
-
ArgOption | ((flags: Record<string,
|
|
99
|
+
ArgOption | ((flags: Record<string, any>, name: string, value: string) => void)
|
|
84
100
|
>;
|
|
85
101
|
|
|
86
102
|
/** Parse CLI args into files + flag values. CLI-only: in a browser returns an empty array. */
|
|
87
103
|
export const processArgs: (argOptions: ArgOptions) => {
|
|
88
104
|
files: string[];
|
|
89
|
-
flags: Record<string,
|
|
105
|
+
flags: Record<string, any>;
|
|
90
106
|
};
|
|
91
107
|
|
|
92
108
|
export interface RunnerFlags {
|
|
@@ -107,7 +123,7 @@ export interface RunnerFlags {
|
|
|
107
123
|
/** Consumed by the parent (`EventServer`) only; children ignore them. */
|
|
108
124
|
graceTimeout?: number;
|
|
109
125
|
workerTimeout?: number;
|
|
110
|
-
[key: string]:
|
|
126
|
+
[key: string]: any;
|
|
111
127
|
}
|
|
112
128
|
|
|
113
129
|
export interface RunnerOptions {
|
|
@@ -115,7 +131,7 @@ export interface RunnerOptions {
|
|
|
115
131
|
parallel: number;
|
|
116
132
|
files: string[];
|
|
117
133
|
/** Raw parsed option values, keyed by canonical option name. */
|
|
118
|
-
optionFlags: Record<string,
|
|
134
|
+
optionFlags: Record<string, any>;
|
|
119
135
|
}
|
|
120
136
|
|
|
121
137
|
/**
|
package/src/utils/config.js
CHANGED
|
@@ -339,7 +339,11 @@ export const initReporter = async (getReporter, setReporter, flags) => {
|
|
|
339
339
|
runtime.getEnvVar('NODE_DISABLE_COLORS') ||
|
|
340
340
|
runtime.getEnvVar('FORCE_COLOR') === '0'
|
|
341
341
|
),
|
|
342
|
-
customOptions =
|
|
342
|
+
customOptions =
|
|
343
|
+
reporterType === 'tap'
|
|
344
|
+
? // flat TAP stream: per-file ids restart, consumers need 1..N
|
|
345
|
+
{useJson: true, renumberAsserts: true, hasColors}
|
|
346
|
+
: {...flags, hasColors},
|
|
343
347
|
customReporter = new CustomReporter(customOptions);
|
|
344
348
|
setReporter(customReporter);
|
|
345
349
|
}
|
|
@@ -11,7 +11,9 @@ const repeatString = (n, string = ' ') => {
|
|
|
11
11
|
|
|
12
12
|
const hasNewline = /\n/,
|
|
13
13
|
hasColon = /:/,
|
|
14
|
-
forceQuotes = /^(?:@|`|\s|$|true$|false$|null$|.+\s$)
|
|
14
|
+
forceQuotes = /^(?:@|`|\s|$|true$|false$|null$|.+\s$)/,
|
|
15
|
+
// plain-scalar hazards: leading indicators, colon-space/end, comment intro, tabs
|
|
16
|
+
yamlUnsafe = /^[[\]{}#&*!|>'"%,]|^[-?:](?:\s|$)|:(?:\s|$)|\s#|\t/;
|
|
15
17
|
|
|
16
18
|
const getDataEncoding = value => {
|
|
17
19
|
switch (typeof value) {
|
|
@@ -21,7 +23,8 @@ const getDataEncoding = value => {
|
|
|
21
23
|
if (!isNaN(value)) return {inline: true, string: '"' + value + '"'};
|
|
22
24
|
{
|
|
23
25
|
const encoded = JSON.stringify(value);
|
|
24
|
-
if (value.length + 2 === encoded.length
|
|
26
|
+
if (value.length + 2 === encoded.length && !yamlUnsafe.test(value))
|
|
27
|
+
return {inline: true, string: value};
|
|
25
28
|
return {inline: true, string: encoded};
|
|
26
29
|
}
|
|
27
30
|
case 'boolean':
|
|
@@ -37,7 +40,7 @@ const getDataEncoding = value => {
|
|
|
37
40
|
return {skip: true};
|
|
38
41
|
}
|
|
39
42
|
if (value === null) return {inline: true, string: 'null'};
|
|
40
|
-
if (value instanceof Date) return {inline: true, string: value.toUTCString()};
|
|
43
|
+
if (value instanceof Date) return {inline: true, string: JSON.stringify(value.toUTCString())};
|
|
41
44
|
if (value instanceof Set || value instanceof Map) return {skip: true};
|
|
42
45
|
if (Array.isArray(value) && !value.length) return {inline: true, string: '[]'};
|
|
43
46
|
const keys = Object.keys(value);
|
|
@@ -50,6 +53,7 @@ const getKeyEncoding = key => {
|
|
|
50
53
|
if (hasNewline.test(key)) return {string: key.split('\n')};
|
|
51
54
|
const encoded = JSON.stringify(key);
|
|
52
55
|
if (key.length + 2 === encoded.length) {
|
|
56
|
+
if (yamlUnsafe.test(key)) return {inline: true, string: encoded};
|
|
53
57
|
if (hasColon.test(key)) return {string: key};
|
|
54
58
|
return {inline: true, string: key};
|
|
55
59
|
}
|