🏡 index : quine-global/webdriverio.git

import { expect, type MatcherContext, type ExpectationResult, type SyncExpectationResult } from 'expect'
import { MESSAGE_TYPES, type Workers } from '@wdio/types'
import { $ } from '@wdio/globals'
import type { ChainablePromiseElement } from 'webdriverio'

import { getCID } from './utils.js'
import { WDIO_EVENT_NAME } from '../constants.js'

declare type RawMatcherFn<Context extends MatcherContext = MatcherContext> = {
    (this: Context, actual: unknown, ...expected: Array<unknown>): ExpectationResult;
}

interface MatcherPayload {
    resolve: (result: SyncExpectationResult) => void
    commandTimeout: NodeJS.Timeout
}

const asymmetricMatcher =
    typeof Symbol === 'function' && Symbol.for
        ? Symbol.for('jest.asymmetricMatcher')
        : 0x13_57_a5

let matcherRequestCount = 0
const matcherRequests = new Map<number, MatcherPayload>()
const COMMAND_TIMEOUT = 30 * 1000 // 30s

/**
 * Matcher factory enables to run all matchers within the browser by sending all necessary information
 * to the worker process and execute the actual assertion in the Node.js environment.
 * @param matcherName name of the matcher
 * @returns a matcher result computed in the Node.js environment
 */
function createMatcher (matcherName: string) {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    return async function (this: MatcherContext, context: WebdriverIO.Browser | WebdriverIO.Element | ChainablePromiseElement | ChainablePromiseArray, ...args: any[]) {
        const cid = getCID()
        if (!import.meta.hot || !cid) {
            return {
                pass: false,
                message: () => 'Could not connect to testrunner'
            }
        }

        if (typeof args[0] === 'object' && '$$typeof' in args[0] && args[0].$$typeof === asymmetricMatcher && args[0].asymmetricMatch) {
            args[0] = {
                $$typeof: args[0].toString(),
                sample: args[0].sample,
                inverse: args[0].inverse
            }
        }

        const expectRequest: Workers.ExpectRequestEvent = {
            id: matcherRequestCount++,
            cid,
            scope: this,
            matcherName,
            args: args
        }

        const isContextObject = typeof context === 'object'

        /**
         * Check if context is an WebdriverIO.Element
         */
        if (isContextObject && 'selector' in context && 'selector' in context) {
            expectRequest.element = context
        }

        /**
         * Check if context is ChainablePromiseElement
         */
        if (isContextObject && 'then' in context && typeof (context as { selector: string }).selector === 'object') {
            expectRequest.element = await context
        }

        /**
         * Check if context is a `Element` and transform it into a WebdriverIO.Element
         */
        if (context instanceof Element) {
            expectRequest.element = await $(context as unknown as HTMLElement)
        } else if (isContextObject && !('sessionId' in context)) {
            /**
             * check if context is an object or promise and resolve it
             * but not pass through the browser object
             */
            expectRequest.context = context
            if ('then' in context) {
                expectRequest.context = await context
            }
        } else if (!isContextObject) {
            /**
             * if context is not an object or promise, pass it through
             */
            expectRequest.context = context
        }

        /**
         * Avoid serialization issues when sending over the element. If we create
         * an element from an existing HTMLElement, it might have custom properties
         * attached to it that can't be serialized.
         */
        if (expectRequest.element && typeof expectRequest.element.selector !== 'string') {
            expectRequest.element.selector = undefined
        }

        /**
         * pass along the stack trace from the browser to the testrunner so that
         * the snapshot tool can determine the correct location to update the
         * snapshot call.
         */
        if (matcherName === 'toMatchInlineSnapshot') {
            expectRequest.scope.errorStack = (new Error('inline snapshot error'))
                .stack
                ?.split('\n')
                .find((line) => line.includes(window.__wdioSpec__))
                /**
                 * stack traces within the browser have an url path, e.g.
                 * `http://localhost:8080/@fs/path/to/__tests__/unit/snapshot.test.js:123:45`
                 * that we want to remove so that the stack trace is properly
                 * parsed by Vitest, e.g. make it to:
                 * `/__tests__/unit/snapshot.test.js:123:45`
                 */
                ?.replace(/http:\/\/localhost:\d+/g, '')
                .replace('/@fs/', '/')
        }

        import.meta.hot.send(WDIO_EVENT_NAME, { type: MESSAGE_TYPES.expectRequestMessage, value: expectRequest })
        const contextString = isContextObject
            ? 'elementId' in context
                ? 'WebdriverIO.Element'
                : 'WebdriverIO.Browser'
            : context

        return new Promise<SyncExpectationResult>((resolve, reject) => {
            const commandTimeout = setTimeout(
                () => reject(new Error(`Assertion expect(${contextString}).${matcherName}(...) timed out`)),
                COMMAND_TIMEOUT
            )

            matcherRequests.set(expectRequest.id, { resolve, commandTimeout })
        })
    }
}

/**
 * request all available matchers from the testrunner
 */
import.meta.hot?.send(WDIO_EVENT_NAME, { type: MESSAGE_TYPES.expectMatchersRequest })

/**
 * listen on assertion results from testrunner
 */
import.meta.hot?.on(WDIO_EVENT_NAME, (message: Workers.SocketMessage) => {
    /**
     * Set up `expect-webdriverio` matchers for the browser environment.
     * Every assertion is send to the testrunner via a websocket connection
     * and is executed in the Node.js environment. This allows us to enable
     * matchers that require Node.js specific modules like `fs` or `child_process`,
     * for visual regression or snapshot testing for example.
     *
     * The testrunner will send a list of available matchers to the browser
     * since there might services or other hooks that add custom matchers.
     */
    if (message.type === MESSAGE_TYPES.expectMatchersResponse) {
        const matchers = message.value.matchers.reduce((acc, matcherName) => {
            acc[matcherName] = createMatcher(matcherName)
            return acc
        }, {} as Record<string, RawMatcherFn<MatcherContext>>)
        expect.extend(matchers)
    }

    if (message.type !== MESSAGE_TYPES.expectResponseMessage) {
        return
    }

    const payload = matcherRequests.get(message.value.id)
    if (!payload) {
        return console.warn(`Couldn't find payload for assertion result with id ${message.value.id}`)
    }

    clearTimeout(payload.commandTimeout)
    matcherRequests.delete(message.value.id)
    payload.resolve({
        pass: message.value.pass,
        message: () => message.value.message
    })
})

export { expect }