Skip to content

Commit 458ce4c

Browse files
committed
WIP: mininal reporter
1 parent d029107 commit 458ce4c

File tree

5 files changed

+308
-90
lines changed

5 files changed

+308
-90
lines changed

src/qtap.js

+25-5
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
'use strict';
22

3+
import { EventEmitter } from 'node:events';
34
import util from 'node:util';
45

56
import kleur from 'kleur';
67
import browsers from './browsers.js';
8+
import reporters from './reporters.js';
79
import { ControlServer } from './server.js';
810

911
/**
@@ -37,9 +39,11 @@ function makeLogger (defaultChannel, printDebug, verbose = false) {
3739
: function debug (messageCode, ...params) {
3840
printDebug(kleur.grey(`[${prefix}] ${kleur.bold(messageCode)} ${paramsFmt(params)}`));
3941
},
40-
warning: function warning (messageCode, ...params) {
41-
printDebug(kleur.yellow(`[${prefix}] WARNING ${kleur.bold(messageCode)}`) + ` ${paramsFmt(params)}`);
42-
}
42+
warning: !verbose
43+
? function () {}
44+
: function warning (messageCode, ...params) {
45+
printDebug(kleur.yellow(`[${prefix}] WARNING ${kleur.bold(messageCode)}`) + ` ${paramsFmt(params)}`);
46+
}
4347
};
4448
}
4549

@@ -89,10 +93,14 @@ async function run (browserNames, files, options = {}) {
8993
options.printDebug || console.error,
9094
options.verbose
9195
);
96+
const eventbus = new EventEmitter();
97+
98+
reporters.noop(eventbus);
99+
// reporters.minimal(eventbus);
92100

93101
const servers = [];
94102
for (const file of files) {
95-
servers.push(new ControlServer(options.root, file, logger, {
103+
servers.push(new ControlServer(options.root, file, eventbus, logger, {
96104
idleTimeout: options.timeout,
97105
connectTimeout: options.connectTimeout
98106
}));
@@ -138,8 +146,20 @@ async function run (browserNames, files, options = {}) {
138146
}
139147
}
140148

149+
// const promise = new Promise((resolve, reject) => {
150+
// // TODO: The promise must be created on-demand so that Node.js built-in protection
151+
// // against unhandled error events remains useful. That is, the caller must either
152+
// // handle the error event -or- request the promise, at which point the user continues
153+
// // to be protected by Node.js 'unhandledRejection' detection, e.g. if they forget to
154+
// // await or catch the promise.
155+
// eventbus.on('error', reject);
156+
// // TODO: finish event should include exitCode and/or data that makes it trivial to construct
157+
// eventbus.on('finish', resolve);
158+
// });
159+
// return { eventbus, promise };
160+
141161
// TODO: Set exit status to 1 on failures, to ease programmatic use and testing.
142-
// TODO: Return an event emitter for custom reportering via programmatic use.
162+
// TODO: Return an event emitter for custom reportin via programmatic use.
143163
return 0;
144164
}
145165

src/reporters.js

+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
function dynamicMinimalReporter (eventbus) {
2+
/**
3+
* @typedef {Object} ClientState
4+
* @property {string} clientId
5+
* @property {string} displayName
6+
* @property {string} status
7+
* @property {null|string} lastline
8+
* @property {Array} failures
9+
*/
10+
11+
/** @type {Object<string,ClientState[]>} */
12+
const clientsByFile = Object.create(null);
13+
/** @type {Object<string,ClientState>} */
14+
const clientsById = Object.create(null);
15+
16+
let screen = '';
17+
18+
function render () {
19+
const icons = { waiting: '⢎', progress: '⠧', success: '✔', failure: '✘' };
20+
let str = '';
21+
for (const testFile in clientsByFile) {
22+
str += `\nRunning ${testFile}\n`;
23+
for (const client of clientsByFile[testFile]) {
24+
str += `* ${client.displayName} ${icons[client.status]} ${client.lastline || ''}\n`;
25+
}
26+
}
27+
28+
if (screen) {
29+
const oldHeight = screen.split('\n').length;
30+
for (let i = 1; i < oldHeight; i++) {
31+
process.stdout.write('\x1b[A\x1b[K');
32+
}
33+
}
34+
35+
process.stdout.write(str);
36+
screen = str;
37+
}
38+
39+
eventbus.on('clientcreate', (event) => {
40+
const client = {
41+
clientId: event.clientId,
42+
displayName: event.displayName,
43+
status: 'waiting',
44+
lastline: null,
45+
failures: []
46+
};
47+
48+
clientsByFile[event.testFile] ??= [];
49+
clientsByFile[event.testFile].push(client);
50+
clientsById[event.clientId] = client;
51+
render();
52+
});
53+
54+
eventbus.on('clientonline', (event) => {
55+
clientsById[event.clientId].status = 'progress';
56+
render();
57+
});
58+
59+
eventbus.on('clientsample', (event) => {
60+
clientsById[event.clientId].lastline = event.line;
61+
render();
62+
});
63+
64+
eventbus.on('clientend', (event) => {
65+
clientsById[event.clientId].status = 'failure';
66+
clientsById[event.clientId].lastline = event.reason;
67+
render();
68+
});
69+
}
70+
71+
function flatMinimalReporter (eventbus) {
72+
eventbus.on('createclient', (client) => {
73+
console.log(`Running ${client.testFile}`);
74+
console.log(`* ${client.displayName}`);
75+
});
76+
}
77+
78+
const isTTY = process.stdout.isTTY && process.env.TERM !== 'dumb';
79+
const minimal = isTTY ? dynamicMinimalReporter : flatMinimalReporter;
80+
81+
function noop (_eventbus) {}
82+
83+
export default { noop, minimal };
84+
85+
// TODO: Default: TAP where each browser is 1 virtual test in case of success.
86+
// TODO: Verbose: TAP forwarded, test names prepended with [browsername].
87+
// TODO: Failures are shown either way, with prepended names.
88+
// TODO: On "runEnd", report wall-clock runtime
89+
// Default: No-op, as overall TAP line as single test (above) can contain runtime
90+
// Verbose: Output comment indicatinh browser done, and test runtime.

src/server.js

+36-85
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import path from 'node:path';
88
import { fnToStr, qtapClientHead, qtapClientBody } from './client.js';
99
import * as util from './util.js';
1010
import tapFinished from './tap-finished.js';
11+
/** @import events from 'node:events' */
1112
/** @import { Logger } from './qtap.js' */
1213

1314
const QTAP_DEBUG = process.env.QTAP_DEBUG === '1';
@@ -19,12 +20,13 @@ class ControlServer {
1920
/**
2021
* @param {string|undefined} root
2122
* @param {string} testFile File path or URL
23+
* @param {events.EventEmitter} eventbus
2224
* @param {Logger} logger
2325
* @param {Object} options
2426
* @param {number|undefined} options.idleTimeout
2527
* @param {number|undefined} options.connectTimeout
2628
*/
27-
constructor (root, testFile, logger, options) {
29+
constructor (root, testFile, eventbus, logger, options) {
2830
if (!root) {
2931
// For `qtap test/index.html`, default root to cwd.
3032
root = process.cwd();
@@ -38,6 +40,7 @@ class ControlServer {
3840

3941
this.root = root;
4042
this.testFile = testFile;
43+
this.eventbus = eventbus;
4144
this.logger = logger.channel('qtap_server_' + ControlServer.nextServerId++);
4245
this.idleTimeout = options.idleTimeout || 30;
4346
this.connectTimeout = options.connectTimeout || 60;
@@ -108,9 +111,7 @@ class ControlServer {
108111
async launchBrowser (browserFn, browserName, globalSignal) {
109112
const clientId = 'client_' + ControlServer.nextClientId++;
110113
const logger = this.logger.channel(`qtap_browser_${clientId}_${browserName}`);
111-
112-
// TODO: Remove `summary` in favour of `eventbus`
113-
const summary = { ok: true };
114+
let clientIdleTimer = null;
114115

115116
const controller = new AbortController();
116117
let signal = controller.signal;
@@ -122,13 +123,6 @@ class ControlServer {
122123
});
123124
}
124125

125-
// TODO: Write test for --connect-timeout by using a no-op browser.
126-
const TIMEOUT_CONNECT = this.connectTimeout;
127-
const TIMEOUT_IDLE = this.idleTimeout;
128-
const TIMEOUT_CHECK_MS = 1000;
129-
const launchStart = performance.now();
130-
let clientIdleTimer = null;
131-
132126
// Reasons to stop a browser, whichever comes first:
133127
// 1. tap-finished.
134128
// 2. tap-parser 'bailout' event (client knows it crashed).
@@ -144,19 +138,19 @@ class ControlServer {
144138
controller.abort(reason);
145139
};
146140

147-
const tapParser = tapFinished({ wait: 0 }, () => {
148-
logger.debug('browser_tap_finished', 'Test has finished, stopping browser');
141+
const tapParser = tapFinished({ wait: 0 }, (result) => {
142+
logger.debug('browser_tap_finished', 'Test has finished, stopping browser', result.ok);
149143
stopBrowser('QTap: browser_tap_finished');
150144
});
151145

152146
tapParser.on('bailout', (reason) => {
153147
logger.warning('browser_tap_bailout', `Test ended unexpectedly, stopping browser. Reason: ${reason}`);
154-
summary.ok = false;
155148
stopBrowser('QTap: browser_tap_bailout');
149+
this.eventbus.emit('clientend', { clientId, reason: 'browser_tap_bailout' });
156150
});
157151
tapParser.once('fail', () => {
158152
logger.debug('browser_tap_fail', 'Results indicate at least one test has failed assertions');
159-
summary.ok = false;
153+
this.eventbus.emit('clientend', { clientId, reason: 'browser_tap_fail' });
160154
});
161155
// Debugging
162156
// tapParser.on('assert', logger.debug.bind(logger, 'browser_tap_assert'));
@@ -176,32 +170,47 @@ class ControlServer {
176170
// in `handleTap()` or `tapParser.on('line')`. But that adds significant overhead from
177171
// Node.js/V8 natively allocating many timers when processing large batches of test results.
178172
// Instead, merely store performance.now() and check that periodically.
179-
clientIdleTimer = setTimeout(function qtapCheckTimeout () {
180-
// TODO: Report timeout failure to reporter/TAP/CLI.
173+
// TODO: Write test for --connect-timeout by using a no-op browser.
174+
const TIMEOUT_CHECK_MS = 1000;
175+
const launchStart = performance.now();
176+
const qtapCheckTimeout = () => {
181177
if (!browser.clientIdleActive) {
182-
if ((performance.now() - launchStart) > (TIMEOUT_CONNECT * 1000)) {
183-
logger.warning('browser_connect_timeout', `Browser did not start within ${TIMEOUT_CONNECT}s, stopping browser`);
184-
summary.ok = false;
178+
if ((performance.now() - launchStart) > (this.connectTimeout * 1000)) {
179+
logger.warning('browser_connect_timeout', `Browser did not start within ${this.connectTimeout}s, stopping browser`);
185180
stopBrowser('QTap: browser_connect_timeout');
181+
this.eventbus.emit('clientend', { clientId, reason: 'browser_connect_timeout' });
186182
return;
187183
}
188184
} else {
189-
if ((performance.now() - browser.clientIdleActive) > (TIMEOUT_IDLE * 1000)) {
190-
logger.warning('browser_idle_timeout', `Browser idle for ${TIMEOUT_IDLE}s, stopping browser`);
191-
summary.ok = false;
185+
if ((performance.now() - browser.clientIdleActive) > (this.idleTimeout * 1000)) {
186+
logger.warning('browser_idle_timeout', `Browser idle for ${this.idleTimeout}s, stopping browser`);
192187
stopBrowser('QTap: browser_idle_timeout');
188+
this.eventbus.emit('clientend', { clientId, reason: 'browser_idle_timeout' });
193189
return;
194190
}
195191
}
196192
clientIdleTimer = setTimeout(qtapCheckTimeout, TIMEOUT_CHECK_MS);
197-
}, TIMEOUT_CHECK_MS);
193+
};
194+
clientIdleTimer = setTimeout(qtapCheckTimeout, TIMEOUT_CHECK_MS);
198195

199196
const url = await this.getProxyBase() + '/?qtap_clientId=' + clientId;
200197
const signals = { client: signal, global: globalSignal };
201198

202199
try {
203200
logger.debug('browser_launch_call');
204-
await browserFn(url, signals, logger);
201+
202+
// Separate calling browserFn() from awaiting so that we can emit 'createclient'
203+
// right after calling it (which may set Browser.displayName). If we await here,
204+
// the event would be emitted when the client is done instead of when it starts.
205+
const browerPromise = browserFn(url, signals, logger);
206+
this.eventbus.emit('clientcreate', {
207+
testFile: this.testFile,
208+
clientId,
209+
browserName,
210+
displayName: browser.getDisplayName()
211+
});
212+
await browerPromise;
213+
205214
logger.debug('browser_launch_ended');
206215
} finally {
207216
// TODO: Report error to TAP. Eg. "No executable found"
@@ -265,6 +274,7 @@ class ControlServer {
265274
if (browser) {
266275
browser.clientIdleActive = performance.now();
267276
browser.logger.debug('browser_connected', `${browser.getDisplayName()} connected! Serving test file.`);
277+
this.eventbus.emit('clientonline', { clientId });
268278
} else {
269279
this.logger.debug('respond_static_testfile', clientId);
270280
}
@@ -309,72 +319,13 @@ class ControlServer {
309319
);
310320

311321
browser.clientIdleActive = performance.now();
322+
this.eventbus.emit('clientsample', { clientId, line: body.trimEnd().split('\n').pop() });
312323
} else {
313324
this.logger.debug('browser_tap_unhandled', clientId, JSON.stringify(body.slice(0, 30) + '…'));
314325
}
315326
});
316327
resp.writeHead(204);
317328
resp.end();
318-
319-
// TODO: Pipe to one of two options, based on --reporter:
320-
// - [tap | default in piped and CI?]: tap-parser + some kind of renumbering or prefixing.
321-
// client_1> ok 40 foo > bar
322-
// out> ok 1 - qtap > Firefox (client_1) connected! Running test/index.html.
323-
// out> ok 42 - foo > bar
324-
// -->
325-
// client_1> ok 40 foo > bar
326-
// client_2> ok 40 foo > bar
327-
// out> ok 1 - qtap > Firefox (client_1) connected! Running test/index.html.
328-
// out> ok 2 - qtap > Chromium (client_2) connected! Running test/index.html.
329-
// out> ok 81 - foo > bar [Firefox client_1]
330-
// out> ok 82 - foo > bar [Chromium client_2]
331-
// - [minimal|default in interactive mode]
332-
// out> Testing /test/index.html
333-
// out>
334-
// out> Firefox : SPINNER [blue]
335-
// out> Running test 40.
336-
// out> [Chromium] : [grey] [star] [grey] Launching...
337-
// out> [Safari] : [grey] [star] [grey] Launching...
338-
// -->
339-
// out> Testing /test/index.html
340-
// out>
341-
// out> [Firefox client_1]: ✔ [green] Completed 123 tests in 42ms.
342-
// out> [Chromium client2]: [blue*spinner] Running test 40.
343-
// out> [Safari client_3] [grey] [star] [grey] Launching...
344-
// -->
345-
// out> Testing /test/index.html
346-
// out>
347-
// out> not ok 40 foo > bar # Chromium client_2
348-
// out> ---
349-
// out> message: failed
350-
// out> actual : false
351-
// out> expected: true
352-
// out> stack: |
353-
// out> @/example.js:46:12
354-
// out> ...
355-
// out>
356-
// out> [Firefox client_1]: ✔ [green] Completed 123 tests in 42ms.
357-
// out> [Chromium client_2]: ✘ [red] 2 failures.
358-
//
359-
// If minimal is selected explicilty in piped/non-interactive/CI mode,
360-
// then it will have no spinners, and also lines won't overwrite each other.
361-
// Test counting will be disabled along with the spinner so instead we'll print:
362-
// out> Firefox client_1: Launching...
363-
// out> Firefox client_1: Running tests... [= instead of spinner/counter]
364-
// out> Firefox client_1: Completed 123 tets in 42ms.
365-
366-
// "▓", "▒", "░" // noise, 100
367-
// "㊂", "㊀", "㊁" // toggle10, 100
368-
// await new Promise(r=>setTimeout(r,100)); process.stdout.write('\r' + frames[i % frames.length] + ' ');
369-
// writable.isTTY
370-
// !process.env.CI
371-
372-
// Default: TAP where each browser is 1 virtual test in case of success.
373-
// Verbose: TAP forwarded, test names prepended with [browsername].
374-
// Failures are shown either way, with prepended names.
375-
// TODO: On "runEnd", report runtime
376-
// Default: No-op, as overall TAP line as single test (above) can contain runtime
377-
// Verbose: Output comment indicatinh browser done, and test runtime.
378329
}
379330

380331
/**

src/util.js

+2
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ export const LocalBrowser = {
134134

135135
return new Promise((resolve, reject) => {
136136
spawned.on('error', error => {
137+
// TODO: Move absorbing of expected error to server.js
137138
if (signals.client.aborted) {
138139
resolve();
139140
} else {
@@ -148,6 +149,7 @@ export const LocalBrowser = {
148149
+ (sig ? `\n signal: ${sig}` : '')
149150
+ (stderr ? `\n stderr:\n${indent(stderr)}` : '')
150151
+ (stdout ? `\n stdout:\n${indent(stdout)}` : '');
152+
// TODO: Move absorbing of expected error to server.js
151153
if (!signals.client.aborted) {
152154
reject(new Error(details));
153155
} else {

0 commit comments

Comments
 (0)