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)
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<number, { resolve: Function, reject: Function }>()
#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(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(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
}
if ('error' in arg && arg.error instanceof Error) {
const errorObject = {
...arg.error,
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 = `
<style>
@import "@wdio/browser-runner/third_party/mocha.css";
.reporter {
transition: width .3s;
box-shadow: -5px 0px 10px rgb(0 0 0 / 30%);
position: absolute;
top: 0;
right: 0;
width: 500px;
height: 100%;
margin: 0;
color: var(--mocha-color);
background-color: var(--mocha-bg-color);
background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iNjRweCIgaGVpZ2h0PSI2NHB4IiB2aWV3Qm94PSIwIDAgNjQgNjQiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8dGl0bGU+TG9nbyBSZWd1bGFyPC90aXRsZT4KICAgIDxnIGlkPSJMb2dvLVJlZ3VsYXIiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxyZWN0IGlkPSJSZWN0YW5nbGUiIGZpbGw9IiNFQTU5MDYiIHg9IjAiIHk9IjAiIHdpZHRoPSI2NCIgaGVpZ2h0PSI2NCIgcng9IjUiPjwvcmVjdD4KICAgICAgICA8cGF0aCBkPSJNOCwxNiBMOCw0OCBMNiw0OCBMNiwxNiBMOCwxNiBaIE00MywxNiBDNTEuODM2NTU2LDE2IDU5LDIzLjE2MzQ0NCA1OSwzMiBDNTksNDAuODM2NTU2IDUxLjgzNjU1Niw0OCA0Myw0OCBDMzQuMTYzNDQ0LDQ4IDI3LDQwLjgzNjU1NiAyNywzMiBDMjcsMjMuMTYzNDQ0IDM0LjE2MzQ0NCwxNiA0MywxNiBaIE0yNywxNiBMMTQuMTA2LDQ3Ljk5OTIwNzggTDExLjk5OSw0Ny45OTkyMDc4IEwyNC44OTQsMTYgTDI3LDE2IFogTTQzLDE4IEMzNS4yNjgwMTM1LDE4IDI5LDI0LjI2ODAxMzUgMjksMzIgQzI5LDM5LjczMTk4NjUgMzUuMjY4MDEzNSw0NiA0Myw0NiBDNTAuNzMxOTg2NSw0NiA1NywzOS43MzE5ODY1IDU3LDMyIEM1NywyNC4yNjgwMTM1IDUwLjczMTk4NjUsMTggNDMsMTggWiIgaWQ9IkNvbWJpbmVkLVNoYXBlIiBmaWxsPSIjRkZGRkZGIj48L3BhdGg+CiAgICA8L2c+Cjwvc3ZnPg==);
background-repeat: no-repeat;
background-size: 30px;
background-position: 15px 20px;
}
.lds-ring {
display: inline-block;
position: absolute;
top: calc(50% - 40px);
left: calc(50% - 40px);
width: 80px;
height: 80px;
}
.lds-ring div {
box-sizing: border-box;
display: block;
position: absolute;
width: 64px;
height: 64px;
margin: 8px;
border: 4px solid #EA5907;
border-radius: 50%;
animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: #EA5907 transparent transparent transparent;
}
.lds-ring div:nth-child(1) {
animation-delay: -0.45s;
}
.lds-ring div:nth-child(2) {
animation-delay: -0.3s;
}
.lds-ring div:nth-child(3) {
animation-delay: -0.15s;
}
@keyframes lds-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.reporter.minified {
width: 65px;
}
#mocha {
margin: 0;
}
ul#mocha-report {
transition: opacity .3s;
opacity: 1;
padding: 50px 15px 0 0;
}
.minified ul#mocha-report {
opacity: 0;
}
.minified .progress {
display: block;
float: none;
}
.minified #mocha-stats {
width: 50px;
padding-top: 60px;
}
.minified #mocha-stats li {
font-size: .9em;
}
@keyframes fadeOutIn {
0% { opacity: 1; }
20% { opacity: 0; }
100% { opacity: 1; }
}
.btnCollapseExpand {
display: block;
width: 50px;
position: absolute;
height: 50px;
bottom: 0;
right: 0;
transition: transform .3s;
transform: scale(1) translateX(-434px);
margin: 10px 8px;
background: transparent;
border: 0;
cursor: pointer;
}
.minified .btnCollapseExpand {
transform: scale(-1) translateX(0px);
animation-name: fadeOutIn;
animation-duration: .3s;
}
</style>
<div class="reporter">
<div id="mocha"></div>
<div class="lds-ring"><div></div><div></div><div></div><div></div></div>
<button class="btnCollapseExpand">
<svg width="50" height="40" viewBox="2 0 32 32" xmlns="http://www.w3.org/2000/svg">
<path fill="#fff" d="M13.11 29.113c7.243 0 13.113-5.871 13.113-13.113S20.353 2.887 13.11 2.887C5.868 2.887-.003 8.758-.003 16S5.868 29.113 13.11 29.113zm0-25.177c6.652 0 12.064 5.412 12.064 12.064S19.762 28.064 13.11 28.064C6.457 28.064 1.046 22.652 1.046 16S6.457 3.936 13.11 3.936z"/>
<path fill="#fff" d="m13.906 21.637.742.742L21.026 16l-6.378-6.379-.742.742 5.112 5.112H6.291v1.049h12.727z"/>
</svg>
</button>
</div>
`
customElements.define('mocha-framework', MochaFramework)