reportage
Version:
Scenarist-wrapped mocha sessions on browsers to any reporters
1,430 lines (1,406 loc) • 130 kB
JavaScript
/*
@license https://github.com/t2ym/reportage/blob/master/LICENSE.md
Copyright (c) 2023 Tetsuya Mori <t2y3141592@gmail.com>. All rights reserved.
*/
import { _globalThis, sandbox } from './sandbox-global.js';
import { mochaInstaller } from './mocha-loader.js';
import { reporterInstaller } from './proxy-reporter.js';
import {
NAME_REPORTER,
NAME_MEDIATOR,
NAME_MEDIATOR_BRIDGE,
TYPE_READY,
TYPE_ERROR,
TYPE_CONNECT,
TYPE_DISCONNECT,
TYPE_DETACH,
TYPE_TRANSFER_PORT,
TYPE_REQUEST_SESSION,
TYPE_START_SESSION,
TYPE_END_SESSION,
TYPE_CLOSE,
TYPE_CLOSING,
TYPE_NAVIGATE,
TYPE_COVERAGE,
TYPE_COLLECT_COVERAGE,
DISPATCHER_STATE_CLOSED,
DISPATCHER_STATE_NAVIGATING0,
DISPATCHER_STATE_NAVIGATING1,
DISPATCHER_STATE_READY0,
DISPATCHER_STATE_READY1,
DISPATCHER_STATE_STARTING0,
DISPATCHER_STATE_STARTING1,
DISPATCHER_STATE_RUNNING,
DISPATCHER_STATE_STOPPED,
DISPATCHER_STATE_CLOSING,
DISPATCHER_STATE_ERROR,
SESSION_PHASE_STATE_INITIAL,
SESSION_PHASE_STATE_CONTINUING,
SESSION_PHASE_STATE_FINAL,
AGGREGATION_EVENT_PHASE_CONTINUING,
AGGREGATION_EVENT_PHASE_FINAL,
AGGREGATION_EVENT_SCOPE_CONTINUING,
AGGREGATION_EVENT_SCOPE_FINAL,
AGGREGATION_EVENT_RUN_CONTINUING,
AGGREGATION_EVENT_RUN_FINAL,
TEST_UI_SCENARIST,
//TEST_UI_BDD, // not implemented (low priority)
//TEST_UI_TDD, // not implemented (low priority)
SUITE_COMMON,
SUITE_HTML,
TYPE_NAVIGATING,
NAVI_LOCATION_HREF,
NAVI_LOCATION_ASSIGN,
NAVI_LOCATION_REPLACE,
NAVI_HISTORY_REPLACE,
NAVI_HISTORY_PUSH,
NAVI_DEFERRED,
NAVI_WINDOW_OPEN,
NAVI_HISTORY_GO,
} from './constants.js';
//import { MochaConsoleBuffer, MochaConsole, MochaConsoleFlush } from './mocha-console.js';
try {
const reporterURL = new URL(location.href);
const testConfigPath = (reporterURL.hash.substring(1) || '/test/reportage.config.js').split('?')[0];
const { default: Config } = await import(testConfigPath);
await Config.importedBy(import.meta.url);
window.Config = Config; // for debugging
const { default: Suite } = await import(Config.suitesLoaderPath);
mochaInstaller(_globalThis, _globalThis, console);
const { ReceiverRunner } = reporterInstaller(_globalThis.Mocha, Config);
const {
EVENT_RUN_BEGIN,
EVENT_RUN_END,
EVENT_SUITE_BEGIN,
EVENT_SUITE_END,
EVENT_HOOK_BEGIN,
EVENT_TEST_PASS,
EVENT_TEST_FAIL,
EVENT_TEST_PENDING,
STATE_IDLE,
STATE_RUNNING,
STATE_STOPPED,
} = _globalThis.Mocha.Runner.constants;
const startButton = document.getElementById('start-button');
{
let w = window.open("about:blank"); // open a new window without user interaction
if (w) {
w.close();
}
else {
console.error(`Popup blocking on the reporter host domain ${location.hostname} has to be disabled for the site to open target apps!` +
`The instruction on how to allow pop-ups and redirects from a site is found at ` +
`https://support.google.com/chrome/answer/95472?hl=en&co=GENIE.Platform%3DDesktop#zippy=%2Callow-pop-ups-and-redirects-from-a-site`);
console.error(`The command line option --disable-popup-blocking is also effective if it is applied properly to Chrome browser processes`);
throw new Error('popup blocking must be disabled for the reporter and app origins ' + [Config.reporterOrigin, ...Config.originGenerator()].join(' '));
}
}
{
let listener;
let timerResolve;
let timerPromise = new Promise((resolve) => {
timerResolve = resolve;
});
let blurReceived = false;
window.addEventListener('blur', listener = (event) => {
blurReceived = true;
window.removeEventListener('blur', listener);
setTimeout(() => {
const start = Date.now();
let counter = 0;
let intervalId = setInterval(() => {
if (counter < 2) {
counter++;
}
else {
clearInterval(intervalId);
const now = Date.now();
timerResolve(now - start);
}
}, 1);
}, 1);
});
const w = window.open('about:blank', '_checktimer');
setTimeout(() => {
if (!blurReceived) {
timerResolve(1); // It seems the blur event is not fired in puppeteer
}
}, 500);
let duration = await timerPromise;
console.log(`duration ${duration}`);
w.close();
if (duration > 500) {
console.error(`The Chrome command line option --disable-background-timer-throttling is required to run tests in background windows/tabs`);
console.warn(`For stable tests, please confirm these options are configured for Chrome:\n\t--disable-background-timer-throttling\n\t--disable-ipc-flooding-protection\n\t--disable-pushstate-throttle`);
throw new Error(`The Chrome command line option --disable-background-timer-throttling is required to run tests in background windows/tabs`);
}
}
const worker = new SharedWorker(Config.mediatorWorkerPathRelativeToReportage, { /* type: 'module', */name: NAME_MEDIATOR });
const mediatorPort = await new Promise((resolve, reject) => {
// TODO: timeout error handling
worker.port.onmessage = (event) => {
const { type, transfer } = event.data;
const port = transfer[0];
if (type === TYPE_TRANSFER_PORT) {
port.addEventListener('message', function onMessage (_event) {
console.log('reporter.js: received message', _event.data);
const { type, source, target } = _event.data;
if (type === TYPE_READY) {
if (source === NAME_REPORTER && target === NAME_REPORTER) {
console.log('reporter.js: ready');
port.removeEventListener('message', onMessage);
resolve(port);
}
else {
console.error('reporter.js: unexpected ready message received', _event);
reject(new Error(_event.data.errorMessage));
}
}
else if (type === TYPE_ERROR) {
console.error('reporter.js: error message received', _event);
reject(new Error(_event.data.errorMessage));
}
});
port.start();
port.postMessage({
type: TYPE_READY,
source: NAME_REPORTER,
target: NAME_REPORTER, // expecting a mirror response
});
}
else {
reject(new Error(`unknown message type ${type} from mediator-worker.js`));
}
}
worker.port.postMessage({
type: TYPE_CONNECT,
}, []);
});
class ReportageHTML extends _globalThis.Mocha.reporters.HTML {
constructor(runner, options) {
super(runner, options);
}
suiteURL(suite) {
let url;
if (Array.isArray(suite.context)) {
for (let { title, value } of suite.context) {
if (title === 'suiteURL') {
url = value;
break;
}
}
}
if (!url) {
const reporterURL = new URL(Config.reporterURL);
const configPath = (reporterURL.hash.substring(1) || '/test/reportage.config.js').split('?')[0];
url = `${reporterURL.origin}${reporterURL.pathname}${reporterURL.search}#${configPath}`;
}
return url;
}
testURL(test) {
let url;
if (Array.isArray(test.context)) {
for (let { title, value } of test.context) {
if (title === 'testURL') {
url = value;
break;
}
}
/*
if (url && test.state === 'failed' && typeof test.err.stack === 'string') {
let stack = test.err.stack.split('\n');
if (stack[1]) {
let match = stack[1].match(/^[ ]*at [^\(]*\(([^\)]*)\)/);
if (match && match[1]) {
url += `&breakpoint=${encodeURIComponent(match[1])}`
}
}
}
*/
}
if (!url) {
const reporterURL = new URL(Config.reporterURL);
const configPath = (reporterURL.hash.substring(1) || '/test/reportage.config.js').split('?')[0];
url = `${reporterURL.origin}${reporterURL.pathname}${reporterURL.search}#${configPath}`;
}
return url;
}
addCodeToggle(el, contents) {
super.addCodeToggle(el, contents);
const a = el.querySelector('a');
a.addEventListener('click', (event) => {
event.stopPropagation(); // clicking the replay button does not toggle the show/hide status of the code
});
}
}
const suiteGenerator_Scenarist = function* suiteGenerator_Scenarist(Suite) {
let suiteIndex = 0;
const pseudoSearchParamsInHash = new URL(location.hash.substring(1), Config.reporterOrigin).searchParams;
const targetScope = pseudoSearchParamsInHash.get('scope');
const targetTestIndexRaw = pseudoSearchParamsInHash.get('testIndex');
const targetTestIndex = targetTestIndexRaw ? parseInt(targetTestIndexRaw) : -1;
const targetTestClass = pseudoSearchParamsInHash.get('testClass');
for (let scope in Suite.scopes) {
if (Suite.scopes[scope][SUITE_HTML] === SUITE_COMMON) {
continue; // skip running common suites
}
if (targetScope && scope !== targetScope) {
continue; // skip non-target scope
}
const testList = Suite.scopes[scope].test;
for (let index = 0; index < testList.length; index++) {
const tests = testList[index];
const classList = tests.split(',');
let testFound = false;
for (let i = 0; i < classList.length; i++) {
const _class = Suite.scopes[scope].classes[classList[i]];
if (_class && (_class.prototype.operation || _class.prototype.checkpoint)) {
testFound = true;
break;
}
}
if (!testFound) {
continue; // skip empty tests
}
if (targetTestIndexRaw) {
if (index !== targetTestIndex) {
continue; // skip non-target testClass index
}
}
console.log(`[${suiteIndex}] Suite.scopes['${scope}'].test[${index}] = '${tests}'`)
const suite = {
suiteIndex: suiteIndex,
ui: TEST_UI_SCENARIST,
scope: scope,
file: Suite.scopes[scope].file,
testIndex: index,
lastInScope: index + 1 == testList.length,
tests: tests,
testClass: targetTestClass,
[SUITE_HTML]: Suite.scopes[scope][SUITE_HTML],
};
yield suite;
suiteIndex++;
}
}
}
class AggregationBuffer extends Array {
constructor() {
super();
this.current = this.top;
this.done = this.current;
}
get size() {
return this.length;
}
get top() {
return this.size - 1;
}
isEmpty() {
return this.size === 0;
}
cleanup() {
console.log(`before cleanup`, this);
if (this[this.current].type === EVENT_SUITE_END) {
let suiteBeginIndex = this.findSuiteBegin();
if (suiteBeginIndex > 0) {
// cleanup suite
this.splice(suiteBeginIndex, this.current - suiteBeginIndex + 1);
this.current = suiteBeginIndex - 1;
this.done = this.current;
}
else {
throw new Error(`${this.constructor.name}.cleanup: cannot find ${EVENT_SUITE_BEGIN} for __mocha_id__ (${this[this.current].arg.__mocha_id__})`);
}
}
else if (this[this.current].type === EVENT_RUN_END) {
let startIndex = this.findStart();
if (startIndex >= 0) {
// cleanup start - end
this.splice(startIndex, this.current - startIndex + 1);
this.current = startIndex - 1;
this.done = this.current;
}
else {
throw new Error(`${this.constructor.name}.cleanup: cannot find ${EVENT_RUN_BEGIN}`);
}
}
else {
throw new Error(`${this.constructor.name}.cleanup: current.type (${this[this.current].type}) is not ${EVENT_SUITE_END}`);
}
console.log(`after cleanup`, this);
}
findSuiteBegin() {
for (let index = this.current - 1; index >= 0; index--) {
if (this[index].type === EVENT_SUITE_BEGIN &&
this[this.current].arg.__mocha_id__ === this[index].arg.__mocha_id__) {
return index;
}
}
return -1;
}
findStart() {
for (let index = this.current - 1; index >= 0; index--) {
if (this[index].type === EVENT_RUN_BEGIN) {
return index;
}
}
return -1;
}
findAncestors(aggregator) {
//console.log(`${this.constructor.name}.findAncestors`, this.map(e => ({ type: e.type, id: e.arg.__mocha_id__, parent: (e.arg.parent ? e.arg.parent.__mocha_id__ : ''), alias: aggregator.alias[e.arg.__mocha_id__] })));
let index = this.current;
let ancestors = [];
FIND_IMMEDIATE_PARENT_SWITCH:
switch (this[index].type) {
case EVENT_RUN_END:
return null;
case EVENT_SUITE_BEGIN:
break; // already at the immediate parent
case EVENT_TEST_FAIL:
case EVENT_TEST_PENDING:
case EVENT_TEST_PASS:
case EVENT_HOOK_BEGIN:
{
const child = this[index];
for (; index >= 0; index--) {
if (this[index].type === EVENT_SUITE_BEGIN &&
(this[index].arg.__mocha_id__ === child.arg.parent.__mocha_id__ ||
aggregator.alias[this[index].arg.__mocha_id__] === child.arg.parent.__mocha_id__)) {
break FIND_IMMEDIATE_PARENT_SWITCH; // the immediate parent is found
}
}
}
default:
console.error(`Session.findAncestors: cannot find the immediate parent of ${this[this.current].type}`);
return ancestors;
}
ancestors.push(this[index]);
//console.log(`${this.constructor.name}.findAncestors: ancestors.push ${this[index].type} id: ${this[index].arg.__mocha_id__} parent: ${this[index].arg.parent ? this[index].arg.parent.__mocha_id__ : ''} alias: ${aggregator.alias[this[index].arg.__mocha_id__]}`);
let child;
while (index > 0 && this[index].arg && this[index].arg.parent) {
child = this[index];
index--;
for (; index >= 0; index--) {
if (this[index].type === EVENT_SUITE_BEGIN &&
(this[index].arg.__mocha_id__ === child.arg.parent.__mocha_id__ ||
aggregator.alias[this[index].arg.__mocha_id__] === child.arg.parent.__mocha_id__)) {
ancestors.push(this[index]);
//console.log(`${this.constructor.name}.findAncestors: ancestors.push ${this[index].type} id: ${this[index].arg.__mocha_id__} parent: ${this[index].arg.parent ? this[index].arg.parent.__mocha_id__ : ''} alias: ${aggregator.alias[this[index].arg.__mocha_id__]}`);
break;
}
}
}
if (index == 1 && this[index].arg && this[index].arg.root) {
// find start
index--;
if (this[index].type === EVENT_RUN_BEGIN) {
ancestors.push(this[index]);
//console.log(`${this.constructor.name}.findAncestors: ancestors.push ${this[index].type} id: ${this[index].arg.__mocha_id__} parent: ${this[index].arg.parent ? this[index].arg.parent.__mocha_id__ : ''} alias: ${aggregator.alias[this[index].arg.__mocha_id__]}`);
}
else {
console.error(`Session.findAncestors: cannot find start`);
}
}
else {
console.error(`Session.findAncestors: cannot find the root suite ${JSON.stringify(this.map(e => e.type))}`);
}
return ancestors;
}
insert(mEvent) {
if (!mEvent) {
throw new Error(`${this.constructor.name}.insert: empty mEvent`);
}
if (this.current >= 0) {
this.splice(this.current, 0, mEvent);
}
else {
throw new Error(`${this.constructor.name}.insert: current (${this.current}) is negative`);
}
}
}
class Session {
constructor(dispatcher) {
this.state = STATE_IDLE;
this.mEvents = new AggregationBuffer();
this.phaseState = SESSION_PHASE_STATE_INITIAL;
this.sessionId = this.createSessionId();
this.dispatcher = dispatcher;
}
createSessionId() {
return crypto.randomUUID
? crypto.randomUUID()
: (() => {
const hex = Array.prototype.map.call(
crypto.getRandomValues(new Uint16Array(8)),
(v) => v.toString(16).padStart(4, '0'));
return `${hex[0]}${hex[1]}-${hex[2]}-${hex[3]}-${hex[4]}-${hex[5]}${hex[6]}${hex[7]}`
})();
}
createPseudoMochaId() {
const chars = 'abcdefghiklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-';
const length = chars.length;
return Array.prototype.map.call(
crypto.getRandomValues(new Uint8Array(21)),
v => chars[v % length]).join('');
}
onStartSessionMessage(startSessionData) {
this.startSessionData = startSessionData;
if (this.onStartSession(this.startSessionData)) {
this.state = STATE_RUNNING;
return true;
}
else {
return null;
}
}
onStartSession(eventData) {
/*
{
type: "startSession",
sessionId: "",
runner: { total: num, stats: { } },
options: { reporterOptions: {} },
}
*/
if (eventData &&
typeof eventData === 'object' &&
eventData.type === TYPE_START_SESSION &&
typeof eventData.runner === 'object') {
this.total = eventData.runner.total;
this.stats = eventData.runner.stats;
this.options = eventData.options;
console.log('Session.onStartSession: ', eventData);
return true;
}
else {
console.error('Session.onStartSession: invalid event.data', eventData);
return false;
}
}
createChannel() {
const channel = new MessageChannel();
this.port = channel.port1;
channel.port1.onmessage = (event) => {
//const latency = Date.now() - event.data.sent;
//console.warn(`message latency: ${latency}`);
switch (event.data.op) {
case 'write':
let mEvent = event.data.payload;
if (Array.isArray(mEvent)) {
this.mEvents.splice(this.mEvents.length, 0, ...mEvent);
}
else {
this.mEvents.push(mEvent);
}
this.mEvents.current = this.mEvents.top;
this.onWrite(mEvent);
break;
case 'close':
console.log("Session.channel.close");
this.onClose();
break;
case 'abort':
console.log("Session.channel.abort", event.data.err);
this.onAbort(event.data.err);
break;
case 'beacon':
console.log(`Session.channel.beacon ${this.dispatcher ? this.dispatcher.url : 'no dispatcher'}`);
this.onBeacon(event.data.sent);
break;
}
};
const intervalId = setInterval(() => {
if (this.state === STATE_RUNNING && this.dispatcher) {
if (!this.beacon) {
this.beacon = { checked: Date.now() };
}
else if (this.beacon.received && Date.now() - this.beacon.received > Config.beaconTimeout) {
console.log(`Session.channel.beacon timeout ${this.dispatcher.url}`);
clearInterval(intervalId);
this.onAbort(' beacon timed out');
}
else if (Date.now() - this.beacon.checked > Config.beaconTimeout) {
//console.log(`Session.channel.beacon timeout ${this.dispatcher.url}`);
throw new Error(`Session.channel.beacon timeout ${this.dispatcher.url}`);
}
}
else if (this.state === STATE_STOPPED) {
clearInterval(intervalId);
}
}, 1000);
return channel.port2;
}
onBeacon(sent) {
this.beacon = { received: Date.now(), sent: sent };
}
onWrite(mEvent) {
if (Array.isArray(mEvent)) {
console.log('Session.onWrite() ', ...mEvent.map((e) => e.type));
}
else {
console.log('Session.onWrite() ', mEvent.type);
}
if (this.aggregator) {
this.aggregator.onWrite(this, mEvent);
this.mEvents.done = this.mEvents.current;
}
if (Array.isArray(mEvent)) {
if (mEvent[mEvent.length - 1].type === EVENT_RUN_END) {
this.onClose();
}
}
else if (mEvent.type === EVENT_RUN_END) {
this.onClose();
}
}
onClose() {
console.log('Session.onClose()');
if (this.state === STATE_STOPPED) {
return;
}
this.state = STATE_STOPPED;
if (this.aggregator) {
this.aggregator.onClose(this);
}
}
onAbort(err) {
console.log('Session.onAbort()');
if (!this.supplementFailOnAbort(err)) {
console.warn(`Session.onAbort(): no need to abort on a finished session`);
return false;
}
if (this.dispatcher) {
this.dispatcher.detach();
this.dispatcher.closed();
}
if (this.aggregator) {
this.aggregator.onAbort(this, err);
this.mEvents.done = this.mEvents.current;
}
this.state = STATE_STOPPED;
this.phaseState = SESSION_PHASE_STATE_FINAL;
if (this.aggregator) {
this.aggregator.onClose(this);
}
if (this.dispatcher) {
const dispatcher = this.dispatcher;
this.dispatcher = null;
dispatcher.dispatchEvent(new CustomEvent('start', { detail: {} }));
}
return true;
}
onReady() {
console.log(`Session.onReady()`);
this.phaseState = SESSION_PHASE_STATE_FINAL;
if (this.aggregator) {
this.aggregator.onReady(this);
}
}
onNavigating() {
console.log(`Session.onNavigating()`);
this.phaseState = SESSION_PHASE_STATE_CONTINUING;
if (this.aggregator) {
this.aggregator.onNavigating(this);
}
}
supplementFailOnAbort(err) {
console.log('Session.supplementFailOnAbort');
let ancestors = this.mEvents.findAncestors(this.aggregator);
if (!ancestors) {
return false;
}
if (ancestors.length > 0 && ancestors[ancestors.length - 1].type === EVENT_RUN_BEGIN) {
const pseudoEvents = [];
let index;
// supplement a fail event
const title = 'aborted test';
const titlePath = [];
for (index = 0; index < ancestors.length - 1; index++) {
if (ancestors[index].type === EVENT_SUITE_BEGIN &&
ancestors[index].arg.title) {
titlePath.unshift(ancestors[index].arg.title);
}
}
titlePath.push(title);
const pseudoFailEvent = {
"type": EVENT_TEST_FAIL,
"stats": { ...this.mEvents[this.mEvents.current].stats },
"timings": {
"enqueue": Date.now(),
"write": Date.now(),
},
"arg": {
"$$currentRetry": 0,
"$$fullTitle": ancestors[0].arg.$$fullTitle + ' ' + title,
"$$isPending": false,
"$$retriedTest": null,
"$$slow": 0,
"$$titlePath": titlePath,
"body": "window closed",
"duration": 0,
"err": {
"$$toString": "Error: Aborted Test",
"message": "Target application window was unexpectedly closed",
"stack": err
},
"parent": {
"$$fullTitle": ancestors[0].arg.$$fullTitle,
"__mocha_id__": ancestors[0].arg.__mocha_id__
},
"state": "failed",
"title": title,
"type": "test",
"file": null,
"__mocha_id__": this.createPseudoMochaId(),
"context": []
}
};
pseudoEvents.push(pseudoFailEvent);
for (index = 0; index < ancestors.length; index++) {
if (ancestors[index].type === EVENT_SUITE_BEGIN) {
// supplement a suite end event
const pseudoSuiteEndEvent = {
"type": EVENT_SUITE_END,
"stats": { ...this.mEvents[this.mEvents.current].stats },
"timings": {
"enqueue": Date.now(),
"write": Date.now()
},
"arg": {
"_bail": false,
"$$fullTitle": ancestors[index].arg.$$fullTitle,
"$$isPending": false,
"root": ancestors[index].arg.root,
"title": ancestors[index].arg.title,
"__mocha_id__": ancestors[index].arg.__mocha_id__,
"parent": ancestors[index].arg.parent
? { ...ancestors[index].arg.parent }
: ancestors[index].arg.parent
}
};
pseudoEvents.push(pseudoSuiteEndEvent);
}
else if (ancestors[index].type === EVENT_RUN_BEGIN) {
// supplement an end event
const now = (new Date()).toISOString();
const pseudoEndEvent = {
"type": EVENT_RUN_END,
"stats": Object.assign(Object.assign({}, this.mEvents[this.mEvents.current].stats), {
"end": now,
"duration": 0
}),
"timings": {
"enqueue": Date.now()
},
"arg": {
"$$fulltitle": ""
}
};
pseudoEvents.push(pseudoEndEvent);
}
else {
console.error(`Session.supplementFailOnAbort: invalid acnestor type ${ancestors[index].type}`);
}
}
this.mEvents.splice(this.mEvents.length, 0, ...pseudoEvents);
this.mEvents.current = this.mEvents.top;
}
else {
console.error('Session.supplementFailOnAbort: no valid ancestors found');
}
return true;
}
static generatePseudoNextPhaseSessionOnReadyTimeout(dispatcher, error) {
const phase = dispatcher.suiteParameters.phase;
if (!(typeof phase === 'number' && phase >= 1)) {
throw new Error(`Dispatcher.generatePseudoNextPhaseSessionOnReadyTimeout: phase ${phase} must be >= 1`);
}
const lastSession = dispatcher.constructor.suites[dispatcher.suite.suiteIndex].sessions[phase - 1];
let lastNonPendingEventIndex = -1;
let lastSuiteBeginIndex = -1;
for (let i = 0; i < lastSession.mEvents.length; i++) {
switch (lastSession.mEvents[i].type) {
case EVENT_RUN_BEGIN:
case EVENT_RUN_END:
break;
case EVENT_SUITE_BEGIN:
lastSuiteBeginIndex = i;
break;
case EVENT_SUITE_END:
break;
case EVENT_TEST_PASS:
case EVENT_TEST_FAIL:
lastNonPendingEventIndex = i;
break;
case EVENT_TEST_PENDING:
break;
default:
break;
}
}
if (lastNonPendingEventIndex < 0) {
lastNonPendingEventIndex = lastSuiteBeginIndex;
}
if (lastNonPendingEventIndex < 0) {
throw new Error(`Session.generatePseudoNextPhaseSessionOnReadyTimeout: no last non-pending test found`);
}
const session = new Session(dispatcher);
for (let i = 0; i < lastSession.mEvents.length; i++) {
if (i != lastNonPendingEventIndex + 1) {
switch (lastSession.mEvents[i].type) {
case EVENT_RUN_BEGIN:
case EVENT_RUN_END:
case EVENT_SUITE_BEGIN:
case EVENT_SUITE_END:
case EVENT_TEST_PENDING:
default:
session.mEvents[i] = JSON.parse(JSON.stringify(lastSession.mEvents[i], null, 0));
break;
case EVENT_TEST_PASS:
case EVENT_TEST_FAIL:
session.mEvents[i] = JSON.parse(JSON.stringify(lastSession.mEvents[i], null, 0));
session.mEvents[i].type = EVENT_TEST_PENDING;
break;
}
}
else {
switch (lastSession.mEvents[i].type) {
case EVENT_RUN_BEGIN:
case EVENT_RUN_END:
case EVENT_SUITE_BEGIN:
case EVENT_SUITE_END:
case EVENT_TEST_PASS:
case EVENT_TEST_FAIL:
default:
session.mEvents[i] = JSON.parse(JSON.stringify(lastSession.mEvents[i], null, 0));
break;
case EVENT_TEST_PENDING:
session.mEvents[i] = JSON.parse(JSON.stringify(lastSession.mEvents[i], null, 0));
session.mEvents[i].type = EVENT_TEST_FAIL;
session.mEvents[i].err = session.mEvents[i].arg.err = {
"$$toString": error.toString(),
"message": error.message,
"stack": error.stack,
};
break;
}
}
}
session.state = STATE_STOPPED;
session.phaseState = SESSION_PHASE_STATE_FINAL;
session.lastInRun = lastSession.lastInRun;
session.lastInScope = lastSession.lastInScope;
session.dispatcher = dispatcher;
session.aggregator = dispatcher.constructor.aggregator;
return session;
}
static generatePseudoPhase0SessionOnReadyTimeout(dispatcher, error) {
const phase = dispatcher.suiteParameters
? dispatcher.suiteParameters.phase
: 0;
if (!(typeof phase === 'number' && phase === 0)) {
throw new Error(`Dispatcher.generatePseudoPhase0SessionOnReadyTimeout: phase ${phase} must be 0`);
}
const session = new Session(dispatcher);
const date = new Date();
const now = date.valueOf();
const nowISOString = date.toISOString();
const reporterURL = new URL(Config.reporterURL);
const configPath = (reporterURL.hash.substring(1) || '/test/reportage.config.js').split('?')[0];
const url_Root = `${reporterURL.origin}${reporterURL.pathname}${reporterURL.search}#${configPath}`;
const url_Scope = url_Root + `?scope=${encodeURIComponent(dispatcher.suite.scope)}`;
const rootSuiteMochaID = session.createPseudoMochaId();
const scopeSuiteMochaID = session.createPseudoMochaId();
const scopeTitle = Suite.scopes[dispatcher.suite.scope].description || dispatcher.suite.scope + ' suite';
const testIndex = dispatcher.suite.testIndex;
const tests = dispatcher.suite.tests.split(',');
const suiteClasses = tests.map(_test => Suite.scopes[dispatcher.suite.scope].classes[_test]);
const suiteTitles = suiteClasses.map(_class =>
Object.getOwnPropertyDescriptor(_class.prototype, 'description')
? _class.prototype.description
: _class.prototype.uncamel(Suite._name(_class)));
const url_Tests = tests.map(_test => url_Scope + `&testIndex=${encodeURIComponent(testIndex)}&testClass=${encodeURIComponent(_test)}`);
const suiteMochaIDs = tests.map(_test => session.createPseudoMochaId());
const mEvents = [
{
"type": EVENT_RUN_BEGIN,
"stats": {
"suites": 0,
"tests": 0,
"passes": 0,
"pending": 0,
"failures": 0,
"start": nowISOString
},
"timings": {
"enqueue": now,
"write": now
},
"arg": {}
},
{
"type": EVENT_SUITE_BEGIN,
"stats": {
"suites": 0,
"tests": 0,
"passes": 0,
"pending": 0,
"failures": 0,
"start": nowISOString,
},
"timings": {
"enqueue": now,
"write": now
},
"arg": {
"_bail": false,
"$$fullTitle": "",
"$$isPending": false,
"root": true,
"title": "",
"__mocha_id__": rootSuiteMochaID,
"parent": null,
"context": [
{
"title": "suiteURL",
"value": url_Root
}
]
}
},
{
"type": EVENT_SUITE_BEGIN,
"stats": {
"suites": 0,
"tests": 0,
"passes": 0,
"pending": 0,
"failures": 0,
"start": nowISOString
},
"timings": {
"enqueue": now,
"write": now
},
"arg": {
"_bail": false,
"$$fullTitle": scopeTitle,
"$$isPending": false,
"root": false,
"title": scopeTitle,
"__mocha_id__": scopeSuiteMochaID,
"parent": {
"__mocha_id__": rootSuiteMochaID
},
"context": [
{
"title": "suiteURL",
"value": url_Scope
}
]
}
},
...suiteClasses.map((_testClass, index) => [
{
"type": EVENT_SUITE_BEGIN,
"stats": {
"suites": 0,
"tests": 0,
"passes": 0,
"pending": 0,
"failures": 0,
"start": nowISOString
},
"timings": {
"enqueue": now,
"write": now
},
"arg": {
"_bail": false,
"$$fullTitle": scopeTitle + " " + suiteTitles[index],
"$$isPending": false,
"root": false,
"title": suiteTitles[index],
"__mocha_id__": suiteMochaIDs[index],
"parent": {
"__mocha_id__": scopeSuiteMochaID
},
"context": [
{
"title": "suiteURL",
"value": url_Tests[index]
}
]
}
},
...[...new _testClass().scenario()].filter(step => step.operation || step.checkpoint).map((step, stepIndex) => {
if (stepIndex === 0) {
return {
"type": EVENT_TEST_FAIL,
"stats": {
"suites": 0,
"tests": 0,
"passes": 0,
"pending": 0,
"failures": 0,
"start": nowISOString
},
"timings": {
"enqueue": now,
"write": now
},
"arg": {
"$$currentRetry": 0,
"$$fullTitle": scopeTitle + ' ' + suiteTitles[index] + step.name,
"$$isPending": false,
"$$retriedTest": null,
"$$slow": 0,
"$$titlePath": [
scopeTitle,
suiteTitles[index],
step.name
],
"body": step.ctor.toString(),
"duration": 0,
"err": {
"$$toString": error.toString(),
"message": error.message,
"stack": error.stack
},
"parent": {
"$$fullTitle": scopeTitle + " " + suiteTitles[index],
"__mocha_id__": suiteMochaIDs[index]
},
"state": "failed",
"title": step.name,
"type": "test",
"file": null,
"__mocha_id__": session.createPseudoMochaId(),
"context": []
}
};
}
else {
return {
"type": EVENT_TEST_PENDING,
"stats": {
"suites": 0,
"tests": 0,
"passes": 0,
"pending": 1,
"failures": 0,
"start": nowISOString
},
"timings": {
"enqueue": now,
"write": now
},
"arg": {
"$$currentRetry": 0,
"$$fullTitle": scopeTitle + ' ' + suiteTitles[index] + step.name,
"$$isPending": true,
"$$retriedTest": null,
"$$slow": 0,
"$$titlePath": [
scopeTitle,
suiteTitles[index],
step.name
],
"body": step.ctor.toString(),
"duration": 0,
"err": {
"$$toString": step.name,
"actual": "",
"expected": "",
"message": step.name,
"operator": "",
"showDiff": "",
"stack": ""
},
"parent": {
"$$fullTitle": scopeTitle + " " + suiteTitles[index],
"__mocha_id__": suiteMochaIDs[index]
},
"state": "pending",
"title": step.name,
"type": "test",
"file": null,
"__mocha_id__": session.createPseudoMochaId(),
"context": []
}
};
}
}),
{
"type": EVENT_SUITE_END,
"stats": {
"suites": 0,
"tests": 0,
"passes": 0,
"pending": 0,
"failures": 0,
"start": nowISOString
},
"timings": {
"enqueue": now,
"write": now
},
"arg": {
"_bail": false,
"$$fullTitle": scopeTitle + " " + suiteTitles[index],
"$$isPending": false,
"root": false,
"title": suiteTitles[index],
"__mocha_id__": suiteMochaIDs[index],
"parent": {
"__mocha_id__": scopeSuiteMochaID
},
"context": []
}
},
]).reduce((acc, curr) => { acc = [...acc, ...curr]; return acc; }, []),
{
"type": EVENT_SUITE_END,
"stats": {
"suites": 0,
"tests": 0,
"passes": 0,
"pending": 0,
"failures": 0,
"start": nowISOString
},
"timings": {
"enqueue": now,
"write": now
},
"arg": {
"_bail": false,
"$$fullTitle": scopeTitle,
"$$isPending": false,
"root": false,
"title": scopeTitle,
"__mocha_id__": scopeSuiteMochaID,
"parent": {
"__mocha_id__": rootSuiteMochaID
},
"context": []
}
},
{
"type": EVENT_SUITE_END,
"stats": {
"suites": 0,
"tests": 0,
"passes": 0,
"pending": 0,
"failures": 0,
"start": nowISOString,
},
"timings": {
"enqueue": now,
"write": now
},
"arg": {
"_bail": false,
"$$fullTitle": "",
"$$isPending": false,
"root": true,
"title": "",
"__mocha_id__": rootSuiteMochaID,
"parent": null,
"context": []
}
},
{
"type": EVENT_RUN_END,
"stats": {
"suites": 0,
"tests": 0,
"passes": 0,
"pending": 0,
"failures": 0,
"start": nowISOString
},
"timings": {
"enqueue": now,
"write": now
},
"arg": {
"$$fulltitle": ""
}
}
];
for (let mEvent of mEvents) {
session.mEvents.push(mEvent);
}
session.state = STATE_STOPPED;
session.phaseState = SESSION_PHASE_STATE_FINAL;
session.lastInRun = dispatcher.suite.lastInRun;
session.lastInScope = dispatcher.suite.lastInScope;
session.dispatcher = dispatcher;
session.aggregator = dispatcher.constructor.aggregator;
return session;
}
}
class Aggregator {
constructor(suites, Suite, reporterOptions) {
this.suites = suites;
this.buffer = new AggregationBuffer();
this.mEventQueue = [];
this.alias = {};
this.sessionSuiteMap = new WeakMap();
this.initializeIndices();
this.initializeStats(Suite);
this.setupReceiverRunner(reporterOptions);
}
initializeIndices() {
this.suiteIndex = -1;
this.sessionIndex = -1;
this.mEventIndex = -1;
}
initializeStats(Suite) {
const counters = {
describe: 0,
it: 0,
before: 0,
after: 0,
};
const context = {
describe: (name, cb) => {
counters.describe++;
cb();
},
it: (name, cb) => {
counters.it++;
},
before: (name, cb) => {
counters.before++;
},
after: (name, cb) => {
counters.after++;
},
}
const pseudoSearchParamsInHash = new URL(location.hash.substring(1), Config.reporterOrigin).searchParams;
const targetScope = pseudoSearchParamsInHash.get('scope');
const targetTestIndexRaw = pseudoSearchParamsInHash.get('testIndex');
const targetTestIndex = targetTestIndexRaw ? parseInt(targetTestIndexRaw) : -1;
const targetTestClass = pseudoSearchParamsInHash.get('testClass');
for (let scope in Suite.scopes) {
if (Suite.scopes[scope][SUITE_HTML] === SUITE_COMMON) {
continue; // skip running common suites
}
if (targetScope && scope !== targetScope) {
continue; // skip non-target scope
}
const testList = Suite.scopes[scope].test;
for (let index = 0; index < testList.length; index++) {
const tests = testList[index];
const classList = tests.split(',');
let testFound = false;
for (let i = 0; i < classList.length; i++) {
const _class = Suite.scopes[scope].classes[classList[i]];
if (_class && (_class.prototype.operation || _class.prototype.checkpoint)) {
testFound = true;
break;
}
}
if (!testFound) {
continue; // skip empty tests
}
if (targetTestIndexRaw) {
if (index !== targetTestIndex) {
continue; // skip non-target testClass index
}
}
Suite.scopes[scope].run(index, undefined, context);
}
}
this.total = counters.it;
this.stats = {
suites: counters.describe,
tests: 0,
passes: 0,
failures: 0,
pending: 0,
start: new Date(),
};
}
setupReceiverRunner(reporterOptions = {}) {
this.receiverRunner = new ReceiverRunner(ReportageHTML);
/*
{ runner: { total: num, stats: { } }, options: { reporterOptions: {} } }
*/
this.receiverRunner.onOpen({
runner: {
total: this.total,
stats: Object.assign({}, this.stats),
},
options: {
reporterOptions: reporterOptions,
},
});
}
log(eventType = 'log') {
console.log(`${this.constructor.name}.${eventType}: suiteIndex=${this.suiteIndex}, sessionIndex=${this.sessionIndex}, mEventIndex=${this.mEventIndex}`);
console.log(`Buffer: top=${this.buffer.top}, current=${this.buffer.current}, done=${this.buffer.done}`, this.buffer.map(item => item.type));
}
enqueueMochaEvent(mEvent) {
console.log(`${this.constructor.name}.enqueueMochaEvent: mEvent`, mEvent);
//this.onMochaEvent(mEvent); // do not enqueue
this.mEventQueue.push(mEvent);
}
flushMochaEvents() {
let mEvent;
while (mEvent = this.mEventQueue.shift()) {
console.log(`${this.constructor.name}.flushMochaEvents: mEvent`, mEvent);
try {
this.onMochaEvent(mEvent);
}
catch (e) {
// TODO: handle errors
console.error(`${this.constructor.name}.flushMochaEvents: exception in onMochaEvent`, e, mEvent)
}
}
}
proceed(callerMethod) {
if (this.proceeding) {
console.warn(`${this.constructor.name}.proceed: skipping ${callerMethod} since proceeding ${this.proceeding}`)
return;
}
else {
this.proceeding = callerMethod;
}
if (!this.done && callerMethod === 'onLogTotalSuites' && this.suites.totalSuites == 0) {
console.log(`${this.constructor.name}.proceed:${callerMethod}: totalSuites is zero ${this.suites.totalSuites}`);
this.done = true;
let mEvent;
mEvent = ({ type: EVENT_RUN_BEGIN, arg: { } });
console.log(`${this.constructor.name}.proceed: Send ${mEvent.type}`);
this.buffer.push(mEvent);
this.buffer.current = this.buffer.top;
this.enqueueMochaEvent(mEvent);
mEvent = ({ type: EVENT_RUN_END, arg: { }, stats: { passes: 0, pending: 0, failures: 0, duration: 0 } });
if (window.__coverage__) {
mEvent.arg.__coverage__ = [];
}
console.log(`${this.constructor.name}.proceed: Send ${mEvent.type}`);
this.buffer.push(mEvent);
this.buffer.current = this.buffer.top;
this.enqueueMochaEvent(mEvent);
mEvent = ({ type: AGGREGATION_EVENT_RUN_FINAL, totalSuites: 0 });
console.log(`${this.constructor.name}.proceed: Send ${mEvent.type}`);
this.enqueueMochaEvent(mEvent);
this.flushMochaEvents();
return;
}
callerMethod = `${this.constructor.name}.proceed:${callerMethod}:`;
let buf = {};
let _suiteIndex = this.suiteIndex;
let _sessionIndex = this.sessionIndex;
let _mEventIndex = this.mEventIndex;
let continuous = true;
if (this.suites.length < 1) {
if (continuous) {
continuous = false;
}
}
let initialSuiteIndex =
this.suiteIndex < 0
? 0
: this.suiteIndex;
for (let suiteIndex = initialSuiteIndex; continuous && suiteIndex < this.suites.length; suiteIndex++) {
let suite = this.suites[suiteIndex];
let _suite = {};
let _suiteFinished = false;
buf[suiteIndex] = _suite;
if (!suite.sessions) {
if (continuous) {
continuous = false;
_suiteIndex = suiteIndex;
_sessionIndex = -1;
_mEventIndex = -1;
}
continue;
}
let initialSessionIndex =
suiteIndex === this.suiteIndex &&
this.sessionIndex >= 0
? this.sessionIndex
: 0;
for (let sessionIndex = initialSessionIndex; continuous && sessionIndex < suite.sessions.length; sessionIndex++) {
let session = suite.sessions[sessionIndex];
let _session = [];
_suite[sessionIndex] = _session;
let __mEventIndex = -1;
let initialmEventIndex =
suiteIndex === this.suiteIndex &&
sessionIndex === this.sessionIndex &&
this.mEventIndex >= 0
? this.mEventIndex
: 0;
for (let mEventIndex = initialmEventIndex; continuous && mEventIndex < session.mEvents.length; mEventIndex++) {
let mEvent = session.mEvents[mEventIndex];
__mEventIndex = mEventIndex;
let { type } = mEvent;
this.log(type);
switch (type) {
case EVENT_RUN_BEGIN:
_session.push('Rb');
break;
case EVENT_RUN_END:
_session.push('Re');
break;
case EVENT_SUITE_BEGIN:
_session.push('Sb');
break;
case EVENT_SUITE_END:
_session.push('Se');
break;
case EVENT_TEST_PASS:
_session.push('Tp');
break;
case EVENT_TEST_FAIL:
_session.push('Tf');
break;
case EVENT_TEST_PENDING:
_session.push('Ts');
break;
default:
break;
}
if (suiteIndex === this.suiteIndex &&
sessionIndex === this.sessionIndex &&
mEventIndex === this.mEventIndex) {
continue; // already handled
}
console.log(`${this.constructor.name}.proceed: Send ${type} ${mEvent.arg.$$fullTitle || ''}`);
this.enqueueMochaEvent(mEvent);
} // loop for mEvents in a session
const status = this.sessionSuiteMap.get(session);
if (status && status.checked) {
if (continuous) {
switch (session.phaseState) {
case SESSION_PHASE_STATE_INITIAL:
case SESSION_PHASE_STATE_CONTINUING:
if (sessionIndex === suite.sessions.length - 1) {
continuous = false;
_session.push('*');
_suiteIndex = suiteIndex;
_sessionIndex = sessionIndex;
_mEventIndex = __mEventIndex;
}
break;
case SESSION_PHASE_STATE_FINAL:
_suiteFinished = true;
if (typeof this.suites.totalSuites === 'number' &&
suiteIndex === this.suites.totalSuites - 1) {
// last session of last suite
continuous = false;
_session.push('*');
_suiteIndex = suiteIndex;
_sessionIndex = sessionIndex;
_mEventIndex = __mEventIndex;
}
break;
} // session.phaseState