x-test.js (1,351 lines of code) (raw):

/** * Simple assertion which throws exception when not "ok". * assert('foo' === 'bar', 'foo does not equal bar'); */ export const assert = (ok, text) => XTestSuite.assert(suiteContext, ok, text); /** * Register coverage percentage goal for a given file. * coverage('../foo.js', 87); */ export const coverage = (href, goal) => XTestSuite.coverage(suiteContext, href, goal); /** * Force test suite registration to remain open until promise resolves. * const barsPromise = fetch('https://foo/api/v2/bars').then(response => response.json()); * waitFor(barsPromise); */ export const waitFor = promise => XTestSuite.waitFor(suiteContext, promise); /** * Register a test to be run as a subsequent test suite. * test('./test-sibling.html'); */ export const test = href => XTestSuite.test(suiteContext, href); /** * Register a grouping. Alternatively, mark with flags. */ export const describe = (text, callback) => XTestSuite.describe(suiteContext, text, callback); describe.skip = (text, callback) => XTestSuite.describeSkip(suiteContext, text, callback); describe.only = (text, callback) => XTestSuite.describeOnly(suiteContext, text, callback); describe.todo = (text, callback) => XTestSuite.describeTodo(suiteContext, text, callback); /** * Register an individual test lint. Alternatively, mark with flags. */ export const it = (text, callback, interval) => XTestSuite.it(suiteContext, text, callback, interval); it.skip = (text, callback, interval) => XTestSuite.itSkip(suiteContext, text, callback, interval); it.only = (text, callback, interval) => XTestSuite.itOnly(suiteContext, text, callback, interval); it.todo = (text, callback, interval) => XTestSuite.itTodo(suiteContext, text, callback, interval); // Internal Interface. This is exposed for testing purposes only. export { XTestRoot as __XTestRoot__, XTestReporter as __XTestReporter__, XTestTap as __XTestTap__, XTestSuite as __XTestSuite__ }; // https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript function uuid() { return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c => (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16) ); } function publish(type, data) { top.postMessage({ type, data }, '*'); } function subscribe(callback) { top.addEventListener('message', callback); } function addErrorListener(callback) { addEventListener('error', callback); } function addUnhandledrejectionListener(callback) { addEventListener('unhandledrejection', callback); } async function timeout(interval) { interval = interval ?? 30000; await new Promise((resolve, reject) => { setTimeout(() => { reject(new Error(`timeout after ${interval}ms`)); }, interval); }); } const styleSheet = new CSSStyleSheet(); styleSheet.replaceSync(` :host { display: flex; flex-direction: column; justify-content: flex-end; position: fixed; z-index: 1; left: 0; bottom: 0; width: 100vw; height: 400px; background-color: var(--black); max-height: 100vh; min-height: var(--header-height); font-family: monospace; --header-height: 40px; --black: #111111; --white: white; --subdued: #8C8C8C; --version: #39CCCC; --todo: #FF851B; --todone: #FFDC00; --skip: #FF851B; --ok: #2ECC40; --not-ok: #FF4136; --subtest: #4D4D4D; --plan: #39CCCC; --link: #0085ff; } :host(:not([open])) { max-height: var(--header-height); } #header { display: flex; justify-content: space-between; align-items: center; height: var(--header-height); flex-shrink: 0; box-shadow: inset 0 -1px 0 0 #484848, 0 1px 2px 0 #484848; padding-right: 38px; background-color: var(--x-test-reporter-background-color); } :host([open]) #header { cursor: grab; } :host([open][dragging]) #header { cursor: grabbing; } #toggle { position: fixed; bottom: 7px; right: 12px; font: inherit; margin: 0; border: none; border-radius: 4px; display: flex; align-items: center; justify-content: center; height: 26px; width: 26px; cursor: pointer; --color: var(--subdued); color: var(--color); background-color: transparent; box-shadow: inset 0 0 0 1px var(--color); } #toggle:hover, #toggle:focus-visible { --color: var(--white); } #toggle:active { --color: var(--subdued); } #toggle::before { content: "↑"; } :host([open]) #toggle::before { content: "↓"; } #result { margin: auto 12px; padding: 6px 16px; border-radius: 4px; line-height: 14px; color: var(--white); background-color: var(--todo); user-select: none; pointer-events: none; white-space: nowrap; } #result::before { content: "TESTING..."; } :host(:not([ok])) #result { background-color: var(--not-ok); } :host(:not([ok])) #result::before { content: "NOT OK!"; } :host([ok]:not([testing])) #result { background-color: var(--ok); } :host([ok]:not([testing])) #result::before { content: "OK!"; } #tag-line { margin: auto 12px; color: var(--subdued); cursor: default; user-select: none; pointer-events: none; } #body { flex: 1; overflow: auto; display: flex; /* Flip top/bottom for console-like scroll behavior. */ flex-direction: column-reverse; box-sizing: border-box; } :host([dragging]) #body { pointer-events: none; } #spacer { flex: 1; } [output] { white-space: pre; color: var(--subdued); line-height: 20px; padding: 0 12px; cursor: default; } [output]:first-child { padding-top: 12px; } [output]:last-child { padding-bottom: 12px; } [yaml] { line-height: 16px; } a[output]:any-link { display: block; width: min-content; cursor: pointer; } [it][ok]:not([directive]) { color: var(--ok); } [it]:not([ok]):not([directive]), [bail] { color: var(--not-ok); } [it][ok][directive="skip"] { color: var(--skip); } [it]:not([ok])[directive="todo"] { color: var(--todo); } [it][ok][directive="todo"] { color: var(--todone); } [plan][indent], [plan] + [it][ok]:not([directive]), [plan] + [it]:not([ok]):not([directive]), [plan] + [it][ok][directive="skip"], [plan] + [it]:not([ok])[directive="todo"], [plan] + [it][ok][directive="todo"] { color: var(--subdued); } [version] { color: var(--version); } [plan] { color: var(--plan); } a[subtest]:not([bail]) { color: var(--link); } [subtest]:not([bail]) { color: var(--subdued); } [indent] { position: relative; } [indent]::before { position: absolute; content: attr(indent); color: var(--subdued); opacity: 0.25; } `); const template = document.createElement('template'); template.setHTMLUnsafe(` <div id="header"><div id="result"></div><div id="tag-line">x-test - a simple, tap-compliant test runner for the browser.</div></div> <div id="body"><div id="spacer"></div><div id="container"></div></div> <button id="toggle" type="button"></button> `); class XTestReporter extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this.shadowRoot.adoptedStyleSheets = [styleSheet]; this.shadowRoot.append(template.content.cloneNode(true)); } connectedCallback() { this.setAttribute('ok', ''); this.setAttribute('testing', ''); this.style.height = localStorage.getItem('x-test-reporter-height'); if (localStorage.getItem('x-test-reporter-closed') !== 'true') { this.setAttribute('open', ''); } this.shadowRoot.getElementById('toggle').addEventListener('click', () => { this.hasAttribute('open') ? this.removeAttribute('open') : this.setAttribute('open', ''); localStorage.setItem('x-test-reporter-closed', String(!this.hasAttribute('open'))); }); const resize = event => { const nextHeaderY = event.clientY - Number(this.getAttribute('dragging')); const currentHeaderY = this.shadowRoot.getElementById('header').getBoundingClientRect().y; const currentHeight = this.getBoundingClientRect().height; this.style.height = `${Math.round(currentHeight + currentHeaderY - nextHeaderY)}px`; localStorage.setItem('x-test-reporter-height', this.style.height); }; this.shadowRoot.getElementById('header').addEventListener('pointerdown', event => { if (this.hasAttribute('open')) { const headerY = this.shadowRoot.getElementById('header').getBoundingClientRect().y; const clientY = event.clientY; this.setAttribute('dragging', String(clientY - headerY)); addEventListener('pointermove', resize); for (const iframe of document.querySelectorAll('iframe')) { iframe.style.pointerEvents = 'none'; } } }); addEventListener('pointerup', () => { removeEventListener('pointermove', resize); this.removeAttribute('dragging'); for (const iframe of document.querySelectorAll('iframe')) { iframe.style.pointerEvents = null; } }); } tap(...tap) { const items = []; const container = this.shadowRoot.getElementById('container'); for (const text of tap) { const { tag, properties, attributes, failed, done } = XTestReporter.parse(text); const element = document.createElement(tag); Object.assign(element, properties); for (const [attribute, value] of Object.entries(attributes)) { element.setAttribute(attribute, value); } if (done) { this.removeAttribute('testing'); } if (failed) { this.removeAttribute('ok'); } items.push(element); } container.append(...items); } static parse(text) { const result = { tag: '', properties: {}, attributes: {}, failed: false, done: false }; result.properties.innerText = text; const indentMatch = text.match(/^((?: {4})+)/); if (indentMatch) { const lines = text.split('\n').length - 1; const indent = indentMatch[1].replace(/ {4}/g, '\u00a6 '); result.attributes.indent = lines ? `${`${indent}\n`.repeat(lines)}${indent}` : indent; } if (text.match(/^(?: {4})*# Subtest: https?:.*/)) { result.tag = 'a'; const href = text.replace(/^(?: {4})*# Subtest: /, ''); result.properties.href = href; Object.assign(result.attributes, { output: '', subtest: '' }); } else if (text.match(/^Bail out! https?:.*/)) { result.tag = 'a'; result.failed = true; const href = text.replace(/Bail out! /, ''); result.properties.href = href; Object.assign(result.attributes, { output: '', subtest: '', bail: '' }); } else { result.tag = 'div'; result.attributes.output = ''; if (text.match(/^(?: {4})*# Subtest:/)) { result.attributes.subtest = ''; } else if (text.match(/^(?: {4})*# /)) { result.attributes.diagnostic = ''; } else if (text.match(/^(?: {4})*ok /)) { Object.assign(result.attributes, { it: '', ok: '' }); if (text.match(/^(?: {4})*[^ #][^#]* # SKIP/)) { result.attributes.directive = 'skip'; } else if (text.match(/^(?: {4})*[^ #][^#]* # TODO/)) { result.attributes.directive = 'todo'; } } else if (text.match(/^(?: {4})*not ok /)) { result.attributes.it = ''; if (text.match(/^(?: {4})*[^ #][^#]* # TODO/)) { result.attributes.directive = 'todo'; } else { result.failed = true; } } else if (text.match(/^(?: {4})* {2}---/)) { result.attributes.yaml = ''; } else if (text.match(/^TAP/)) { result.attributes.version = ''; } else if (text.match(/^(?: {4})*1\.\.\d*/)) { result.attributes.plan = ''; if (!indentMatch) { result.done = true; } } else if (text.match(/^(?: {4})*Bail out!.*/)) { result.attributes.bail = ''; result.failed = true; } } return result; } } customElements.define('x-test-reporter', XTestReporter); class XTestRoot { static initialize(context, href) { const url = new URL(href); if (!url.searchParams.get('x-test-no-reporter')) { context.state.reporter = new XTestReporter(); document.body.append(context.state.reporter); } context.state.coverage = url.searchParams.get('x-test-run-coverage') === ''; context.state.coverageValuePromise = new Promise(resolve => { context.state.resolveCoverageValuePromise = value => { context.state.coverageValue = value; resolve(context.state.coverageValue); }; }); const versionStepId = context.uuid(); const exitStepId = context.uuid(); context.state.stepIds.push(versionStepId, exitStepId); context.state.steps[versionStepId] = { stepId: versionStepId, type: 'version', status: 'waiting' }; context.state.steps[exitStepId] = { stepId: exitStepId, type: 'exit', status: 'waiting' }; context.subscribe(event => { switch (event.data.type) { case 'x-test-client-ping': XTestRoot.onPing(context, event); break; case 'x-test-client-coverage-result': XTestRoot.onCoverageResult(context, event); break; case 'x-test-suite-register': XTestRoot.onRegister(context, event); break; case 'x-test-suite-ready': XTestRoot.onReady(context, event); break; case 'x-test-suite-result': XTestRoot.onResult(context, event); break; case 'x-test-suite-bail': XTestRoot.onBail(context, event); break; } XTestRoot.check(context); }); // Run own tests in iframe. url.searchParams.delete('x-test-no-reporter'); url.searchParams.delete('x-test-run-coverage'); context.publish('x-test-suite-register', { type: 'test', testId: context.uuid(), href: url.href }); } static onPing(context/*, event*/) { context.publish('x-test-root-pong', { ended: context.state.ended, waiting: context.state.waiting }); } static onBail(context, event) { if (!context.state.ended) { XTestRoot.bail(context, event.data.data.error, { testId: event.data.data.testId }); } } static registerTest(context, data) { if (!context.state.ended) { const testId = data.testId; // New "test" (to be opened in its own iframe). Queue it up. const initiatorTestId = data.initiatorTestId; const siblingTestEndIndex = context.state.stepIds.findLastIndex(candidateId => { const candidate = context.state.steps[candidateId]; if (candidate.type === 'test-end' && context.state.tests[candidate.testId].initiatorTestId === initiatorTestId) { return true; } }); const parentTestEndIndex = context.state.stepIds.findLastIndex(candidateId => { const candidate = context.state.steps[candidateId]; if (candidate.type === 'test-end' && context.state.tests[candidate.testId].testId === initiatorTestId) { return true; } }); const coverageIndex = context.state.stepIds.findIndex(candidateId => { const candidate = context.state.steps[candidateId]; if (candidate.type === 'coverage') { return true; } }); const exitIndex = context.state.stepIds.findLastIndex(candidateId => { const candidate = context.state.steps[candidateId]; if (candidate.type === 'exit') { return true; } }); const index = siblingTestEndIndex === -1 ? parentTestEndIndex === -1 ? coverageIndex === -1 ? exitIndex : coverageIndex : parentTestEndIndex + 1 : siblingTestEndIndex + 1; const lastSiblingChildrenIndex = context.state.children.findLastIndex(candidate => { return candidate.type === 'test' && context.state.tests[candidate.testId].initiatorTestId === initiatorTestId; }); const parentTestChildrenIndex = context.state.children.findLastIndex(candidate => { return candidate.type === 'test' && context.state.tests[candidate.testId].testId === initiatorTestId; }); const firstCoverageChildrenIndex = context.state.children.findIndex(candidate => { return candidate.type === 'coverage'; }); const childrenIndex = lastSiblingChildrenIndex === -1 ? parentTestChildrenIndex === -1 ? firstCoverageChildrenIndex === -1 ? context.state.children.length : firstCoverageChildrenIndex : parentTestChildrenIndex + 1 : lastSiblingChildrenIndex + 1; const testStartStepId = context.uuid(); const testPlanStepId = context.uuid(); const testEndStepId = context.uuid(); context.state.stepIds.splice(index, 0, testStartStepId, testPlanStepId, testEndStepId); context.state.steps[testStartStepId] = { stepId: testStartStepId, type: 'test-start', testId, status: 'waiting' }; context.state.steps[testPlanStepId] = { stepId: testPlanStepId, type: 'test-plan', testId, status: 'waiting' }; context.state.steps[testEndStepId] = { stepId: testEndStepId, type: 'test-end', testId, status: 'waiting' }; context.state.tests[testId] = { ...data, children: [] }; context.state.children.splice(childrenIndex, 0, { type: 'test', testId }); } } static registerDescribeStart(context, data) { if (!context.state.ended) { // New "describe-start" (to mark the start of a subtest). Queue it up. const stepId = context.uuid(); const describeId = data.describeId; const index = context.state.stepIds.findLastIndex(candidateId => { const candidate = context.state.steps[candidateId]; if (candidate.type === 'test-plan' && candidate.testId === data.parents[0].testId) { return true; } }); context.state.stepIds.splice(index, 0, stepId); context.state.steps[stepId] = { stepId, type: 'describe-start', describeId: data.describeId, status: 'waiting' }; context.state.describes[describeId] = { ...data, children: [] }; if (data.parents.at(-1)?.type === 'describe') { context.state.describes[data.parents.at(-1).describeId].children.push({ type: 'describe', describeId }); } else { context.state.tests[data.parents.at(-1).testId].children.push({ type: 'describe', describeId }); } } } static registerDescribeEnd(context, data) { if (!context.state.ended) { // Completed "describe-end" (to mark the end of a subtest). Queue it up. const planStepId = context.uuid(); const endStepId = context.uuid(); const describe = context.state.describes[data.describeId]; // eslint-disable-line no-shadow const index = context.state.stepIds.findLastIndex(candidateId => { const candidate = context.state.steps[candidateId]; if (candidate.type === 'test-plan' && candidate.testId === describe.parents[0].testId) { return true; } }); context.state.stepIds.splice(index, 0, planStepId, endStepId); context.state.steps[planStepId] = { stepId: planStepId, type: 'describe-plan', describeId: data.describeId, status: 'waiting' }; context.state.steps[endStepId] = { stepId: endStepId, type: 'describe-end', describeId: data.describeId, status: 'waiting' }; } } static registerIt(context, data) { if (!context.state.ended) { // New "it" (to be run as part of a test suite). Queue it up. const stepId = context.uuid(); const itId = data.itId; const index = context.state.stepIds.findLastIndex(candidateId => { const candidate = context.state.steps[candidateId]; if (candidate.type === 'test-plan' && candidate.testId === data.parents[0].testId) { return true; } }); context.state.stepIds.splice(index, 0, stepId); context.state.steps[stepId] = { stepId, type: 'it', itId: data.itId, status: 'waiting' }; context.state.its[itId] = data; if (data.parents.at(-1)?.type === 'describe') { context.state.describes[data.parents.at(-1).describeId].children.push({ type: 'it', itId }); } else { context.state.tests[data.parents.at(-1).testId].children.push({ type: 'it', itId }); } } } static registerCoverage(context, data) { if (!context.state.ended) { // New "coverage" goal. Queue it up. const stepId = context.uuid(); const coverageId = data.coverageId; const index = context.state.stepIds.findLastIndex(candidateId => { const candidate = context.state.steps[candidateId]; if (candidate.type === 'exit') { return true; } }); context.state.stepIds.splice(index, 0, stepId); context.state.steps[stepId] = { stepId, type: 'coverage', coverageId: coverageId, status: 'waiting' }; context.state.coverages[coverageId] = data; const childrenIndex = context.state.children.length; context.state.children.splice(childrenIndex, 0, { type: 'coverage', coverageId }); } } static onRegister(context, event) { if (!context.state.ended) { const data = event.data.data; switch(data.type) { case 'test': XTestRoot.registerTest(context, data); break; case 'describe-start': XTestRoot.registerDescribeStart(context, data); break; case 'describe-end': XTestRoot.registerDescribeEnd(context, data); break; case 'it': XTestRoot.registerIt(context, data); break; case 'coverage': XTestRoot.registerCoverage(context, data); break; default: throw new Error(`Unexpected registration type "${data.type}".`); } } } static onReady(context, event) { if (!context.state.ended) { const data = event.data.data; const only = ( Object.values(context.state.its).some(candidate => { return candidate.only && candidate.parents[0].testId === data.testId; }) || Object.values(context.state.describes).some(candidate => { return candidate.only && candidate.parents[0].testId === data.testId; }) ); if (only) { for (const it of Object.values(context.state.its)) { // eslint-disable-line no-shadow if (it.parents[0].testId === data.testId) { if (!it.only) { const describeParents = it.parents .filter(candidate => candidate.type === 'describe') .map(parent => context.state.describes[parent.describeId]); const hasOnlyDescribeParent = describeParents.some(candidate => candidate.only); if (!hasOnlyDescribeParent) { it.directive = 'SKIP'; } else if (!it.directive) { const lastDescribeParentWithDirective = describeParents.findLast(candidate => !!candidate.directive); if (lastDescribeParentWithDirective) { it.directive = lastDescribeParentWithDirective.directive; } } } } } } else { for (const it of Object.values(context.state.its)) { // eslint-disable-line no-shadow if (it.parents[0].testId === data.testId) { if (!it.directive) { const describeParents = it.parents .filter(candidate => candidate.type === 'describe') .map(parent => context.state.describes[parent.describeId]); const lastDescribeParentWithDirective = describeParents.findLast(candidate => !!candidate.directive); if (lastDescribeParentWithDirective) { it.directive = lastDescribeParentWithDirective.directive; } } } } } const stepId = context.state.stepIds.find(candidateId => { const candidate = context.state.steps[candidateId]; return candidate.type === 'test-start' && candidate.testId === data.testId; }); const step = context.state.steps[stepId]; if (step.status !== 'running') { throw new Error('test to ready is not running'); } const href = XTestRoot.href(context, stepId); const level = XTestRoot.level(context, stepId); const tap = XTestTap.subtest(href, level); XTestRoot.output(context, stepId, tap); step.status = 'done'; } } static onResult(context, event) { if (!context.state.ended) { const data = event.data.data; const it = context.state.its[data.itId]; // eslint-disable-line no-shadow const stepId = context.state.stepIds.find(candidateId => { const candidate = context.state.steps[candidateId]; return candidate.type === 'it' && candidate.itId === it.itId; }); const step = context.state.steps[stepId]; if (step.status !== 'running') { throw new Error('step to complete is not running'); } Object.assign(it, { ok: data.ok, error: data.error }); step.status = 'done'; const ok = XTestRoot.ok(context, stepId); const number = XTestRoot.number(context, stepId); const text = XTestRoot.text(context, stepId); const directive = XTestRoot.directive(context, stepId); const level = XTestRoot.level(context, stepId); const tap = XTestTap.testLine(ok, number, text, directive, level); if (!data.error) { XTestRoot.output(context, stepId, tap); } else { const yaml = XTestRoot.yaml(context, stepId); const errorTap = XTestTap.yaml(yaml.message, yaml.severity, yaml.data, level); XTestRoot.output(context, stepId, tap, errorTap); } } } static onCoverageResult(context, event) { if (!context.state.ended) { context.state.resolveCoverageValuePromise(event.data.data); } } static kickoffVersion(context, stepId) { const tap = XTestTap.version(); XTestRoot.output(context, stepId, tap); context.state.steps[stepId].status = 'done'; } static kickoffDescribeStart(context, stepId) { const level = XTestRoot.level(context, stepId); const text = XTestRoot.text(context, stepId); const tap = XTestTap.subtest(text, level); XTestRoot.output(context, stepId, tap); context.state.steps[stepId].status = 'done'; } static kickoffDescribePlan(context, stepId) { const level = XTestRoot.level(context, stepId); const count = XTestRoot.count(context, stepId); const tap = XTestTap.plan(count, level); XTestRoot.output(context, stepId, tap); context.state.steps[stepId].status = 'done'; } static kickoffDescribeEnd(context, stepId) { const number = XTestRoot.number(context, stepId); const ok = XTestRoot.ok(context, stepId); const text = XTestRoot.text(context, stepId); const directive = XTestRoot.directive(context, stepId); const level = XTestRoot.level(context, stepId); const tap = XTestTap.testLine(ok, number, text, directive, level); XTestRoot.output(context, stepId, tap); context.state.steps[stepId].status = 'done'; } static kickoffTestStart(context, stepId) { // Destroy prior test. This keeps the final test around for debugging. const lastIframe = document.querySelector('iframe'); lastIframe?.remove(); // Create the new test. const step = context.state.steps[stepId]; const href = XTestRoot.href(context, stepId); const iframe = document.createElement('iframe'); iframe.addEventListener('error', () => { const error = new Error(`Failed to load ${href}`); XTestRoot.bail(context, error); }); iframe.setAttribute('data-x-test-test-id', step.testId); Object.assign(iframe, { src: href }); Object.assign(iframe.style, { border: 'none', backgroundColor: 'white', height: '100vh', width: '100vw', position: 'fixed', zIndex: '0', top: '0', left: '0', }); document.body.append(iframe); step.status = 'running'; } static kickoffTestPlan(context, stepId) { const count = XTestRoot.count(context, stepId); const level = XTestRoot.level(context, stepId); const tap = XTestTap.plan(count, level); XTestRoot.output(context, stepId, tap); context.state.steps[stepId].status = 'done'; } static kickoffTestEnd(context, stepId) { const number = XTestRoot.number(context, stepId); const ok = XTestRoot.ok(context, stepId); const text = XTestRoot.text(context, stepId); const directive = XTestRoot.directive(context, stepId); const level = XTestRoot.level(context, stepId); const tap = XTestTap.testLine(ok, number, text, directive, level); XTestRoot.output(context, stepId, tap); context.state.steps[stepId].status = 'done'; } static kickoffIt(context, stepId) { const step = context.state.steps[stepId]; const { itId, directive, interval } = context.state.its[step.itId]; context.publish('x-test-root-run', { itId, directive, interval }); step.status = 'running'; } static kickoffCoverage(context, stepId) { const step = context.state.steps[stepId]; const coverage = context.state.coverages[step.coverageId]; // eslint-disable-line no-shadow if (context.state.coverageValue) { try { const analysis = XTestRoot.analyzeHrefCoverage(context.state.coverageValue.js, coverage.href, coverage.goal); Object.assign(coverage, { ok: analysis.ok, percent: analysis.percent, output: analysis.output }); } catch (error) { Object.assign(coverage, { ok: false, percent: 0, output: '' }); XTestRoot.bail(context, error); } } else { Object.assign(coverage, { ok: true, percent: 0, output: '', directive: 'SKIP' }); } const ok = XTestRoot.ok(context, stepId); const number = XTestRoot.number(context, stepId); const text = XTestRoot.text(context, stepId); const directive = XTestRoot.directive(context, stepId); const level = XTestRoot.level(context, stepId); const tap = XTestTap.testLine(ok, number, text, directive, level); if (!ok) { const errorTap = XTestTap.diagnostic(coverage.output, level); XTestRoot.output(context, stepId, tap, errorTap); } else { XTestRoot.output(context, stepId, tap); } step.status = 'done'; } static kickoffExit(context, stepId) { const count = XTestRoot.count(context, stepId); const tap = XTestTap.plan(count); XTestRoot.output(context, stepId, tap); context.state.steps[stepId].status = 'done'; XTestRoot.end(context); } static requestCoverageValue(context) { context.state.waiting = true; Promise.race([context.state.coverageValuePromise, context.timeout(5000)]) .then(() => { XTestRoot.check(context); }) .catch(error => { XTestRoot.bail(context, error); }); context.publish('x-test-root-coverage-request'); } static check(context) { if (!context.state.ended) { // Look to see if any tests are running. const runningStepId = context.state.stepIds.find(candidateId => { return context.state.steps[candidateId].status === 'running'; }); if (!runningStepId) { // If nothing's running, find the first step that's waiting and run that. const stepId = context.state.stepIds.find(candidateId => { return context.state.steps[candidateId].status === 'waiting'; }); if (stepId) { const waitingStep = context.state.steps[stepId]; switch (waitingStep.type) { case 'version': XTestRoot.kickoffVersion(context, stepId); XTestRoot.check(context); break; case 'describe-start': XTestRoot.kickoffDescribeStart(context, stepId); XTestRoot.check(context); break; case 'describe-plan': XTestRoot.kickoffDescribePlan(context, stepId); XTestRoot.check(context); break; case 'describe-end': XTestRoot.kickoffDescribeEnd(context, stepId); XTestRoot.check(context); break; case 'test-start': XTestRoot.kickoffTestStart(context, stepId); XTestRoot.check(context); break; case 'test-plan': XTestRoot.kickoffTestPlan(context, stepId); XTestRoot.check(context); break; case 'test-end': XTestRoot.kickoffTestEnd(context, stepId); XTestRoot.check(context); break; case 'it': XTestRoot.kickoffIt(context, stepId); XTestRoot.check(context); break; case 'coverage': if (!context.state.coverage || context.state.coverageValue) { XTestRoot.kickoffCoverage(context, stepId); XTestRoot.check(context); } else if (!context.state.waiting) { XTestRoot.requestCoverageValue(context); } break; case 'exit': XTestRoot.kickoffExit(context, stepId); break; default: throw new Error(`Unexpected step type "${waitingStep.type}".`); } } } } } static bail(context, error, options) { if (!context.state.ended) { if (error && error.stack) { XTestRoot.log(context, XTestTap.diagnostic(error.stack)); } if (options?.testId) { const test = context.state.tests[options.testId]; // eslint-disable-line no-shadow test.error = error; const href = test.href; XTestRoot.log(context, XTestTap.bailOut(href)); } else { XTestRoot.log(context, XTestTap.bailOut()); } XTestRoot.end(context); } } static log(context, ...tap) { for (const line of tap) { console.log(line); // eslint-disable-line no-console } context.state.reporter?.tap(...tap); } static output(context, stepId, ...stepTap) { const lastIndex = context.state.stepIds.findIndex(candidateId => { const candidate = context.state.steps[candidateId]; return !candidate.tap; }); context.state.steps[stepId].tap = stepTap; const index = context.state.stepIds.findIndex(candidateId => { const candidate = context.state.steps[candidateId]; return !candidate.tap; }); if (lastIndex !== index) { let tap; if (index === -1) { // We're done! tap = context.state.stepIds.slice(lastIndex).map(targetId => context.state.steps[targetId].tap); } else { tap = context.state.stepIds.slice(lastIndex, index).map(targetId => context.state.steps[targetId].tap); } XTestRoot.log(context, ...tap.flat()); } } static childOk(context, child, options) { switch (child.type) { case 'test': return context.state.tests[child.testId].children.every(candidate => XTestRoot.childOk(context, candidate, options)); case 'describe': return context.state.describes[child.describeId].children.every(candidate => XTestRoot.childOk(context, candidate, options)); case 'it': return context.state.its[child.itId].ok || options?.todoOk && context.state.its[child.itId].directive === 'TODO'; case 'coverage': return context.state.coverages[child.coverageId].ok; default: throw new Error(`Unexpected type "${child.type}".`); } } static ok(context, stepId) { const step = context.state.steps[stepId]; switch (step.type) { case 'test-end': return XTestRoot.childOk(context, { type: 'test', testId: step.testId }, { todoOk: true }); case 'describe-end': return XTestRoot.childOk(context, { type: 'describe', describeId: step.describeId }, { todoOk: true }); case 'it': return XTestRoot.childOk(context, { type: 'it', itId: step.itId }); case 'coverage': return XTestRoot.childOk(context, { type: 'coverage', coverageId: step.coverageId }); default: throw new Error(`Unexpected type "${step.type}".`); } } static number(context, stepId) { const step = context.state.steps[stepId]; switch (step.type) { case 'it': { const it = context.state.its[step.itId]; // eslint-disable-line no-shadow const parentChildren = it.parents.at(-1)?.type === 'describe' ? context.state.describes[it.parents.at(-1).describeId].children : context.state.tests[it.parents.at(-1).testId].children; const index = parentChildren.findIndex(candidate => candidate.itId === it.itId); return index + 1; } case 'describe-end': { const describe = context.state.describes[step.describeId]; // eslint-disable-line no-shadow const parentChildren = describe.parents.at(-1)?.type === 'describe' ? context.state.describes[describe.parents.at(-1).describeId].children : context.state.tests[describe.parents.at(-1).testId].children; const index = parentChildren.findIndex(candidate => candidate.describeId === describe.describeId); return index + 1; } case 'test-end': { const test = context.state.tests[step.testId]; // eslint-disable-line no-shadow const index = context.state.children.findIndex(candidate => candidate.testId === test.testId); return index + 1; } case 'coverage': { const coverage = context.state.coverages[step.coverageId]; // eslint-disable-line no-shadow const index = context.state.children.findIndex(candidate => candidate.coverageId === coverage.coverageId); return index + 1; } default: throw new Error(`Unexpected type "${step.type}".`); } } static text(context, stepId) { // The regex-replace prevents usage of the special `#` character which is // meaningful in TAP. It's overly-conservative now — it could be less // restrictive in the future. const step = context.state.steps[stepId]; switch (step.type) { case 'test-end': return context.state.tests[step.testId].href; case 'describe-start': case 'describe-end': return context.state.describes[step.describeId].text.replace(/#/g, '*'); case 'it': return context.state.its[step.itId].text.replace(/#/g, '*'); case 'coverage': { const coverage = context.state.coverages[step.coverageId]; // eslint-disable-line no-shadow return `${coverage.goal}% coverage goal for ${coverage.href} (got ${coverage.percent.toFixed(2)}%)`; } default: throw new Error(`Unexpected type "${step.type}".`); } } static href(context, stepId) { const step = context.state.steps[stepId]; switch (step.type) { case 'test-start': case 'test-end': return context.state.tests[step.testId].href; default: throw new Error(`Unexpected type "${step.type}".`); } } static directive(context, stepId) { const step = context.state.steps[stepId]; switch (step.type) { case 'describe-end': case 'test-end': return null; case 'it': return context.state.its[step.itId].directive; case 'coverage': return context.state.coverages[step.coverageId].directive; default: throw new Error(`Unexpected type "${step.type}".`); } } static level(context, stepId) { const step = context.state.steps[stepId]; switch (step.type) { case 'test-plan': return 1; case 'test-start': case 'test-end': case 'coverage': return 0; case 'describe-plan': return context.state.describes[step.describeId].parents.length + 1; case 'describe-start': case 'describe-end': return context.state.describes[step.describeId].parents.length; case 'it': return context.state.its[step.itId].parents.length; default: throw new Error(`Unexpected type "${step.type}".`); } } static count(context, stepId) { const step = context.state.steps[stepId]; switch (step.type) { case 'test-plan': return context.state.tests[step.testId].children.length; case 'describe-plan': return context.state.describes[step.describeId].children.length; case 'exit': return context.state.children.length; default: throw new Error(`Unexpected type "${step.type}".`); } } static yaml(context, stepId) { const step = context.state.steps[stepId]; switch (step.type) { case 'it': { const it = context.state.its[step.itId]; // eslint-disable-line no-shadow const { ok, directive, error } = it; const yaml = { message: 'ok', severity: 'comment', data: {} }; if (ok) { if (directive === 'SKIP') { yaml.message = 'skip'; } else if (directive === 'TODO') { yaml.message = 'todo'; } } else { if (directive === 'TODO') { yaml.message = error && error.message ? error.message : 'todo'; yaml.severity = 'todo'; } else { yaml.message = error && error.message ? error.message : 'fail'; yaml.severity = 'fail'; } if (error && error.stack) { yaml.data.stack = error.stack; } } return yaml; } default: throw new Error(`Unexpected type "${step.type}".`); } } static end(context) { context.state.ended = true; context.state.waiting = false; context.publish('x-test-root-end'); } static analyzeHrefCoverage(coverageValue, href, goal) { const set = new Set(); let text = ''; for (const item of coverageValue ?? []) { if (item.url === href) { text = item.text; for (const range of item.ranges) { for (let i = range.start; i < range.end; i++) { set.add(i); } } } } const ranges = []; const state = { used: set.has(0), start: 0 }; for (let index = 0; index < text.length; index++) { const used = set.has(index); if (used !== state.used) { ranges.push({ used: state.used, start: state.start, end: index }); Object.assign(state, { used, start: index }); } } ranges.push({ used: state.used, start: state.start, end: text.length }); let output = ''; let lineNumber = 1; for (const range of ranges) { let lines = text .slice(range.start, range.end) .split('\n') .map((line, iii) => lineNumber === 1 || iii > 0 ? `${String(lineNumber++ + (range.used ? '' : ' !')).padEnd(8, ' ')}| ${line}` : line); if (range.used) { if (lines.length > 3) { lines = [...lines.slice(0, 1), '\u2026', ...lines.slice(-1)]; } } else { if (lines.length > 5) { lines = [...lines.slice(0, 2), '\u2026', ...lines.slice(-2)]; } } output += range.used ? `${lines.join('\n')}` : `${lines.join('\n')}`; } const percent = set.size / text.length * 100; const ok = percent >= goal; return { ok, percent, output }; } } class XTestTap { static level(level) { level = level ?? 0; return ' '.repeat(level); } static version() { return 'TAP Version 14'; } static diagnostic(message, level) { return `${XTestTap.level(level)}# ${message.replace(/\n/g, `\n${XTestTap.level(level)}# `)}`; } static testLine(ok, number, description, directive, level) { description = description.replace(/\n/g, ' '); const okText = ok ? 'ok' : 'not ok'; const directiveText = directive ? ` # ${directive}` : ''; return `${XTestTap.level(level)}${okText} ${number} - ${description}${directiveText}`; } static subtest(name, level) { const text = `${XTestTap.level(level)}# Subtest: ${name}`; return text; } static yaml(message, severity, data, level) { let text = `${XTestTap.level(level)} ---`; text += `\n${XTestTap.level(level)} message: ${message.replace(/\n/g, ' ')}`; text += `\n${XTestTap.level(level)} severity: ${severity}`; if (data && data.stack) { text += `\n${XTestTap.level(level)} stack: |-`; text += `\n${XTestTap.level(level)} ${data.stack.replace(/\n/g, `\n${XTestTap.level(level)} `)}`; } text += `\n${XTestTap.level(level)} ...`; return text; } static bailOut(message) { return message ? `Bail out! ${message.replace(/\n/g, ` `)}` : 'Bail out!'; } static plan(number, level) { return `${XTestTap.level(level)}1..${number}`; } } class XTestSuite { static initialize(context, testId, href) { Object.assign(context.state, { testId, href }); context.state.parents.push({ type: 'test', testId }); context.subscribe(async event => { switch (event.data.type) { case 'x-test-suite-bail': XTestSuite.onBail(context, event); break; case 'x-test-root-run': XTestSuite.onRun(context, event); break; default: // Ignore — this message isn't for us. } }); // Setup global error / rejection handlers. context.addErrorListener(event => { event.preventDefault(); XTestSuite.bail(context, event.error); }); context.addUnhandledrejectionListener(event => { event.preventDefault(); XTestSuite.bail(context, event.reason); }); // Await a single microtask before we signal that we're ready. XTestSuite.waitFor(context, Promise.resolve()); } static onBail(context/*, event*/) { if (!context.state.bailed) { context.state.bailed = true; } } static async onRun(context, event) { if ( !context.state.bailed && context.state.callbacks[event.data.data.itId] ) { const { itId, directive, interval } = event.data.data; try { if (directive !== 'SKIP') { const callback = context.state.callbacks[itId]; await Promise.race([callback(), context.timeout(interval)]); } context.publish('x-test-suite-result', { itId, ok: true, error: null }); } catch (error) { error = XTestSuite.createError(error); // eslint-disable-line no-ex-assign context.publish('x-test-suite-result', { itId, ok: false, error }); } } } static bail(context, error) { if (!context.state.bailed) { context.state.bailed = true; context.publish( 'x-test-suite-bail', { testId: context.state.testId, error: XTestSuite.createError(error) } ); } } static createError(originalError) { const error = {}; if (originalError instanceof Error) { Object.assign(error, { message: originalError.message, stack: originalError.stack }); } else { error.message = String(originalError); } return error; } static assert(context, ok, text) { if (context && !context.state.bailed) { if (!ok) { throw new Error(text ?? 'not ok'); } } } static coverage(context, href, goal) { if (context && !context.state.bailed) { if (!(goal >= 0 && goal <= 100)) { throw new Error(`Unexpected goal percentage "${goal}".`); } const coverageId = context.uuid(); const url = new URL(href, context.state.href); context.publish('x-test-suite-register', { type: 'coverage', coverageId, href: url.href, goal }); } } static test(context, href) { if (context && !context.state.bailed && !context.state.ready) { const testId = context.uuid(); const testHref = new URL(href, context.state.href).href; const initiatorTestId = context.state.testId; context.publish('x-test-suite-register', { type: 'test', testId, initiatorTestId, href: testHref }); } } static #describerInner(context, text, callback, directive, only) { if (context && !context.state.bailed && !context.state.ready) { if (!(callback instanceof Function)) { throw new Error(`Unexpected callback value "${callback}".`); } const describeId = context.uuid(); const parents = [...context.state.parents]; directive = directive ?? null; only = only ?? false; context.publish( 'x-test-suite-register', { type: 'describe-start', describeId, parents, text, directive, only } ); try { context.state.parents.push({ type: 'describe', describeId }); callback(); context.state.parents.pop(); context.publish('x-test-suite-register', { type: 'describe-end', describeId }); } catch (error) { XTestSuite.bail(context, error); } } } static describe(context, text, callback) { XTestSuite.#describerInner(context, text, callback); } static describeSkip(context, text, callback) { XTestSuite.#describerInner(context, text, callback, 'SKIP'); } static describeOnly(context, text, callback) { XTestSuite.#describerInner(context, text, callback, null, true); } static describeTodo(context, text, callback) { XTestSuite.#describerInner(context, text, callback, 'TODO'); } static #itInner(context, text, callback, interval, directive, only) { if (context && !context.state.bailed && !context.state.ready) { if (!(callback instanceof Function)) { throw new Error(`Unexpected callback value "${callback}".`); } const itId = context.uuid(); const parents = [...context.state.parents]; interval = interval ?? null; directive = directive ?? null; only = only ?? false; context.state.callbacks[itId] = callback; context.publish( 'x-test-suite-register', { type: 'it', itId, parents, text, interval, directive, only } ); } } static it(context, text, callback, interval) { XTestSuite.#itInner(context, text, callback, interval); } static itSkip(context, text, callback, interval) { XTestSuite.#itInner(context, text, callback, interval, 'SKIP'); } static itOnly(context, text, callback, interval) { XTestSuite.#itInner(context, text, callback, interval, null, true); } static itTodo(context, text, callback, interval) { XTestSuite.#itInner(context, text, callback, interval, 'TODO'); } static async waitFor(context, promise) { if (context && !context.state.bailed) { if (!context.state.bailed) { const waitForId = context.uuid(); context.state.waitForId = waitForId; context.state.promises.push(promise); try { await Promise.all(context.state.promises); if (context.state.waitForId === waitForId) { context.state.ready = true; context.publish('x-test-suite-ready', { testId: context.state.testId }); } } catch (error) { XTestSuite.bail(context, error); } } } } } // There is one-and-only-one root. Either boot as root or child test. let suiteContext = null; if (!frameElement?.getAttribute('data-x-test-test-id')) { const state = { ended: false, waiting: false, children: [], stepIds: [], steps: {}, tests: {}, describes: {}, its: {}, coverage: false, coverages: {}, resolveCoverageValuePromise: null, coverageValuePromise: null, coverageValue: null, reporter: null, }; const rootContext = { state, uuid, publish, subscribe, timeout }; XTestRoot.initialize(rootContext, location.href); } else { const state = { testId: null, href: null, callbacks: {}, bailed: false, waitForId: null, ready: false, promises: [], parents: [], }; suiteContext = { state, uuid, publish, subscribe, timeout, addErrorListener, addUnhandledrejectionListener, }; XTestSuite.initialize(suiteContext, frameElement.getAttribute('data-x-test-test-id'), location.href); }