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 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
- if (!settings) {
229
- await selectTimer();
230
- settings = await init();
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
- const {isBrowser, isCli} = settings,
234
- reporter = getReporter(),
235
- testFileName = getTestFileName(settings);
239
+ const {isBrowser, isCli} = settings,
240
+ reporter = getReporter(),
241
+ testFileName = getTestFileName(settings);
236
242
 
237
- reporter.report({
238
- type: 'test',
239
- test: 0,
240
- name: testFileName ? 'FILE: /' + testFileName : ''
241
- });
243
+ reporter.report({
244
+ type: 'test',
245
+ test: 0,
246
+ name: testFileName ? 'FILE: /' + testFileName : ''
247
+ });
242
248
 
243
- reporter.state.beforeAll = getBeforeAll();
244
- clearBeforeAll();
245
- reporter.state.afterAll = getAfterAll();
246
- clearAfterAll();
247
- reporter.state.beforeEach = getBeforeEach();
248
- clearBeforeEach();
249
- reporter.state.afterEach = getAfterEach();
250
- clearAfterEach();
251
- reporter.state.isBeforeAllUsed = false;
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
- const currentState = reporter.state;
259
+ const currentState = reporter.state;
254
260
 
255
- for (;;) {
256
- const tests = getTests();
257
- if (!tests.length) break;
258
- clearTests();
259
- const canContinue = await runTests(tests);
260
- if (!canContinue) break;
261
- await new Promise(resolve => defer(resolve));
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
- await currentState?.runAfterAll();
270
+ await currentState?.runAfterAll();
265
271
 
266
- const runHasFailed = reporter.state && reporter.state.failed > 0;
272
+ const runHasFailed = reporter.state && reporter.state.failed > 0;
267
273
 
268
- reporter.report({
269
- type: 'end',
270
- test: 0,
271
- name: testFileName ? 'FILE: /' + testFileName : '',
272
- fail: runHasFailed
273
- });
274
+ reporter.report({
275
+ type: 'end',
276
+ test: 0,
277
+ name: testFileName ? 'FILE: /' + testFileName : '',
278
+ fail: runHasFailed
279
+ });
274
280
 
275
- if (!getConfiguredFlag() && runHasFailed) {
276
- if (isCli) {
277
- process.exitCode = 1;
281
+ if (!getConfiguredFlag() && runHasFailed) {
282
+ if (isCli) {
283
+ process.exitCode = 1;
284
+ }
278
285
  }
279
- }
280
- if (isBrowser && typeof __tape6_reportResults == 'function') {
281
- __tape6_reportResults(runHasFailed ? 'failure' : 'success');
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.14.0",
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.0",
105
- "prettier": "^3.9.4",
106
- "typescript": "^6.0.3"
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 only for failures — stack
190
- // walking on passing assertions is wasted work
191
- if (isFailed) {
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
- this.out(this.lowWhite(' stack: |-'));
306
- event.stackList.forEach(line => this.out(this.lowWhite(' at ' + line)));
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
- renumberAsserts = false,
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
- this.write = write || (hasColors ? this.logger : (...args) => this.writeOut(...args));
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: ' + formatValue(event.expected), 'yaml');
185
+ this.write(' expected: ' + formatSerialized(event.expected), 'yaml');
167
186
  }
168
187
  if (event.hasOwnProperty('actual')) {
169
- this.write(' actual: ' + formatValue(event.actual), 'yaml');
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: event.expected && JSON.parse(event.expected),
180
- actual: event.actual && JSON.parse(event.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
- this.write(' stack: |-', 'yaml');
189
- event.stackList.forEach(line => this.write(' at ' + line, 'yaml'));
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 a loose bag (`type`, `test`,
14
- * `name`, `marker`, `operator`, `fail`, `data`, ...). See TESTING.md.
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
- state?: {stopTest?: boolean; [key: string]: unknown} | null;
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]: unknown;
47
+ [key: string]: any;
44
48
  }
45
49
 
46
50
  /**
@@ -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
- [key: string]: unknown;
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, unknown>, name: string, value: string) => void;
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, unknown>, name: string, value: string) => void)
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, unknown>;
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]: unknown;
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, unknown>;
134
+ optionFlags: Record<string, any>;
119
135
  }
120
136
 
121
137
  /**
@@ -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 = reporterType === 'tap' ? {useJson: true, hasColors} : {...flags, hasColors},
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) return {inline: true, string: value};
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
  }