import url from 'node:url' import type { EventEmitter } from 'node:events' import Jasmine from 'jasmine' import logger from '@wdio/logger' import { wrapGlobalTestMethod, executeHooksWithArgs } from '@wdio/utils' import { _setGlobal } from '@wdio/globals' import type { Services, Capabilities } from '@wdio/types' import type { expect as wdioExpectImport, matchers as wdioMatchersImport, getConfig as wdioGetConfig } from 'expect-webdriverio' import JasmineReporter from './reporter.js' import { jestResultToJasmine } from './utils.js' import type { JasmineOpts as jasmineNodeOpts, ResultHandlerPayload, FrameworkMessage, FormattedMessage } from './types.js' const INTERFACES = { bdd: ['beforeAll', 'beforeEach', 'it', 'xit', 'fit', 'afterEach', 'afterAll'] } const EXPECT_ASYMMETRIC_MATCHERS = [ 'any', 'anything', 'arrayContaining', 'objectContaining', 'stringContaining', 'stringMatching', 'not', ] as const const TEST_INTERFACES = ['it', 'fit', 'xit'] const NOOP = function noop() { } const DEFAULT_TIMEOUT_INTERVAL = 60000 const FILE_PROTOCOL = 'file://' const log = logger('@wdio/jasmine-framework') type HooksArray = { [K in keyof Required]: Required[K][] } interface WebdriverIOJasmineConfig extends Omit, HooksArray { jasmineOpts: Omit } /** * Jasmine runner */ class JasmineAdapter { private _jasmineOpts: jasmineNodeOpts private _reporter: JasmineReporter private _totalTests = 0 private _hasTests = true private _lastTest?: unknown private _lastSpec?: unknown private _jrunner = new Jasmine({}) constructor( private _cid: string, private _config: WebdriverIOJasmineConfig, private _specs: string[], private _capabilities: Capabilities.ResolvedTestrunnerCapabilities, reporter: EventEmitter ) { this._jasmineOpts = Object.assign({ cleanStack: true }, ( this._config.jasmineOpts || // @ts-expect-error legacy option this._config.jasmineNodeOpts )) this._reporter = new JasmineReporter(reporter, { cid: this._cid, specs: this._specs, cleanStack: this._jasmineOpts.cleanStack, jasmineOpts: this._jasmineOpts }) this._hasTests = true this._jrunner.exitOnCompletion = false } async init() { const self = this const { jasmine } = this._jrunner // @ts-ignore outdated const jasmineEnv = jasmine.getEnv() this._specs.forEach((spec) => this._jrunner.addSpecFile( /** * as Jasmine doesn't support file:// formats yet we have to * remove it before adding it to Jasmine */ spec.startsWith(FILE_PROTOCOL) ? url.fileURLToPath(spec) : spec )) // @ts-ignore only way to hack timeout into jasmine jasmine.DEFAULT_TIMEOUT_INTERVAL = this._jasmineOpts.defaultTimeoutInterval || DEFAULT_TIMEOUT_INTERVAL jasmineEnv.addReporter(this._reporter) /** * Filter specs to run based on jasmineOpts.grep and jasmineOpts.invert */ jasmineEnv.configure({ specFilter: this._jasmineOpts.specFilter || this.customSpecFilter.bind(this), stopOnSpecFailure: Boolean(this._jasmineOpts.stopOnSpecFailure), failSpecWithNoExpectations: Boolean(this._jasmineOpts.failSpecWithNoExpectations), failFast: this._jasmineOpts.failFast, random: Boolean(this._jasmineOpts.random), seed: Boolean(this._jasmineOpts.seed), oneFailurePerSpec: Boolean( // depcrecated old property this._jasmineOpts.stopSpecOnExpectationFailure || this._jasmineOpts.oneFailurePerSpec ) }) /** * enable expectHandler */ jasmine.Spec.prototype.addExpectationResult = this.getExpectationResultHandler(jasmine) const hookArgsFn = (context: unknown): [unknown, unknown] => [{ ...(self._lastTest || {}) }, context] const emitHookEvent = ( fnName: string, eventType: string, ) => ( _test: never, _context: never, { error }: { error?: jasmine.FailedExpectation } = {}, ) => { const title = `"${fnName === 'beforeAll' ? 'before' : 'after'} all" hook` const hook = { id: '', start: new Date(), type: 'hook' as const, description: title, fullName: title, duration: null, properties: {}, passedExpectations: [], pendingReason: '', failedExpectations: [], deprecationWarnings: [], status: '', debugLogs: null, filename: '', ...(error ? { error } : {}) } this._reporter.emit('hook:' + eventType, hook) } /** * wrap commands */ INTERFACES.bdd.forEach((fnName) => { const isTest = TEST_INTERFACES.includes(fnName) const beforeHook = [...this._config.beforeHook] as ((test: unknown, context: unknown) => void)[] const afterHook = [...this._config.afterHook] /** * add beforeAll and afterAll hooks to reporter */ if (fnName.includes('All')) { beforeHook.push(emitHookEvent(fnName, 'start')) afterHook.push(emitHookEvent(fnName, 'end')) } wrapGlobalTestMethod( isTest, isTest ? this._config.beforeTest : beforeHook, hookArgsFn, isTest ? this._config.afterTest : afterHook, hookArgsFn, fnName, this._cid ) }) /** * for a clean stdout we need to avoid that Jasmine initializes the * default reporter */ Jasmine.prototype.configureDefaultReporter = NOOP /** * wrap Suite and Spec prototypes to get access to their data */ // @ts-ignore const beforeAllMock = jasmine.Suite.prototype.beforeAll // @ts-ignore jasmine.Suite.prototype.beforeAll = function (...args) { self._lastSpec = this.result beforeAllMock.apply(this, args) } const executeMock = jasmine.Spec.prototype.execute jasmine.Spec.prototype.execute = function (...args: unknown[]) { self._lastTest = this.result // @ts-ignore needs to be set to be compatible with what WebdriverIO expects self._lastTest.start = new Date().getTime() // @ts-ignore needs to be set to be compatible with what WebdriverIO expects self._lastTest.file = this.result.filename globalThis._wdioDynamicJasmineResultErrorList = this.result.failedExpectations globalThis._jasmineTestResult = this.result executeMock.apply(this, args) } return this } /** * We have to ensure that `@wdio/runner` and `@wdio/jasmine-framework` are using the same `expect` and `matchers` globals. * This is why we have the `@wdio/runner` package pass on these primitives to the jasmine framework so that we can use them * to setup the jasmine environment. * * @param wdioExpect - WebdriverIO expect * @param wdioMatchers - WebdriverIO matchers * @param getConfig - WebdriverIO getConfig */ async setupExpect( wdioExpect: typeof wdioExpectImport, wdioMatchers: typeof wdioMatchersImport, getConfig: typeof wdioGetConfig ) { const { jasmine } = this._jrunner // @ts-ignore outdated const jasmineEnv = jasmine.getEnv() /** * set up WebdriverIO matchers with Jasmine */ const expect = jasmineEnv.expectAsync const matchers = this.#setupMatchers(jasmine, wdioMatchers, getConfig) jasmineEnv.beforeAll(() => jasmineEnv.addAsyncMatchers(matchers)) /** * make Jasmine and WebdriverIOs expect global more compatible by attaching * support asymmetric matchers to the `expect` global */ for (const matcher of EXPECT_ASYMMETRIC_MATCHERS) { expect[matcher] = wdioExpect[matcher] } expect.not = wdioExpect.not _setGlobal('expect', expect, this._config.injectGlobals) /** * load environment */ await this._loadFiles() /** * overwrite Jasmine global expect with WebdriverIOs expect */ _setGlobal('expect', expect, this._config.injectGlobals) } async _loadFiles() { try { if (Array.isArray(this._jasmineOpts.requires)) { // @ts-ignore outdated types this._jrunner.addRequires(this._jasmineOpts.requires) } if (Array.isArray(this._jasmineOpts.helpers)) { this._jrunner.addMatchingHelperFiles(this._jasmineOpts.helpers) } // @ts-ignore outdated types await this._jrunner.loadRequires() await this._jrunner.loadHelpers() await this._jrunner.loadSpecs() // @ts-ignore outdated types this._grep(this._jrunner.env.topSuite()) this._hasTests = this._totalTests > 0 } catch (err) { log.warn( 'Unable to load spec files quite likely because they rely on `browser` object that is not fully initialized.\n' + '`browser` object has only `capabilities` and some flags like `isMobile`.\n' + 'Helper files that use other `browser` commands have to be moved to `before` hook.\n' + `Spec file(s): ${this._specs.join(',')}\n`, 'Error: ', err ) } } _grep (suite: jasmine.Suite) { // @ts-ignore outdated types suite.children.forEach((child) => { if (Array.isArray((child as jasmine.Suite).children)) { return this._grep(child as jasmine.Suite) } if (this.customSpecFilter(child)) { this._totalTests++ } }) } hasTests() { return this._hasTests } async run() { // @ts-expect-error this._jrunner.env.beforeAll(this.wrapHook('beforeSuite')) // @ts-expect-error this._jrunner.env.afterAll(this.wrapHook('afterSuite')) await this._jrunner.execute() const result = this._reporter.getFailedCount() await executeHooksWithArgs('after', this._config.after, [result, this._capabilities, this._specs]) return result } customSpecFilter (spec: jasmine.Spec) { const { grep, invertGrep } = this._jasmineOpts const grepMatch = !grep || spec.getFullName().match(new RegExp(grep)) !== null if (grepMatch === Boolean(invertGrep)) { // @ts-expect-error internal method if (typeof spec.pend === 'function') { // @ts-expect-error internal method spec.pend('grep') } return false } return true } /** * Hooks which are added as true Jasmine hooks need to call done() to notify async */ wrapHook (hookName: keyof Services.HookFunctions) { return () => executeHooksWithArgs( hookName, this._config[hookName], [this.prepareMessage(hookName)] ).catch((e: Error) => { log.info(`Error in ${hookName} hook: ${e.stack?.slice(7)}`) }) } prepareMessage (hookName: keyof Services.HookFunctions) { const params: FrameworkMessage = { type: hookName } switch (hookName) { case 'beforeSuite': case 'afterSuite': params.payload = Object.assign({ file: this._jrunner?.specFiles[0] }, this._lastSpec) break case 'beforeTest': case 'afterTest': params.payload = Object.assign({ file: this._jrunner?.specFiles[0] }, this._lastTest) break } return this.formatMessage(params) } formatMessage (params: FrameworkMessage) { const message: FormattedMessage = { type: params.type } if (params.payload) { message.title = params.payload.description message.fullName = params.payload.fullName || null message.file = params.payload.file if (params.payload.failedExpectations && params.payload.failedExpectations.length) { message.errors = params.payload.failedExpectations message.error = params.payload.failedExpectations[0] } if (params.payload.id && params.payload.id.startsWith('spec')) { message.parent = (this._lastSpec as jasmine.Spec)?.description message.passed = params.payload.failedExpectations.length === 0 } if (params.type === 'afterTest') { message.duration = new Date().getTime() - params.payload.start } if (typeof params.payload.duration === 'number') { message.duration = params.payload.duration } } return message } getExpectationResultHandler (jasmine: jasmine.Jasmine) { const { expectationResultHandler } = this._jasmineOpts const origHandler = jasmine.Spec.prototype.addExpectationResult if (typeof expectationResultHandler !== 'function') { return origHandler } return this.expectationResultHandler(origHandler) } expectationResultHandler (origHandler: Function) { const { expectationResultHandler } = this._jasmineOpts return function (this: jasmine.Spec, passed: boolean, data: ResultHandlerPayload) { try { expectationResultHandler!.call(this, passed, data) } catch (e: unknown) { /** * propagate expectationResultHandler error if actual assertion passed * but the custom handler decides to throw */ if (passed) { passed = false data = { passed, message: 'expectationResultHandlerError: ' + (e as Error).message, error: e as Error } } } return origHandler.call(this, passed, data) } } #transformMatchers (matchers: jasmine.CustomMatcherFactories) { return Object.entries(matchers).reduce((prev, [name, fn]) => { prev[name] = (util) => ({ compare: async (actual: T, expected: T, ...args: unknown[]) => fn(util).compare(actual, expected, ...args), negativeCompare: async (actual: T, expected: T, ...args: unknown[]) => { const { pass, message } = fn(util).compare(actual, expected, ...args) return { pass: !pass, message } } }) return prev }, {} as jasmine.CustomAsyncMatcherFactories) } #setupMatchers ( jasmine: jasmine.Jasmine, matchers: typeof wdioMatchersImport, getConfig: typeof wdioGetConfig ): jasmine.CustomAsyncMatcherFactories { /** * overwrite "jasmine.addMatchers" to be always async since the `expect` global we * have is the `expectAsync` from Jasmine, so we need to ensure that synchronous * matchers are added to `expectAsync` */ globalThis.jasmine.addMatchers = (matchers) => globalThis.jasmine.addAsyncMatchers(this.#transformMatchers(matchers)) // @ts-expect-error not exported in jasmine const syncMatchers: jasmine.CustomAsyncMatcherFactories = this.#transformMatchers(jasmine.matchers) const wdioMatchers: jasmine.CustomAsyncMatcherFactories = [...matchers.entries()].reduce((prev, [name, fn]) => { prev[name] = () => ({ async compare (...args: unknown[]) { const context = getConfig() const result = fn.apply({ ...context, isNot: false }, args) return jestResultToJasmine(result, false) }, async negativeCompare (...args: unknown[]) { const context = getConfig() const result = fn.apply({ ...context, isNot: true }, args) return jestResultToJasmine(result, true) } }) return prev }, {} as jasmine.CustomAsyncMatcherFactories) return { ...wdioMatchers, ...syncMatchers } } } const adapterFactory: { init?: Function } = {} adapterFactory.init = async function (...args: unknown[]) { // @ts-ignore pass along parameters const adapter = new JasmineAdapter(...args) const instance = await adapter.init() return instance } export default adapterFactory export { JasmineAdapter, adapterFactory } export * from './types.js' type jasmine = typeof Jasmine declare global { /** * Define a single spec. A spec should contain one or more expectations that test the state of the code. * A spec whose expectations all succeed will be passing and a spec with any failures will fail. * @param expectation Textual description of what this spec is checking * @param assertion Function that contains the code of your test. If not provided the test will be pending. * @param timeout Custom timeout for an async spec. * @param retries Custom retry count for this single spec (WebdriverIO specific) */ function it(expectation: string, assertion?: jasmine.ImplementationCallback, timeout?: number, retries?: number): void /** * A focused `it`. If suites or specs are focused, only those that are focused will be executed. * @param expectation Textual description of what this spec is checking * @param assertion Function that contains the code of your test. If not provided the test will be pending. * @param timeout Custom timeout for an async spec. * @param retries Custom retry count for this single spec (WebdriverIO specific) */ function fit(expectation: string, assertion?: jasmine.ImplementationCallback, timeout?: number, retries?: number): void /** * A temporarily disabled `it`. The spec will report as pending and will not be executed. * @param expectation Textual description of what this spec is checking * @param assertion Function that contains the code of your test. If not provided the test will be pending. * @param timeout Custom timeout for an async spec. * @param retries Custom retry count for this single spec (WebdriverIO specific) */ function xit(expectation: string, assertion?: jasmine.ImplementationCallback, timeout?: number, retries?: number): void /** * Run some shared setup before each of the specs in the describe in which it is called. * @param action Function that contains the code to setup your specs. * @param timeout Custom timeout for an async beforeEach. * @param retries Custom retry count for this single hook (WebdriverIO specific) */ function beforeEach(action: jasmine.ImplementationCallback, timeout?: number, retries?: number): void /** * Run some shared teardown after each of the specs in the describe in which it is called. * @param action Function that contains the code to teardown your specs. * @param timeout Custom timeout for an async afterEach. * @param retries Custom retry count for this single hook (WebdriverIO specific) */ function afterEach(action: jasmine.ImplementationCallback, timeout?: number, retries?: number): void /** * Run some shared setup once before all of the specs in the describe are run. * Note: Be careful, sharing the setup from a beforeAll makes it easy to accidentally leak state between your specs so that they erroneously pass or fail. * @param action Function that contains the code to setup your specs. * @param timeout Custom timeout for an async beforeAll. * @param retries Custom retry count for this single hook (WebdriverIO specific) */ function beforeAll(action: jasmine.ImplementationCallback, timeout?: number, retries?: number): void /** * Run some shared teardown once before all of the specs in the describe are run. * Note: Be careful, sharing the teardown from a afterAll makes it easy to accidentally leak state between your specs so that they erroneously pass or fail. * @param action Function that contains the code to teardown your specs. * @param timeout Custom timeout for an async afterAll * @param retries Custom retry count for this single hook (WebdriverIO specific) */ function afterAll(action: jasmine.ImplementationCallback, timeout?: number, retries?: number): void namespace WebdriverIO { interface JasmineOpts extends jasmineNodeOpts {} } namespace ExpectWebdriverIO { // eslint-disable-next-line @typescript-eslint/no-unused-vars interface Matchers extends jasmine.Matchers {} } }