UNPKG

reportage

Version:

Scenarist-wrapped mocha sessions on browsers to any reporters

1,430 lines (1,406 loc) 130 kB
/* @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