diff --git a/assets/csp-bypass@testplane.io.xpi b/assets/csp-bypass@testplane.io.xpi new file mode 100644 index 000000000..96875bacc Binary files /dev/null and b/assets/csp-bypass@testplane.io.xpi differ diff --git a/package-lock.json b/package-lock.json index 5af7c5b5d..f9bd8b385 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@jspm/core": "2.0.1", "@puppeteer/browsers": "2.7.1", "@rrweb/record": "2.0.0-alpha.18", + "@testing-library/dom": "10.4.0", "@testplane/devtools": "8.32.3", "@testplane/wdio-protocols": "9.4.6", "@testplane/wdio-utils": "9.5.3", @@ -708,7 +709,6 @@ }, "node_modules/@babel/runtime": { "version": "7.24.1", - "dev": true, "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" @@ -2332,6 +2332,82 @@ "node": ">=14.16" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", + "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "license": "MIT" + }, "node_modules/@testplane/devtools": { "version": "8.32.3", "resolved": "https://registry.npmjs.org/@testplane/devtools/-/devtools-8.32.3.tgz", @@ -3009,6 +3085,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "license": "MIT" + }, "node_modules/@types/babel__code-frame": { "version": "7.0.6", "dev": true, @@ -6458,6 +6540,15 @@ "deps-sort": "bin/cmd.js" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/des.js": { "version": "1.0.1", "dev": true, @@ -6582,6 +6673,12 @@ "node": ">=6.0.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "license": "MIT" + }, "node_modules/dom-serializer": { "version": "1.4.1", "dev": true, @@ -9854,6 +9951,15 @@ "dev": true, "license": "ISC" }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/make-error": { "version": "1.3.6", "dev": true, @@ -11954,7 +12060,6 @@ }, "node_modules/regenerator-runtime": { "version": "0.14.1", - "dev": true, "license": "MIT" }, "node_modules/regexpp": { @@ -14837,7 +14942,6 @@ }, "@babel/runtime": { "version": "7.24.1", - "dev": true, "requires": { "regenerator-runtime": "^0.14.0" } @@ -15867,6 +15971,62 @@ "defer-to-connect": "^2.0.1" } }, + "@testing-library/dom": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", + "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "dependencies": { + "aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "requires": { + "dequal": "^2.0.3" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "requires": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "dependencies": { + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==" + } + } + }, + "react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + } + } + }, "@testplane/devtools": { "version": "8.32.3", "resolved": "https://registry.npmjs.org/@testplane/devtools/-/devtools-8.32.3.tgz", @@ -16391,6 +16551,11 @@ "version": "1.0.3", "dev": true }, + "@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==" + }, "@types/babel__code-frame": { "version": "7.0.6", "dev": true @@ -18732,6 +18897,11 @@ "through2": "^2.0.0" } }, + "dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==" + }, "des.js": { "version": "1.0.1", "dev": true, @@ -18817,6 +18987,11 @@ "esutils": "^2.0.2" } }, + "dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==" + }, "dom-serializer": { "version": "1.4.1", "dev": true, @@ -20914,6 +21089,11 @@ } } }, + "lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==" + }, "make-error": { "version": "1.3.6", "dev": true @@ -22281,8 +22461,7 @@ } }, "regenerator-runtime": { - "version": "0.14.1", - "dev": true + "version": "0.14.1" }, "regexpp": { "version": "3.2.0", diff --git a/package.json b/package.json index b567c573f..450ed6ca6 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ }, "scripts": { "build": "tsc --build && npm run build-bundles && npm run copy-static && node scripts/create-client-scripts-symlinks.js", - "copy-static": "copyfiles 'src/browser/client-scripts/*' 'src/**/[!cache]*/autogenerated/**/*.json' build", + "copy-static": "copyfiles 'src/browser/client-scripts/*' 'src/**/[!cache]*/autogenerated/**/*.json' 'assets/*' build", "build-node-bundle": "esbuild ./src/bundle/cjs/index.ts --outdir=./build/src/bundle/cjs --bundle --format=cjs --platform=node --target=ES2021", "build-browser-bundle": "node ./src/browser/client-scripts/build.js", "build-bundles": "concurrently -c 'auto' 'npm:build-browser-bundle' 'npm:build-node-bundle --minify'", @@ -69,6 +69,7 @@ "@jspm/core": "2.0.1", "@puppeteer/browsers": "2.7.1", "@rrweb/record": "2.0.0-alpha.18", + "@testing-library/dom": "10.4.0", "@testplane/devtools": "8.32.3", "@testplane/wdio-protocols": "9.4.6", "@testplane/wdio-utils": "9.5.3", diff --git a/src/browser/browser.ts b/src/browser/browser.ts index a7f304561..d8944a13b 100644 --- a/src/browser/browser.ts +++ b/src/browser/browser.ts @@ -13,6 +13,9 @@ import { AsyncEmitter } from "../events"; import { BrowserConfig } from "../config/browser-config"; import type { Callstack } from "./history/callstack"; import type { WdProcess, WebdriverPool } from "../browser-pool/webdriver-pool"; +import { configure, setupBrowser } from "./queries"; +import { getFirefoxCSPAddOn } from "./queries/firefoxCSPAddOn"; +import { getNormalizedBrowserName } from "../utils/browser"; const CUSTOM_SESSION_OPTS = [ "outputDir", @@ -102,6 +105,17 @@ export class Browser { this._addExtendOptionsMethod(this._session!); } + protected async _installFirefoxCSPAddOn(): Promise { + if (getNormalizedBrowserName(this._session!.capabilities.browserName) !== "firefox") return Promise.resolve(""); + const firefoxCSPAddOn = await getFirefoxCSPAddOn(); + return this._session!.installAddOn(firefoxCSPAddOn, false); + } + + protected _addQueries(): void { + configure({ testIdAttribute: this._config.testIdAttribute, asyncUtilTimeout: this._config.waitTimeout }); + setupBrowser(this._session!); + } + protected _addSteps(): void { addRunStepCommand(this); } diff --git a/src/browser/existing-browser.ts b/src/browser/existing-browser.ts index b4ea002aa..b38195965 100644 --- a/src/browser/existing-browser.ts +++ b/src/browser/existing-browser.ts @@ -84,6 +84,8 @@ export class ExistingBrowser extends Browser { this._extendStacktrace(); this._addSteps(); + await this._installFirefoxCSPAddOn(); + this._addQueries(); this._addHistory(); await history.runGroup( diff --git a/src/browser/new-browser.ts b/src/browser/new-browser.ts index c0f857b62..d51f8ee8f 100644 --- a/src/browser/new-browser.ts +++ b/src/browser/new-browser.ts @@ -73,6 +73,8 @@ export class NewBrowser extends Browser { this._extendStacktrace(); this._addSteps(); + await this._installFirefoxCSPAddOn(); + this._addQueries(); this._addHistory(); await runGroup( diff --git a/src/browser/queries/firefoxCSPAddOn.ts b/src/browser/queries/firefoxCSPAddOn.ts new file mode 100644 index 000000000..b5b2f6707 --- /dev/null +++ b/src/browser/queries/firefoxCSPAddOn.ts @@ -0,0 +1,7 @@ +import fs from "fs/promises"; +import path from "path"; + +export const getFirefoxCSPAddOn = async (): Promise => { + const extension = await fs.readFile(path.join(__dirname, "../../../assets/csp-bypass@testplane.io.xpi")); + return extension.toString("base64"); +}; diff --git a/src/browser/queries/index.ts b/src/browser/queries/index.ts new file mode 100644 index 000000000..8650b9800 --- /dev/null +++ b/src/browser/queries/index.ts @@ -0,0 +1,294 @@ +/* +Portions of this functionality are copyright of their respective authors +and released under the MIT license: +https://github.com/testing-library/webdriverio-testing-library +*/ + +import path from "path"; +import fs from "fs"; +import { + Matcher, + MatcherOptions, + queries as baseQueries, + waitForOptions as WaitForOptions, +} from "@testing-library/dom"; + +import { + QueryArg, + Config, + QueryName, + TestplaneQueries, + TestplaneQueriesChainable, + ObjectQueryArg, + SerializedObject, + SerializedArg, + SelectorsBase, +} from "./types"; +import { ElementBase } from "@testplane/webdriverio"; + +declare global { + interface Window { + TestingLibraryDom: typeof baseQueries & { + configure: typeof configure; + }; + } +} + +/* +eslint-disable +@typescript-eslint/explicit-function-return-type +*/ + +const DOM_TESTING_LIBRARY_UMD_PATH = path.join( + require.resolve("@testing-library/dom"), + "../../", + "dist/@testing-library/dom.umd.js", +); +const DOM_TESTING_LIBRARY_UMD = fs.readFileSync(DOM_TESTING_LIBRARY_UMD_PATH).toString().replace("define.amd", "false"); + +let _config: Partial; + +function isContainerWithExecute(container: ElementBase | WebdriverIO.Browser): container is WebdriverIO.Browser { + return (container as { execute?: unknown }).execute !== null; +} + +function findContainerWithExecute(container: ElementBase): WebdriverIO.Browser { + let curContainer: ElementBase | WebdriverIO.Browser = container.parent; + while (!isContainerWithExecute(curContainer)) { + curContainer = curContainer.parent; + } + return curContainer; +} + +const findBrowser = (container: ElementBase): WebdriverIO.Browser => { + let browser: ElementBase | WebdriverIO.Browser = container; + while ((browser as ElementBase).parent && browser.capabilities === undefined) { + browser = (browser as ElementBase).parent; + } + return browser as WebdriverIO.Browser; +}; + +async function injectDOMTestingLibrary(container: ElementBase) { + const browser = findContainerWithExecute(container); + const shouldInjectDTL = await browser.execute(function () { + return !window.TestingLibraryDom; + }); + + if (shouldInjectDTL) { + await browser.execute(function (library) { + if (navigator.userAgent.indexOf("Firefox") !== -1) { + try { + // Inject via inline-script + const script = window.document.createElement("script"); + script.textContent = library; + window.document.head.append(script); + if (!window.TestingLibraryDom) { + // Inject via eval + window.eval(library); + } + } catch (error) { + throw new Error( + `The DOM Testing Library cannot be injected on certain domains, particularly "${window.location.host}", due to restrictions imposed by the Content-Security-Policy (CSP) header.`, + ); + } + } else { + eval(library); + } + }, DOM_TESTING_LIBRARY_UMD); + } + + await browser.execute(function (config: Partial) { + window.TestingLibraryDom.configure(config); + }, _config); +} + +function serializeObject(object: ObjectQueryArg): SerializedObject { + return Object.entries(object) + .map<[string, SerializedArg]>(([key, value]: [string, QueryArg]) => [key, serializeArg(value)]) + .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), { + serialized: "object", + }); +} + +function serializeArg(arg: QueryArg): SerializedArg { + if (arg instanceof RegExp) { + return { serialized: "RegExp", RegExp: arg.toString() }; + } + if (typeof arg === "undefined") { + return { serialized: "Undefined", Undefined: true }; + } + if (arg && typeof arg === "object") { + return serializeObject(arg); + } + return arg; +} + +type SerializedQueryResult = { selector: string }[] | string | { selector: string } | null; + +async function executeQuery(query: QueryName, container: HTMLElement, ...args: SerializedArg[]) { + return new Promise((done: (result: SerializedQueryResult) => void) => { + function deserializeObject(object: SerializedObject) { + return Object.entries(object) + .map<[string, QueryArg]>(([key, value]) => [key, deserializeArg(value)]) + .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}); + } + + function deserializeArg(arg: SerializedArg): QueryArg { + if (typeof arg === "object" && arg === null) { + return undefined; + } + if (typeof arg === "object" && (arg as { nodeType?: string }).nodeType !== undefined) { + return arg as QueryArg; + } + if (typeof arg === "object" && arg.serialized === "RegExp") { + return eval(arg.RegExp); + } + if (typeof arg === "object" && arg.serialized === "Undefined") { + return undefined; + } + if (typeof arg === "object") { + return deserializeObject(arg); + } + return arg; + } + + const [matcher, options, waitForOptions] = args.map(deserializeArg); + + void (async () => { + let result: ReturnType<(typeof window.TestingLibraryDom)[typeof query]> = null; + try { + // Override RegExp to fix 'matcher instanceof RegExp' check on Firefox + window.RegExp = RegExp; + + result = await window.TestingLibraryDom[query]( + container, + // @ts-expect-error Matcher can be any type from @testing-library/dom + matcher as Matcher, + options as MatcherOptions, + waitForOptions as WaitForOptions, + ); + } catch (e: unknown) { + return done((e as Error).message); + } + + if (!result) { + return done(null); + } + + function makeSelectorResult(element: HTMLElement): { selector: string } { + const elementIdAttributeName = "data-testplane-element-id"; + let elementId = element.getAttribute(elementIdAttributeName); + + // if id doesn't already exist create one and add it to element + if (!elementId) { + elementId = (Math.abs(Math.random()) * 1000000000000).toFixed(0); + element.setAttribute(elementIdAttributeName, elementId); + } + + return { selector: `[${elementIdAttributeName}="${elementId}"]` }; + } + + if (Array.isArray(result)) { + return done(result.map(makeSelectorResult)); + } + + return done(makeSelectorResult(result)); + })(); + }); +} + +function createQuery(container: ElementBase & SelectorsBase, queryName: QueryName) { + return async (...args: QueryArg[]) => { + await injectDOMTestingLibrary(container); + + const browser = findBrowser(container); + const result: SerializedQueryResult = await browser.execute( + executeQuery, + queryName, + container as unknown as HTMLElement, + ...args.map(serializeArg), + ); + + if (typeof result === "string") { + throw new Error(result); + } + + if (!result) { + return null; + } + + if (Array.isArray(result)) { + return Promise.all(result.map(({ selector }) => container.$(selector))); + } + + return container.$(result.selector); + }; +} + +function within(element: ElementBase & SelectorsBase) { + return (Object.keys(baseQueries) as QueryName[]).reduce( + (queries, queryName) => ({ + ...queries, + [queryName]: createQuery(element, queryName), + }), + {}, + ) as TestplaneQueries; +} + +/* +eslint-disable +@typescript-eslint/no-explicit-any, +@typescript-eslint/no-unsafe-argument +*/ + +// Patches (via addCommand) the browser object with the DomTestingLibrary queries +function setupBrowser(browser: Browser): TestplaneQueries { + const queries: { [key: string | number | symbol]: TestplaneQueries[QueryName] } = {}; + + Object.keys(baseQueries).forEach(key => { + const queryName = key as QueryName; + + const query = async (...args: Parameters) => { + const body = await browser.$("body"); + return within(body as ElementBase & SelectorsBase)[queryName](...(args as any[])); + }; + + // add query to response queries + queries[queryName] = query as TestplaneQueries[QueryName]; + + // add query to BrowserObject and Elements + browser.addCommand(queryName, query as TestplaneQueries[QueryName]); + browser.addCommand( + queryName, + function (this: ElementBase & SelectorsBase, ...args: any) { + return within(this)[queryName](...args); + }, + true, + ); + + // add chainable query to BrowserObject and Elements + browser.addCommand(`${queryName}$`, query as TestplaneQueriesChainable[`${QueryName}$`]); + browser.addCommand( + `${queryName}$`, + function (this: ElementBase & SelectorsBase, ...args) { + return within(this)[queryName](...args); + }, + true, + ); + }); + + return queries as unknown as TestplaneQueries; +} + +/* +eslint-enable +@typescript-eslint/no-explicit-any, +@typescript-eslint/no-unsafe-argument +*/ + +function configure(config: Partial) { + _config = config; +} + +export * from "./types"; +export { within, setupBrowser, configure }; diff --git a/src/browser/queries/types.ts b/src/browser/queries/types.ts new file mode 100644 index 000000000..e1726b99a --- /dev/null +++ b/src/browser/queries/types.ts @@ -0,0 +1,73 @@ +import { + Config as BaseConfig, + BoundFunction as BoundFunctionBase, + queries, + waitForOptions, + SelectorMatcherOptions, + MatcherOptions, +} from "@testing-library/dom"; + +export type SelectorsBase = Pick; + +export type Queries = typeof queries; +export type QueryName = keyof Queries; + +export type Config = Pick< + BaseConfig, + | "asyncUtilTimeout" + | "computedStyleSupportsPseudoElements" + | "defaultHidden" + | "testIdAttribute" + | "throwSuggestions" +>; + +export type QueryReturnType = T extends Promise + ? Element + : T extends HTMLElement + ? Element + : T extends Promise + ? ElementArray + : T extends HTMLElement[] + ? ElementArray + : T extends null + ? null + : never; + +export type BoundFunction = ( + ...params: Parameters> +) => Promise>>>; + +export type BoundFunctionSync = ( + ...params: Parameters> +) => QueryReturnType>>; + +export type TestplaneQueries = { + [P in keyof Queries]: BoundFunction; +}; + +export type TestplaneQueriesChainable = { + [P in keyof Queries as `${string & P}$`]: Container extends SelectorsBase + ? BoundFunctionSync, ReturnType, Queries[P]> + : undefined; +}; + +export type ObjectQueryArg = MatcherOptions | queries.ByRoleOptions | SelectorMatcherOptions | waitForOptions; + +export type QueryArg = ObjectQueryArg | RegExp | number | string | undefined; + +export type SerializedObject = { + serialized: "object"; + // eslint-disable-next-line no-use-before-define + [key: string]: SerializedArg; +}; +export type SerializedRegExp = { serialized: "RegExp"; RegExp: string }; +export type SerializedUndefined = { serialized: "Undefined"; Undefined: true }; + +export type SerializedArg = SerializedObject | SerializedRegExp | SerializedUndefined | number | string; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace WebdriverIO { + interface Element extends TestplaneQueries {} + } +} diff --git a/src/browser/types.ts b/src/browser/types.ts index 9f3bd7cf3..6035f02c6 100644 --- a/src/browser/types.ts +++ b/src/browser/types.ts @@ -8,6 +8,7 @@ import { OpenAndWaitCommand } from "./commands/openAndWait"; import type { Callstack } from "./history/callstack"; import { Test, Hook } from "../test-reader/test-object"; import type { CaptureSnapshotOptions, CaptureSnapshotResult } from "./commands/captureDomSnapshot"; +import { TestplaneQueries, TestplaneQueriesChainable } from "./queries"; export const BrowserName = { CHROME: PuppeteerBrowser.CHROME, @@ -23,6 +24,7 @@ export type W3CBrowserName = Exclude<(typeof BrowserName)[keyof typeof BrowserNa export interface BrowserMeta { pid: number; browserVersion: string; + [name: string]: unknown; } @@ -57,8 +59,9 @@ declare global { ...args: Parameters ) => ReturnType; - interface Browser { + interface Browser extends TestplaneQueries, TestplaneQueriesChainable { getMeta(this: WebdriverIO.Browser): Promise; + getMeta(this: WebdriverIO.Browser, key: string): Promise; setMeta(this: WebdriverIO.Browser, key: string, value: unknown): Promise; diff --git a/src/config/defaults.js b/src/config/defaults.js index be013bd9d..70b315c83 100644 --- a/src/config/defaults.js +++ b/src/config/defaults.js @@ -118,6 +118,7 @@ module.exports = { }, passive: false, timeTravel: TimeTravelMode.Off, + testIdAttribute: "data-testid", }; module.exports.configPaths = [ diff --git a/src/config/types.ts b/src/config/types.ts index 6000557f7..a1248525b 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -277,7 +277,9 @@ export interface CommonConfig { sessionsPerBrowser: number; testsPerSession: number; retry: number; + shouldRetry(testInfo: { ctx: Test; retriesLeft: number }): boolean | null; + httpTimeout: number; urlHttpTimeout: number | null; pageLoadTimeout: number | null; @@ -293,9 +295,13 @@ export interface CommonConfig { }; takeScreenshotOnFailsTimeout: number | null; takeScreenshotOnFailsMode: "fullpage" | "viewport"; + prepareBrowser(browser: WebdriverIO.Browser): void | null; + screenshotPath: string | null; + screenshotsDir(test: Test): string; + calibrate: boolean; compositeImage: boolean; strictTestsOrder: boolean; @@ -350,6 +356,8 @@ export interface CommonConfig { }; timeTravel: TimeTravelConfig; + + testIdAttribute?: string; } export interface SetsConfig { @@ -386,6 +394,7 @@ export interface ConfigParsed extends CommonConfig { export interface RuntimeConfig { extend: (data: unknown) => this; + [key: string]: unknown; } diff --git a/src/index.ts b/src/index.ts index 488d0d673..10e866d89 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import "./browser/types"; import "expect-webdriverio"; import { GlobalHelper } from "./types"; + export { run as runCli } from "./cli"; export { Testplane as default } from "./testplane"; export { Key } from "@testplane/webdriverio"; @@ -40,6 +41,7 @@ export type { export type { StatsResult } from "./stats"; import type { TestDefinition, SuiteDefinition, TestHookDefinition } from "./test-reader/test-object/types"; + export type { TestDefinition, SuiteDefinition, TestHookDefinition }; declare global {