From 5817e92916be7e97940cec1940bc19296be28687 Mon Sep 17 00:00:00 2001 From: DudaGod Date: Tue, 23 Apr 2024 19:30:45 +0300 Subject: [PATCH] fix(ct): correctly support custom element commands --- src/browser/browser.js | 17 +- src/browser/history/commands.ts | 4 +- src/browser/history/index.js | 2 +- src/browser/types.ts | 2 +- .../vite/browser-modules/driver.ts | 28 ++- .../vite/browser-modules/mock/@wdio-logger.ts | 10 + .../browser-modules/mock/default-module.ts | 1 + .../mock/import-meta-resolve.ts | 1 + .../browser-env/vite/browser-modules/types.ts | 3 +- .../vite/plugins/generate-index-html.ts | 24 +- .../browser-env/runner/test-runner/index.ts | 36 ++- test/src/browser/existing-browser.js | 6 +- test/src/browser/history/commands.js | 4 +- .../browser-env/runner/test-runner/index.ts | 211 ++++++++++++++---- 14 files changed, 261 insertions(+), 88 deletions(-) create mode 100644 src/runner/browser-env/vite/browser-modules/mock/@wdio-logger.ts create mode 100644 src/runner/browser-env/vite/browser-modules/mock/default-module.ts create mode 100644 src/runner/browser-env/vite/browser-modules/mock/import-meta-resolve.ts diff --git a/src/browser/browser.js b/src/browser/browser.js index 9e776c3cb..a4747e3e3 100644 --- a/src/browser/browser.js +++ b/src/browser/browser.js @@ -6,6 +6,7 @@ const { SAVE_HISTORY_MODE } = require("../constants/config"); const { X_REQUEST_ID_DELIMITER } = require("../constants/browser"); const history = require("./history"); const stacktrace = require("./stacktrace"); +const { getBrowserCommands, getElementCommands } = require("./history/commands"); const addRunStepCommand = require("./commands/runStep").default; const CUSTOM_SESSION_OPTS = [ @@ -106,12 +107,17 @@ module.exports = class Browser { } _startCollectingCustomCommands() { - this._session.overwriteCommand("addCommand", (origCommand, name, ...rest) => { - if (!this._session[name]) { - this._customCommands.add(name); + const browserCommands = getBrowserCommands(); + const elementCommands = getElementCommands(); + + this._session.overwriteCommand("addCommand", (origCommand, name, wrapper, elementScope, ...rest) => { + const isKnownCommand = elementScope ? elementCommands.includes(name) : browserCommands.includes(name); + + if (!isKnownCommand) { + this._customCommands.add({ name, elementScope: Boolean(elementScope) }); } - return origCommand(name, ...rest); + return origCommand(name, wrapper, elementScope, ...rest); }); } @@ -144,6 +150,7 @@ module.exports = class Browser { } get customCommands() { - return Array.from(this._customCommands); + const allCustomCommands = Array.from(this._customCommands); + return _.uniqWith(allCustomCommands, _.isEqual); } }; diff --git a/src/browser/history/commands.ts b/src/browser/history/commands.ts index 1bcf3abc4..3dd5dce3b 100644 --- a/src/browser/history/commands.ts +++ b/src/browser/history/commands.ts @@ -8,6 +8,7 @@ const wdioBrowserCommands = [ "$", "action", "actions", + "addCommand", "call", "custom$$", "custom$", @@ -23,6 +24,7 @@ const wdioBrowserCommands = [ "mockClearAll", "mockRestoreAll", "newWindow", + "overwriteCommand", "pause", "react$$", "react$", @@ -54,8 +56,8 @@ const wdioElementCommands = [ "dragAndDrop", "getAttribute", "getCSSProperty", - "getComputedRole", "getComputedLabel", + "getComputedRole", "getHTML", "getLocation", "getProperty", diff --git a/src/browser/history/index.js b/src/browser/history/index.js index 1590c5414..f5277a06c 100644 --- a/src/browser/history/index.js +++ b/src/browser/history/index.js @@ -88,7 +88,7 @@ const overwriteBrowserCommands = (session, callstack) => overwriteCommands({ session, callstack, - commands: cmds.getBrowserCommands(), + commands: cmds.getBrowserCommands().filter(cmd => !shouldNotWrapCommand(cmd)), elementScope: false, }); diff --git a/src/browser/types.ts b/src/browser/types.ts index 34f7de272..d415cc088 100644 --- a/src/browser/types.ts +++ b/src/browser/types.ts @@ -18,7 +18,7 @@ export interface Browser { state: Record; applyState: (state: Record) => void; callstackHistory: Callstack; - customCommands: string[]; + customCommands: { name: string; elementScope: boolean }[]; } type FunctionProperties = Exclude< diff --git a/src/runner/browser-env/vite/browser-modules/driver.ts b/src/runner/browser-env/vite/browser-modules/driver.ts index 1aea3d57d..40ede0201 100644 --- a/src/runner/browser-env/vite/browser-modules/driver.ts +++ b/src/runner/browser-env/vite/browser-modules/driver.ts @@ -27,7 +27,13 @@ export default class ProxyDriver { commandWrapper: VoidFunction | undefined, ): unknown { const monad = webdriverMonad(params, modifier, getWdioPrototype(userPrototype)); - return monad(window.__testplane__.sessionId, commandWrapper); + const browser = monad(window.__testplane__.sessionId, commandWrapper); + + window.__testplane__.customCommands.forEach(({ name, elementScope }) => { + browser.addCommand(name, mockCommand(name), elementScope); + }); + + return browser; } } @@ -67,25 +73,23 @@ function getAllProtocolCommands(): string[] { } function getMockedProtocolCommands(): PropertiesObject { - return [...getAllProtocolCommands(), ...SERVER_HANDLED_COMMANDS, ...window.__testplane__.customCommands].reduce( - (acc, commandName) => { - acc[commandName] = { value: mockCommand(commandName) }; - return acc; - }, - {} as PropertiesObject, - ); + return [...getAllProtocolCommands(), ...SERVER_HANDLED_COMMANDS].reduce((acc, commandName) => { + acc[commandName] = { value: mockCommand(commandName) }; + return acc; + }, {} as PropertiesObject); } function mockCommand(commandName: string): ProtocolCommandFn { - return async (...args: unknown[]): Promise => { + return async function (this: WebdriverIO.Browser | WebdriverIO.Element, ...args: unknown[]): Promise { const { socket } = window.__testplane__; const timeout = getCommandTimeout(commandName); + const element = isWdioElement(this) ? this : undefined; try { // TODO: remove type casting after https://github.com/socketio/socket.io/issues/4925 const [error, result] = (await socket .timeout(timeout) - .emitWithAck(BrowserEventNames.runBrowserCommand, { name: commandName, args })) as [ + .emitWithAck(BrowserEventNames.runBrowserCommand, { name: commandName, args, element })) as [ err: null | Error, result?: unknown, ]; @@ -167,3 +171,7 @@ function truncate(value: string, maxLen: number): string { return `${value.slice(0, maxLen - 3)}...`; } + +function isWdioElement(ctx: WebdriverIO.Browser | WebdriverIO.Element): ctx is WebdriverIO.Element { + return Boolean((ctx as WebdriverIO.Element).elementId); +} diff --git a/src/runner/browser-env/vite/browser-modules/mock/@wdio-logger.ts b/src/runner/browser-env/vite/browser-modules/mock/@wdio-logger.ts new file mode 100644 index 000000000..0adf7a08b --- /dev/null +++ b/src/runner/browser-env/vite/browser-modules/mock/@wdio-logger.ts @@ -0,0 +1,10 @@ +export default function getLogger(): typeof console { + return { + log: (): void => {}, + info: (): void => {}, + warn: (): void => {}, + error: (): void => {}, + } as unknown as typeof console; +} + +getLogger.setLogLevelsConfig = (): void => {}; diff --git a/src/runner/browser-env/vite/browser-modules/mock/default-module.ts b/src/runner/browser-env/vite/browser-modules/mock/default-module.ts new file mode 100644 index 000000000..617e71368 --- /dev/null +++ b/src/runner/browser-env/vite/browser-modules/mock/default-module.ts @@ -0,0 +1 @@ +export default (): void => {}; diff --git a/src/runner/browser-env/vite/browser-modules/mock/import-meta-resolve.ts b/src/runner/browser-env/vite/browser-modules/mock/import-meta-resolve.ts new file mode 100644 index 000000000..d761c9299 --- /dev/null +++ b/src/runner/browser-env/vite/browser-modules/mock/import-meta-resolve.ts @@ -0,0 +1 @@ +export const resolve = (): void => {}; diff --git a/src/runner/browser-env/vite/browser-modules/types.ts b/src/runner/browser-env/vite/browser-modules/types.ts index 2dd39e9d4..257c9468f 100644 --- a/src/runner/browser-env/vite/browser-modules/types.ts +++ b/src/runner/browser-env/vite/browser-modules/types.ts @@ -19,6 +19,7 @@ export enum BrowserEventNames { export interface BrowserRunBrowserCommandPayload { name: string; args: unknown[]; + element?: WebdriverIO.Element; } export interface BrowserRunExpectMatcherPayload { @@ -59,7 +60,7 @@ export interface WorkerInitializePayload { sessionId: WebdriverIO.Browser["sessionId"]; capabilities: WebdriverIO.Browser["capabilities"]; requestedCapabilities: WebdriverIO.Browser["options"]["capabilities"]; - customCommands: string[]; + customCommands: { name: string; elementScope: boolean }[]; // TODO: use BrowserConfig type after migrate to esm config: { automationProtocol: "webdriver" | "devtools"; diff --git a/src/runner/browser-env/vite/plugins/generate-index-html.ts b/src/runner/browser-env/vite/plugins/generate-index-html.ts index b4d318c94..eecd0a949 100644 --- a/src/runner/browser-env/vite/plugins/generate-index-html.ts +++ b/src/runner/browser-env/vite/plugins/generate-index-html.ts @@ -14,21 +14,16 @@ import type { Plugin, Rollup } from "vite"; const debug = createDebug("vite:plugin:generateIndexHtml"); // modules that used only in NodeJS environment and don't need to be compiled -const MODULES_TO_MOCK = ["import-meta-resolve", "puppeteer-core", "archiver", "@wdio/repl"]; +const DEFAULT_MODULES_TO_MOCK = ["puppeteer-core", "archiver", "@wdio/repl"]; const POLYFILLS = [...builtinModules, ...builtinModules.map(m => `node:${m}`)]; const virtualDriverModuleId = "virtual:@testplane/driver"; -const virtualMockModuleId = "virtual:@testplane/mock"; const virtualModules = { driver: { id: virtualDriverModuleId, resolvedId: `\0${virtualDriverModuleId}`, }, - mock: { - id: virtualMockModuleId, - resolvedId: `\0${virtualMockModuleId}`, - }, }; export const plugin = async (): Promise => { @@ -46,6 +41,15 @@ export const plugin = async (): Promise => { const automationProtocolPath = `/@fs${driverModulePath}`; + const mockDefaultModulePath = path.resolve(browserModulesPath, "mock/default-module.js"); + const mockImportMetaResolvePath = path.resolve(browserModulesPath, "mock/import-meta-resolve.js"); + const mockWdioLoggerPath = path.resolve(browserModulesPath, "mock/@wdio-logger.js"); + + const modulesToMock = DEFAULT_MODULES_TO_MOCK.reduce((acc, val) => _.set(acc, val, mockDefaultModulePath), { + "@wdio/logger": mockWdioLoggerPath, + "import-meta-resolve": mockImportMetaResolvePath, + }) as Record; + return [ { name: "testplane:generateIndexHtml", @@ -114,8 +118,8 @@ export const plugin = async (): Promise => { return polyfillPath(id.replace("/promises", "")); } - if (MODULES_TO_MOCK.includes(id)) { - return virtualModules.mock.resolvedId; + if (Object.keys(modulesToMock).includes(id)) { + return modulesToMock[id]; } }, @@ -123,10 +127,6 @@ export const plugin = async (): Promise => { if (id === virtualModules.driver.resolvedId) { return `export const automationProtocolPath = ${JSON.stringify(automationProtocolPath)};`; } - - if (id === virtualModules.mock.resolvedId) { - return ["export default () => {};", "export const resolve = () => ''"].join("\n"); - } }, transform(code, id): Rollup.TransformResult { diff --git a/src/worker/browser-env/runner/test-runner/index.ts b/src/worker/browser-env/runner/test-runner/index.ts index 550927ef2..88300b3ef 100644 --- a/src/worker/browser-env/runner/test-runner/index.ts +++ b/src/worker/browser-env/runner/test-runner/index.ts @@ -104,16 +104,23 @@ export class TestRunner extends NodejsEnvTestRunner { const { publicAPI: session } = browser; return async (payload, cb): Promise => { - const { name, args } = payload; - const cmdName = name as keyof typeof session; - - if (typeof session[cmdName] !== "function") { - cb([prepareData(new Error(`"browser.${name}" does not exists in browser instance`))]); + const { name, args, element } = payload; + + const wdioInstance = await getWdioInstance(session, element); + const wdioInstanceName = element ? "element" : "browser"; + const cmdName = name as keyof typeof wdioInstance; + + if (typeof wdioInstance[cmdName] !== "function") { + cb([ + prepareData( + new Error(`"${wdioInstanceName}.${name}" does not exists in ${wdioInstanceName} instance`), + ), + ]); return; } try { - const result = await (session[cmdName] as (...args: unknown[]) => Promise)(...args); + const result = await (wdioInstance[cmdName] as (...args: unknown[]) => Promise)(...args); if (_.isError(result)) { return cb([prepareData(result)]); @@ -209,3 +216,20 @@ function transformExpectArg(arg: any): unknown { return arg; } + +async function getWdioInstance( + session: WebdriverIO.Browser, + element?: WebdriverIO.Element, +): Promise { + const wdioInstance = element ? await session.$(element) : session; + + if (isWdioElement(wdioInstance) && !wdioInstance.selector) { + wdioInstance.selector = element?.selector as Selector; + } + + return wdioInstance; +} + +function isWdioElement(ctx: WebdriverIO.Browser | WebdriverIO.Element): ctx is WebdriverIO.Element { + return Boolean((ctx as WebdriverIO.Element).elementId); +} diff --git a/test/src/browser/existing-browser.js b/test/src/browser/existing-browser.js index 9a0d12476..6228eb5bc 100644 --- a/test/src/browser/existing-browser.js +++ b/test/src/browser/existing-browser.js @@ -173,10 +173,12 @@ describe("ExistingBrowser", () => { }); await initBrowser_(browser); - session.addCommand("foo", () => {}); + session.addCommand("foo", () => {}, false); + session.addCommand("foo", () => {}, true); assert.isNotEmpty(browser.customCommands); - assert.include(browser.customCommands, "foo"); + assert.deepInclude(browser.customCommands, { name: "foo", elementScope: false }); + assert.deepInclude(browser.customCommands, { name: "foo", elementScope: true }); }); }); diff --git a/test/src/browser/history/commands.js b/test/src/browser/history/commands.js index f8af6fd0e..f237db5cd 100644 --- a/test/src/browser/history/commands.js +++ b/test/src/browser/history/commands.js @@ -11,6 +11,7 @@ describe("commands-history", () => { "$", "action", "actions", + "addCommand", "call", "custom$$", "custom$", @@ -26,6 +27,7 @@ describe("commands-history", () => { "mockClearAll", "mockRestoreAll", "newWindow", + "overwriteCommand", "pause", "react$$", "react$", @@ -61,8 +63,8 @@ describe("commands-history", () => { "dragAndDrop", "getAttribute", "getCSSProperty", - "getComputedRole", "getComputedLabel", + "getComputedRole", "getHTML", "getLocation", "getProperty", diff --git a/test/src/worker/browser-env/runner/test-runner/index.ts b/test/src/worker/browser-env/runner/test-runner/index.ts index 6603e3128..b460b5c93 100644 --- a/test/src/worker/browser-env/runner/test-runner/index.ts +++ b/test/src/worker/browser-env/runner/test-runner/index.ts @@ -132,6 +132,14 @@ describe("worker/browser-env/runner/test-runner", () => { ...opts, }); + const mkElement_ = (opts: Partial = {}): WebdriverIO.Element => { + return { + elementId: "default-id", + selector: "default-selector", + ...opts, + } as unknown as WebdriverIO.Element; + }; + const mkSocket_ = (): WorkerViteSocket => { const socket = new EventEmitter() as unknown as WorkerViteSocket; socket.emitWithAck = sandbox.stub().resolves([null]); @@ -317,7 +325,7 @@ describe("worker/browser-env/runner/test-runner", () => { state: {}, } as RunOpts; - const customCommands = ["assertView"]; + const customCommands = [{ name: "assertView", elementScope: false }]; (BrowserAgent.prototype.getBrowser as SinonStub).resolves(mkBrowser_({ customCommands })); await runWithEmitBrowserInit(socket, { @@ -372,41 +380,87 @@ describe("worker/browser-env/runner/test-runner", () => { }); describe(`"${BrowserEventNames.runBrowserCommand}" event`, () => { - describe("should return error as first callback argument if", () => { - it("command does not exists in browser instance", done => { - const browser = mkBrowser_(); - (BrowserAgent.prototype.getBrowser as SinonStub).resolves(browser); - const socket = mkSocket_() as BrowserViteSocket; - socketClientStub.returns(socket); + describe("call command on element instance", () => { + describe("should return error as first callback argument if", () => { + it("command does not exists", done => { + const element = mkElement_(); + const browser = mkBrowser_(); + browser.publicAPI.$ = sandbox.stub().resolves(element); - const expectedErrMsg = '"browser.foo" does not exists in browser instance'; + (BrowserAgent.prototype.getBrowser as SinonStub).resolves(browser); + const socket = mkSocket_() as BrowserViteSocket; + socketClientStub.returns(socket); - runWithEmitBrowserInit(socket).then(() => { - socket.emit(BrowserEventNames.runBrowserCommand, { name: "foo", args: [] }, response => { - try { - assert.match(response, [ - { stack: sinon.match(expectedErrMsg), message: expectedErrMsg }, - ]); - done(); - } catch (err) { - done(err); - } + const expectedErrMsg = '"element.foo" does not exists in element instance'; + + runWithEmitBrowserInit(socket).then(() => { + socket.emit( + BrowserEventNames.runBrowserCommand, + { name: "foo", args: [], element }, + response => { + try { + assert.match(response, [ + { stack: sinon.match(expectedErrMsg), message: expectedErrMsg }, + ]); + done(); + } catch (err) { + done(err); + } + }, + ); + }); + }); + + ( + [ + { name: "command return error", cmdStubReturnMethod: "resolves" }, + { name: "command throw exception", cmdStubReturnMethod: "rejects" }, + ] as { name: string; cmdStubReturnMethod: "resolves" | "rejects" }[] + ).forEach(({ name, cmdStubReturnMethod }) => { + it(name, done => { + const error = new Error("o.O"); + + const element = mkElement_(); + element.getCSSProperty = sandbox + .stub() + [cmdStubReturnMethod as "resolves" | "rejects"](error); + + const browser = mkBrowser_(); + browser.publicAPI.$ = sandbox.stub().resolves(element); + + (BrowserAgent.prototype.getBrowser as SinonStub).resolves(browser); + + const socket = mkSocket_() as BrowserViteSocket; + socketClientStub.returns(socket); + + runWithEmitBrowserInit(socket).then(() => { + socket.emit( + BrowserEventNames.runBrowserCommand, + { name: "getCSSProperty", args: [], element }, + response => { + try { + assert.match(response, [{ message: "o.O", stack: sinon.match("o.O") }]); + done(); + } catch (err) { + done(err); + } + }, + ); + }); }); }); }); - ( - [ - { name: "command return error", cmdStubReturnMethod: "resolves" }, - { name: "command throw exception", cmdStubReturnMethod: "rejects" }, - ] as { name: string; cmdStubReturnMethod: "resolves" | "rejects" }[] - ).forEach(({ name, cmdStubReturnMethod }) => { - it(name, done => { - const error = new Error("o.O"); + describe("should return result as second callback argument if", () => { + it("command executed successfully with string", done => { + const result = "some_result"; + + const element = mkElement_(); + element.getCSSProperty = sandbox.stub().resolves(result); + const browser = mkBrowser_(); - browser.publicAPI.execute = sandbox - .stub() - [cmdStubReturnMethod as "resolves" | "rejects"](error); + browser.publicAPI.$ = sandbox.stub().resolves(element); + (BrowserAgent.prototype.getBrowser as SinonStub).resolves(browser); const socket = mkSocket_() as BrowserViteSocket; @@ -415,10 +469,11 @@ describe("worker/browser-env/runner/test-runner", () => { runWithEmitBrowserInit(socket).then(() => { socket.emit( BrowserEventNames.runBrowserCommand, - { name: "execute", args: [] }, + { name: "getCSSProperty", args: ["foo", "bar"], element }, response => { try { - assert.match(response, [{ message: "o.O", stack: sinon.match("o.O") }]); + assert.calledOnceWith(element.getCSSProperty, "foo", "bar"); + assert.deepEqual(response, [null, result]); done(); } catch (err) { done(err); @@ -430,30 +485,90 @@ describe("worker/browser-env/runner/test-runner", () => { }); }); - describe("should return result as second callback argument if", () => { - it("command executed successfully with string", done => { - const result = "some_result"; - const browser = mkBrowser_(); - browser.publicAPI.execute = sandbox.stub().resolves(result); - (BrowserAgent.prototype.getBrowser as SinonStub).resolves(browser); + describe("call command on browser instance", () => { + describe("should return error as first callback argument if", () => { + it("command does not exists", done => { + const browser = mkBrowser_(); + (BrowserAgent.prototype.getBrowser as SinonStub).resolves(browser); + const socket = mkSocket_() as BrowserViteSocket; + socketClientStub.returns(socket); - const socket = mkSocket_() as BrowserViteSocket; - socketClientStub.returns(socket); + const expectedErrMsg = '"browser.foo" does not exists in browser instance'; - runWithEmitBrowserInit(socket).then(() => { - socket.emit( - BrowserEventNames.runBrowserCommand, - { name: "execute", args: [1, 2, 3] }, - response => { + runWithEmitBrowserInit(socket).then(() => { + socket.emit(BrowserEventNames.runBrowserCommand, { name: "foo", args: [] }, response => { try { - assert.calledOnceWith(browser.publicAPI.execute, 1, 2, 3); - assert.deepEqual(response, [null, result]); + assert.match(response, [ + { stack: sinon.match(expectedErrMsg), message: expectedErrMsg }, + ]); done(); } catch (err) { done(err); } - }, - ); + }); + }); + }); + + ( + [ + { name: "command return error", cmdStubReturnMethod: "resolves" }, + { name: "command throw exception", cmdStubReturnMethod: "rejects" }, + ] as { name: string; cmdStubReturnMethod: "resolves" | "rejects" }[] + ).forEach(({ name, cmdStubReturnMethod }) => { + it(name, done => { + const error = new Error("o.O"); + const browser = mkBrowser_(); + browser.publicAPI.execute = sandbox + .stub() + [cmdStubReturnMethod as "resolves" | "rejects"](error); + (BrowserAgent.prototype.getBrowser as SinonStub).resolves(browser); + + const socket = mkSocket_() as BrowserViteSocket; + socketClientStub.returns(socket); + + runWithEmitBrowserInit(socket).then(() => { + socket.emit( + BrowserEventNames.runBrowserCommand, + { name: "execute", args: [] }, + response => { + try { + assert.match(response, [{ message: "o.O", stack: sinon.match("o.O") }]); + done(); + } catch (err) { + done(err); + } + }, + ); + }); + }); + }); + }); + + describe("should return result as second callback argument if", () => { + it("command executed successfully with string", done => { + const result = "some_result"; + const browser = mkBrowser_(); + browser.publicAPI.execute = sandbox.stub().resolves(result); + (BrowserAgent.prototype.getBrowser as SinonStub).resolves(browser); + + const socket = mkSocket_() as BrowserViteSocket; + socketClientStub.returns(socket); + + runWithEmitBrowserInit(socket).then(() => { + socket.emit( + BrowserEventNames.runBrowserCommand, + { name: "execute", args: [1, 2, 3] }, + response => { + try { + assert.calledOnceWith(browser.publicAPI.execute, 1, 2, 3); + assert.deepEqual(response, [null, result]); + done(); + } catch (err) { + done(err); + } + }, + ); + }); }); }); });