diff --git a/eslint.config.mjs b/eslint.config.mjs index 5e661b65b..c82440f3c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -28,5 +28,65 @@ export default wdioEslint.config([ '@typescript-eslint/no-require-imports': 'off', '@typescript-eslint/no-explicit-any': 'off' } + }, + { + files: ['src/**/*.ts'], + plugins: { + local: { + rules: { + 'enforce-options-list': { + create(context) { + const fileName = context.filename || context.getFilename() + const isConstantsFile = fileName.endsWith('src/constants.ts') + const definedOptions = new Set() + let listNode = null + const listElements = new Set() + + return { + VariableDeclarator(node) { + if (node.id.type === 'Identifier') { + if (node.id.name.includes('DEFAULT_OPTIONS')) { + if (isConstantsFile) { + definedOptions.add(node.id.name) + } else { + context.report({ + node: node.id, + message: `Option '${node.id.name}' must be included in 'src/constants.ts#defaultOptionsList', so it can be globally overridden.` + }) + } + } + if (isConstantsFile && node.id.name === 'defaultOptionsList' && node.init && node.init.type === 'ArrayExpression') { + listNode = node + node.init.elements.forEach(el => { + if (el && el.type === 'Identifier') { + listElements.add(el.name) + } + }) + } + } + }, + 'Program:exit'() { + if (!listNode) { + return + } + + definedOptions.forEach(opt => { + if (!listElements.has(opt)) { + context.report({ + node: listNode, + message: `Option '${opt}' must be included in 'defaultOptionsList', so it can be globally overridden in 'src/constants.ts'.` + }) + } + }) + } + } + } + } + } + } + }, + rules: { + 'local/enforce-options-list': 'error' + } } ]) diff --git a/package.json b/package.json index 48675f2f7..da16ff30c 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,8 @@ "watch": "npm run compile -- --watch", "prepare": "husky install", "playgrounds:setup": "for dir in playgrounds/*/; do cd \"$dir\" && npm install && cd ../..; done", - "playgrounds:checks:all": "for dir in playgrounds/*/; do cd \"$dir\" && npm run checks:all && cd ../..; done" + "playgrounds:checks:all": "for dir in playgrounds/*/; do cd \"$dir\" && npm run checks:all && cd ../..; done", + "playgrounds:snapshots:update": "cd playgrounds/mocha && npm run snapshots:update && cd ../jest && npm run snapshots:update && cd ../.." }, "dependencies": { "@vitest/snapshot": "^4.0.16", diff --git a/playgrounds/jasmine/package-lock.json b/playgrounds/jasmine/package-lock.json index f791ec4fd..d0672a0e8 100644 --- a/playgrounds/jasmine/package-lock.json +++ b/playgrounds/jasmine/package-lock.json @@ -24,7 +24,6 @@ } }, "../..": { - "name": "expect-webdriverio", "version": "5.6.4", "dev": true, "license": "MIT", diff --git a/playgrounds/jest/package-lock.json b/playgrounds/jest/package-lock.json index 2b7c2dab9..cf9bbf7ad 100644 --- a/playgrounds/jest/package-lock.json +++ b/playgrounds/jest/package-lock.json @@ -24,7 +24,6 @@ } }, "../..": { - "name": "expect-webdriverio", "version": "5.6.4", "dev": true, "license": "MIT", diff --git a/playgrounds/jest/package.json b/playgrounds/jest/package.json index dc7ab4ab0..fbf402289 100644 --- a/playgrounds/jest/package.json +++ b/playgrounds/jest/package.json @@ -8,7 +8,8 @@ "typecheck": "tsc --noEmit", "test": "wdio run wdio.conf.ts", "lint": "eslint .", - "checks:all": "npm run typecheck && npm run lint && npm test" + "checks:all": "npm run typecheck && npm run lint && npm test", + "snapshots:update": "npm run test -- --spec snapshot.test.ts --updateSnapshots" }, "devDependencies": { "@types/jest": "^30.0.0", diff --git a/playgrounds/jest/test/specs/__snapshots__/snapshot.test.ts.snap b/playgrounds/jest/test/specs/__snapshots__/snapshot.test.ts.snap index f4d9ec4da..c43152608 100644 --- a/playgrounds/jest/test/specs/__snapshots__/snapshot.test.ts.snap +++ b/playgrounds/jest/test/specs/__snapshots__/snapshot.test.ts.snap @@ -15,7 +15,7 @@ exports[`DOM snapshots > should match command result snapshot 1`] = ` exports[`DOM snapshots > should match element outerHTML snapshot 1`] = ` "" `; diff --git a/playgrounds/mocha/package-lock.json b/playgrounds/mocha/package-lock.json index 53f856e16..358be4919 100644 --- a/playgrounds/mocha/package-lock.json +++ b/playgrounds/mocha/package-lock.json @@ -21,7 +21,6 @@ } }, "../..": { - "name": "expect-webdriverio", "version": "5.6.4", "dev": true, "license": "MIT", diff --git a/playgrounds/mocha/package.json b/playgrounds/mocha/package.json index bc5dbc45a..c68cc0f91 100644 --- a/playgrounds/mocha/package.json +++ b/playgrounds/mocha/package.json @@ -8,7 +8,8 @@ "typecheck": "tsc --noEmit", "test": "wdio run wdio.conf.ts", "lint": "eslint .", - "checks:all": "npm run typecheck && npm run lint && npm test" + "checks:all": "npm run typecheck && npm run lint && npm test", + "snapshots:update": "npm run test -- --spec snapshot.test.ts --updateSnapshots" }, "devDependencies": { "@wdio/cli": "^9.4.0", diff --git a/playgrounds/mocha/test/specs/__snapshots__/snapshot.test.ts.snap b/playgrounds/mocha/test/specs/__snapshots__/snapshot.test.ts.snap index f4d9ec4da..c43152608 100644 --- a/playgrounds/mocha/test/specs/__snapshots__/snapshot.test.ts.snap +++ b/playgrounds/mocha/test/specs/__snapshots__/snapshot.test.ts.snap @@ -15,7 +15,7 @@ exports[`DOM snapshots > should match command result snapshot 1`] = ` exports[`DOM snapshots > should match element outerHTML snapshot 1`] = ` "" `; diff --git a/playgrounds/mocha/test/specs/options.test.ts b/playgrounds/mocha/test/specs/options.test.ts new file mode 100644 index 000000000..e266133ca --- /dev/null +++ b/playgrounds/mocha/test/specs/options.test.ts @@ -0,0 +1,31 @@ +import { $ } from '@wdio/globals' +import { setOptions, getConfig } from 'expect-webdriverio' + +describe('Global Options', () => { + const defaultWait = getConfig().wait + + before(() => { + setOptions({ wait: 1 }) + }) + + it('should set global wait option', () => { + expect(getConfig().wait).toBe(1) + expect(getConfig().wait).not.toBe(defaultWait) + expect(defaultWait).toBe(10000) + }) + + it('should allow setting and using global wait option', async () => { + const start = Date.now() + + // Should fail immediately (wait: 1ms) + await expect(expect($('non-existent-element-' + Date.now())).toBeDisplayed()).rejects.toThrow() + const duration = Date.now() - start + + // Ensure failure was fast (< 500ms) compared to default timeout + expect(duration).toBeLessThan(500) + }) + + after(() => { + setOptions({ wait: defaultWait }) + }) +}) diff --git a/src/constants.ts b/src/constants.ts index f84c62eb4..fb8627e22 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -4,3 +4,16 @@ export const DEFAULT_OPTIONS: Required = { beforeAssertion: async () => {}, afterAssertion: async () => {}, } + +export const DEFAULT_OPTIONS_TO_BE_DISPLAYED: Required> = { + ...DEFAULT_OPTIONS, + withinViewport: false, + contentVisibilityAuto: true, + opacityProperty: true, + visibilityProperty: true +} + +export const defaultOptionsList = [ + DEFAULT_OPTIONS, + DEFAULT_OPTIONS_TO_BE_DISPLAYED +] diff --git a/src/index.ts b/src/index.ts index c88bd5c10..c245f2455 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import { expect as expectLib } from 'expect' import type { WdioMatchersObject } from './types.js' import * as wdioMatchers from './matchers.js' -import { DEFAULT_OPTIONS } from './constants.js' +import { DEFAULT_OPTIONS, defaultOptionsList } from './constants.js' import createSoftExpect from './softExpect.js' import { SoftAssertService } from './softAssert.js' @@ -51,15 +51,22 @@ Object.defineProperty(expectWithSoft, 'clearSoftFailures', { export const expect = expectWithSoft +// TODO one day to rename to something more aligned with setDefaultOptions export const getConfig = (): ExpectWebdriverIO.DefaultOptions => DEFAULT_OPTIONS -export const setDefaultOptions = (options = {}): void => { +export const setDefaultOptions = (options: Partial): void => { Object.entries(options).forEach(([key, value]) => { - if (key in DEFAULT_OPTIONS) { - // @ts-ignore - DEFAULT_OPTIONS[key] = value - } + defaultOptionsList.forEach((option) => { + if (key in option) { + // @ts-ignore + option[key] = value + } + }) }) } + +/** + * @deprecated use setDefaultOptions instead + */ export const setOptions = setDefaultOptions /** diff --git a/src/matchers/element/toBeDisplayed.ts b/src/matchers/element/toBeDisplayed.ts index 16b0352e3..ec06f1c24 100644 --- a/src/matchers/element/toBeDisplayed.ts +++ b/src/matchers/element/toBeDisplayed.ts @@ -1,18 +1,10 @@ import { executeCommandBe } from '../../utils.js' -import { DEFAULT_OPTIONS } from '../../constants.js' import type { WdioElementMaybePromise } from '../../types.js' - -const DEFAULT_OPTIONS_DISPLAYED: ExpectWebdriverIO.ToBeDisplayedOptions = { - ...DEFAULT_OPTIONS, - withinViewport: false, - contentVisibilityAuto: true, - opacityProperty: true, - visibilityProperty: true -} +import { DEFAULT_OPTIONS_TO_BE_DISPLAYED } from '../../constants.js' export async function toBeDisplayed( received: WdioElementMaybePromise, - options: ExpectWebdriverIO.ToBeDisplayedOptions = DEFAULT_OPTIONS_DISPLAYED, + options: ExpectWebdriverIO.ToBeDisplayedOptions = DEFAULT_OPTIONS_TO_BE_DISPLAYED, ) { this.expectation = this.expectation || 'displayed' @@ -27,7 +19,7 @@ export async function toBeDisplayed( opacityProperty, visibilityProperty, ...commandOptions - } = { ...DEFAULT_OPTIONS_DISPLAYED, ...options } + } = { ...DEFAULT_OPTIONS_TO_BE_DISPLAYED, ...options } const result = await executeCommandBe.call(this, received, el => el?.isDisplayed({ withinViewport, diff --git a/test/matchers/beMatchers.test.ts b/test/matchers/beMatchers.test.ts index c2eba4864..6d20fa856 100644 --- a/test/matchers/beMatchers.test.ts +++ b/test/matchers/beMatchers.test.ts @@ -1,7 +1,10 @@ -import { vi, test, describe, expect } from 'vitest' +import { vi, test, describe, expect, afterEach, beforeEach } from 'vitest' import { $ } from '@wdio/globals' import { matcherLastWordName } from '../__fixtures__/utils.js' import * as Matchers from '../../src/matchers.js' +import { setOptions } from '../../src/index.js' +import { DEFAULT_OPTIONS } from '../../src/constants.js' +import { executeCommandBe } from '../../src/utils.js' vi.mock('@wdio/globals') @@ -17,6 +20,15 @@ const beMatchers = { 'toBeSelected': 'isSelected', } satisfies Partial> +vi.mock('../../src/utils.js', async (importOriginal) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importOriginal() + return { + ...actual, + executeCommandBe: vi.fn(actual.executeCommandBe) + } +}) + describe('be* matchers', () => { describe('Ensure all toBe matchers are covered', () => { @@ -135,6 +147,35 @@ Expected: "${matcherLastWordName(matcherName)}" Received: "not ${matcherLastWordName(matcherName)}"` ) }) + + describe('global options', () => { + const defaultOptions = { ...DEFAULT_OPTIONS } + + beforeEach(() => { + // Set global options to custom values before each test + setOptions({ wait: 99, interval: 101 }) + }) + + afterEach(() => { + // Reset options after each test to avoid side effects + setOptions(defaultOptions) + expect(DEFAULT_OPTIONS.wait).not.toBe(99) + }) + + test('should use globally set default options', async () => { + const el = await $('sel') + el.isDisplayed = vi.fn().mockResolvedValue(true) + + await matcherFn.call({}, el) + + expect(DEFAULT_OPTIONS.wait).toBe(99) + expect(executeCommandBe).toHaveBeenCalledWith( + el, + expect.anything(), + expect.objectContaining({ wait: 99, interval: 101 }) + ) + }) + }) }) }) }) diff --git a/test/matchers/element/toBeDisplayed.test.ts b/test/matchers/element/toBeDisplayed.test.ts index 250ead0bd..39f13780a 100644 --- a/test/matchers/element/toBeDisplayed.test.ts +++ b/test/matchers/element/toBeDisplayed.test.ts @@ -1,9 +1,11 @@ -import { vi, test, describe, expect } from 'vitest' +import { vi, test, describe, expect, afterEach, beforeEach } from 'vitest' import { $ } from '@wdio/globals' import { toBeDisplayed } from '../../../src/matchers/element/toBeDisplayed.js' import { executeCommandBe } from '../../../src/utils.js' import { DEFAULT_OPTIONS } from '../../../src/constants.js' +import { setDefaultOptions } from '../../../src/index.js' +import type { ChainablePromiseElement } from 'webdriverio' vi.mock('@wdio/globals') vi.mock('../../../src/utils.js', async (importOriginal) => { @@ -15,7 +17,7 @@ vi.mock('../../../src/utils.js', async (importOriginal) => { } }) -describe('toBeDisplayed', () => { +describe(toBeDisplayed, () => { /** * result is inverted for toBeDisplayed because it inverts isEnabled result * `!await el.isEnabled()` @@ -189,4 +191,50 @@ Expected: "displayed" Received: "not displayed"` ) }) + + describe('global options', () => { + const defaultOptions = { ...DEFAULT_OPTIONS } + + let el: ChainablePromiseElement + + beforeEach(async () => { + setDefaultOptions({ wait: 99, interval: 101 }) + el = await $('sel') + el.isDisplayed = vi.fn().mockResolvedValue(true) + + }) + + afterEach(() => { + setDefaultOptions(defaultOptions) + }) + + test('should use globally set default options with executeCommandBe', async () => { + await toBeDisplayed.call({}, el) + + expect(executeCommandBe).toHaveBeenCalledWith( + el, + expect.anything(), + expect.objectContaining({ wait: 99, interval: 101 }) + ) + }) + + test('should use globally set default options with isDisplayed', async () => { + + await toBeDisplayed.call({}, el) + + expect(executeCommandBe).toHaveBeenCalledWith( + el, + expect.anything(), + expect.objectContaining({ wait: 99, interval: 101 }) + ) + expect(el.isDisplayed).toHaveBeenCalledWith( + { + withinViewport: false, + contentVisibilityAuto: true, + opacityProperty: true, + visibilityProperty: true + } + ) + }) + }) }) diff --git a/test/options.test.ts b/test/options.test.ts new file mode 100644 index 000000000..1daba5d48 --- /dev/null +++ b/test/options.test.ts @@ -0,0 +1,37 @@ +import { test, expect, describe, afterEach } from 'vitest' +import { setDefaultOptions, setOptions } from '../src/index.js' +import { DEFAULT_OPTIONS, DEFAULT_OPTIONS_TO_BE_DISPLAYED } from '../src/constants.js' + +describe('Default Options', () => { + const defaultOptions = { ...DEFAULT_OPTIONS } + afterEach(() => { + setDefaultOptions(defaultOptions) + }) + + describe(setOptions, () => { + test('legacy setOptions should update both DEFAULT_OPTIONS_TO_BE_DISPLAYED and DEFAULT_OPTIONS', () => { + expect(DEFAULT_OPTIONS_TO_BE_DISPLAYED.wait).not.toBe(98) + expect(DEFAULT_OPTIONS.wait).not.toBe(98) + + setOptions({ wait: 98 }) + + expect(DEFAULT_OPTIONS_TO_BE_DISPLAYED.wait).toBe(98) + expect(DEFAULT_OPTIONS.wait).toBe(98) + }) + }) + + describe(setDefaultOptions, () => { + + test('setDefaultOptions should update both DEFAULT_OPTIONS_TO_BE_DISPLAYED and DEFAULT_OPTIONS', () => { + expect(DEFAULT_OPTIONS_TO_BE_DISPLAYED.wait).not.toBe(1234) + expect(DEFAULT_OPTIONS.wait).not.toBe(1234) + + setDefaultOptions({ wait: 1234 }) + + expect(DEFAULT_OPTIONS_TO_BE_DISPLAYED.wait).toBe(1234) + expect(DEFAULT_OPTIONS.wait).toBe(1234) + }) + }) + +}) +