/* eslint-disable @typescript-eslint/no-explicit-any */ import safeStringify from 'safe-stringify' import { setupEnv, formatMessage } from '@wdio/mocha-framework/common' import { MESSAGE_TYPES, type Workers } from '@wdio/types' import { getCID, filterTestArgument } from '../utils.js' import { EVENTS, WDIO_EVENT_NAME } from '../../constants.js' const startTime = Date.now() if (!window.Mocha) { throw new Error('Can\'t find Mocha attached to the `window` scope, was it installed? Run `npm install mocha` and run again!') } const BaseReporter = window.Mocha.reporters.html class HTMLReporter extends BaseReporter { constructor (runner: never, options: never) { /** * this is a little hack to ensure Mocha attaches the HTML reporter * to the element within the Shadow DOM */ const getElementById = document.getElementById.bind(document) // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain document.getElementById = () => document.querySelector('mocha-framework')?.shadowRoot?.querySelector('#mocha')! super(runner, options) document.getElementById = getElementById } addCodeToggle () {} } export class MochaFramework extends HTMLElement { #root: ShadowRoot #spec: string #require: string[] #hookResolver = new Map() #runnerEvents: any[] = [] #isMinified = false constructor () { super() this.#root = this.attachShadow({ mode: 'open' }) this.#spec = this.getAttribute('spec')! this.#require = window.__wdioEnv__.args.require || [] delete window.__wdioEnv__.args.require if (!this.#spec) { throw new Error('"spec" attribute required but not set') } mocha.setup({ ...window.__wdioEnv__.args, reporter: HTMLReporter } as any) } static get observedAttributes() { return ['minified'] } get spec () { return this.#spec } connectedCallback() { this.#root.appendChild(template.content.cloneNode(true)) this.#root.querySelector('.btnCollapseExpand')?.addEventListener('click', () => { if (this.#isMinified) { this.shadowRoot!.host.removeAttribute('minified') } else { this.shadowRoot!.host.setAttribute('minified', 'minified') } }) } attributeChangedCallback (name: string, oldValue: unknown, newValue: unknown) { if (name === 'minified') { this.#isMinified = typeof newValue === 'string' const reporterElem = this.#root.querySelector('.reporter') if (reporterElem) { reporterElem.className = this.#isMinified ? 'reporter minified' : 'reporter' } document.body.style.width = `calc(100% - ${this.#isMinified ? '65px' : '500px'})` } } async run () { const globalTeardownScripts: Function[] = [] const globalSetupScripts: Function[] = [] for (const r of this.#require) { const { mochaGlobalSetup, mochaGlobalTeardown } = (await import(/* @vite-ignore */r)) || {} if (typeof mochaGlobalSetup === 'function') { globalSetupScripts.push(mochaGlobalSetup) } if (typeof mochaGlobalTeardown === 'function') { globalTeardownScripts.push(mochaGlobalTeardown) } } const cid = getCID() if (!cid) { throw new Error('"cid" query parameter is missing') } const beforeHook = this.#getHook('beforeHook') const beforeTest = this.#getHook('beforeTest') const afterHook = this.#getHook('afterHook') const afterTest = this.#getHook('afterTest') setupEnv(cid, window.__wdioEnv__.args, beforeTest, beforeHook, afterTest, afterHook) /** * import test case (order is important here) */ const file = this.#spec await import(/* @vite-ignore */file) /** * run setup scripts */ for (const setupScript of globalSetupScripts) { await setupScript() } /** * listen on socket events from testrunner */ import.meta.hot?.on(WDIO_EVENT_NAME, this.#handleSocketMessage.bind(this)) const self = this const mochaBeforeHook = globalThis.before || globalThis.suiteSetup mochaBeforeHook(async function () { const { title, tests, pending, delayed } = this.test?.parent?.suites[0] || {} await self.#getHook('beforeSuite')({ ...({ title, tests, pending, delayed }), file, }) }) const mochaAfterHook = globalThis.after || globalThis.suiteTeardown mochaAfterHook(async function () { const { title, tests, pending, delayed } = this.test?.parent?.suites[0] || {} await self.#getHook('afterSuite')({ ...({ title, tests, pending, delayed }), file, duration: Date.now() - startTime }) }) /** * remove loader animation */ const spinner = this.#root.querySelector('.lds-ring') if (spinner) { spinner.remove() } const runner = mocha.run(async (failures) => { await this.#onFinish(failures) for (const teardownScript of globalTeardownScripts) { await teardownScript() } }) Object.entries(EVENTS).map(([mochaEvent, wdioEvent]) => runner.on(mochaEvent, (payload: any) => { this.#runnerEvents.push(formatMessage({ type: wdioEvent, payload, err: payload.err })) })) } async #onFinish (failures: number) { await this.#getHook('after')(failures, window.__wdioEnv__.capabilities, [this.#spec]) /** * propagate results to browser so it can be picked up by the runner */ this.#sendTestReport({ failures, events: this.#runnerEvents }) console.log(`[WDIO] Finished test suite in ${Date.now() - startTime}ms`) } #handleSocketMessage (message: Workers.SocketMessage) { if (message.type === MESSAGE_TYPES.hookResultMessage) { return this.#handleHookResult(message.value) } } #handleHookResult (result: Workers.HookResultEvent) { const resolver = this.#hookResolver.get(result.id) if (!resolver) { return console.warn(`[WDIO] couldn't find resolve for id "${result.id}"`) } this.#hookResolver.delete(result.id) if (result.error) { return resolver.reject(result.error) } return resolver.resolve() } #getHook (name: string) { return (...args: any[]) => new Promise((resolve, reject) => { const id = this.#hookResolver.size + 1 const cid = getCID() if (!cid) { return reject(new Error('"cid" query parameter is missing')) } this.#hookResolver.set(id, { resolve, reject }) /** * filter large objects from args otherwise stringifying it to send it over * to the Node.js process may result in slow performance */ args = args.map((arg) => { if (typeof arg !== 'object') { return arg } // Check for error and convert error class to serializable Object. if ('error' in arg && arg.error instanceof Error) { const errorObject = { // Pull all enumerable properties, supporting properties on custom Errors ...arg.error, // Explicitly pull Error's non-enumerable properties message: arg.error.message, name: arg.error.name, stack: arg.error.stack, type: arg.error.type || arg.error.name, matcherResult: arg.error.matcherResult, expected: arg.error.expected, actual: arg.error.actual } return { ...arg, error: errorObject } } return { ...filterTestArgument(arg, this.#spec), ...('type' in arg && 'title' in arg ? { parent: arg.parent } : {}), ...('test' in arg && arg.test ? { test: filterTestArgument(arg.test, this.#spec) } : {}), ...('currentTest' in arg && arg.currentTest ? { currentTest: filterTestArgument(arg.currentTest, this.#spec) } : {}) } }) import.meta.hot?.send(WDIO_EVENT_NAME, this.#hookTrigger({ name, id, cid, args })) }) } #hookTrigger (value: Workers.HookTriggerEvent): Workers.SocketMessage { return { type: MESSAGE_TYPES.hookTriggerMessage, value: JSON.parse(safeStringify(value)) } } #sendTestReport (value: Workers.BrowserTestResults) { import.meta.hot?.send(WDIO_EVENT_NAME, { type: MESSAGE_TYPES.browserTestResult, value: JSON.parse(safeStringify(value)) }) } } const template = document.createElement('template') template.innerHTML = /*html*/`
` customElements.define('mocha-framework', MochaFramework)