diff --git a/packages/app/src/components/sidebar.ts b/packages/app/src/components/sidebar.ts index 3ec3063..de1a627 100644 --- a/packages/app/src/components/sidebar.ts +++ b/packages/app/src/components/sidebar.ts @@ -17,6 +17,7 @@ export class DevtoolsSidebar extends Element { border-right: 1px solid var(--vscode-panel-border) !important; display: flex; flex-direction: column; + height: 100%; } ` ] diff --git a/packages/app/src/components/sidebar/explorer.ts b/packages/app/src/components/sidebar/explorer.ts index 44daeee..358e470 100644 --- a/packages/app/src/components/sidebar/explorer.ts +++ b/packages/app/src/components/sidebar/explorer.ts @@ -49,6 +49,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { display: flex; flex-direction: column; min-height: 0; + flex: 1 1 auto; } header { @@ -120,7 +121,9 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { runAll: detail.uid === '*', framework: this.#getFramework(), specFile: detail.specFile || this.#deriveSpecFile(detail), - configFile: this.#getConfigPath() + configFile: this.#getConfigPath(), + rerunCommand: this.#getRerunCommand(), + launchCommand: this.#getLaunchCommand() } await this.#postToBackend('/api/tests/run', payload) } @@ -199,7 +202,9 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { entryType: 'suite', runAll: true, framework: this.#getFramework(), - configFile: this.#getConfigPath() + configFile: this.#getConfigPath(), + rerunCommand: this.#getRerunCommand(), + launchCommand: this.#getLaunchCommand() }) } @@ -277,6 +282,14 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { return options?.configFilePath || options?.configFile } + #getRerunCommand(): string | undefined { + return this.#getRunnerOptions()?.rerunCommand + } + + #getLaunchCommand(): string | undefined { + return this.#getRunnerOptions()?.launchCommand + } + #renderEntry(entry: TestEntry): TemplateResult { return html` + rerunCommand?: string + launchCommand?: string } export interface TestRunDetail { diff --git a/packages/app/src/controller/DataManager.ts b/packages/app/src/controller/DataManager.ts index 580ee17..9e88f82 100644 --- a/packages/app/src/controller/DataManager.ts +++ b/packages/app/src/controller/DataManager.ts @@ -317,9 +317,15 @@ export class DataManagerController implements ReactiveController { } if (scope === 'clearExecutionData') { - const { uid, entryType } = + const { uid, entryType, clearSuiteTree } = data as SocketMessage<'clearExecutionData'>['data'] this.clearExecutionData(uid, entryType) + if (clearSuiteTree) { + this.suitesContextProvider.setValue([]) + this.#activeRerunTestUid = undefined + rerunState.activeRerunSuiteUid = undefined + this.#lastSeenRunTimestamp = 0 + } this.#host.requestUpdate() return } @@ -542,14 +548,22 @@ export class DataManagerController implements ReactiveController { #handleReplaceCommand(oldTimestamp: number, newCommand: CommandLog) { const current = this.commandsContextProvider.value || [] - // Find the last entry with the matching timestamp (most recent retry) - const idx = current.map((c) => c.timestamp).lastIndexOf(oldTimestamp) + // Prefer stable `id` — chained selenium calls share a millisecond. + let idx = -1 + const newId = (newCommand as CommandLog & { id?: number }).id + if (typeof newId === 'number') { + idx = current.findIndex( + (c) => (c as CommandLog & { id?: number }).id === newId + ) + } + if (idx === -1) { + idx = current.map((c) => c.timestamp).lastIndexOf(oldTimestamp) + } if (idx !== -1) { const updated = [...current] updated[idx] = newCommand this.commandsContextProvider.setValue(updated) } else { - // No matching entry found — just append this.commandsContextProvider.setValue([...current, newCommand]) } } @@ -562,10 +576,28 @@ export class DataManagerController implements ReactiveController { } #handleNetworkRequestsUpdate(data: NetworkRequest[]) { - this.networkRequestsContextProvider.setValue([ - ...(this.networkRequestsContextProvider.value || []), - ...data - ]) + const current = this.networkRequestsContextProvider.value || [] + const byId = new Map() + current.forEach((r, i) => { + if (r?.id) { + byId.set(r.id, i) + } + }) + const next = [...current] + for (const incoming of data) { + if (!incoming?.id) { + next.push(incoming) + continue + } + const existingIdx = byId.get(incoming.id) + if (existingIdx !== undefined) { + next[existingIdx] = incoming + } else { + byId.set(incoming.id, next.length) + next.push(incoming) + } + } + this.networkRequestsContextProvider.setValue(next) } #handleMetadataUpdate(data: Metadata) { diff --git a/packages/app/src/controller/types.ts b/packages/app/src/controller/types.ts index 8564eb8..3968a75 100644 --- a/packages/app/src/controller/types.ts +++ b/packages/app/src/controller/types.ts @@ -39,7 +39,11 @@ export interface SocketMessage< data: T extends keyof TraceLog ? TraceLog[T] : T extends 'clearExecutionData' - ? { uid?: string; entryType?: 'suite' | 'test' } + ? { + uid?: string + entryType?: 'suite' | 'test' + clearSuiteTree?: boolean + } : T extends 'replaceCommand' ? { oldTimestamp: number; command: CommandLog } : unknown diff --git a/packages/selenium-devtools/example/cucumber-test/cucumber.json b/packages/selenium-devtools/example/cucumber-test/cucumber.json new file mode 100644 index 0000000..b96a2d2 --- /dev/null +++ b/packages/selenium-devtools/example/cucumber-test/cucumber.json @@ -0,0 +1,12 @@ +{ + "default": { + "import": [ + "example/cucumber-test/features/support/setup.js", + "example/cucumber-test/features/support/world.js", + "example/cucumber-test/features/support/steps.js" + ], + "paths": ["example/cucumber-test/features/*.feature"], + "publishQuiet": true, + "format": ["progress"] + } +} diff --git a/packages/selenium-devtools/example/cucumber-test/features/login.feature b/packages/selenium-devtools/example/cucumber-test/features/login.feature new file mode 100644 index 0000000..e8eea3e --- /dev/null +++ b/packages/selenium-devtools/example/cucumber-test/features/login.feature @@ -0,0 +1,15 @@ +Feature: the-internet login flow + + Scenario: logs in with valid credentials and lands on /secure + Given I am on the login page + When I enter username "tomsmith" and password "SuperSecretPassword!" + And I submit the login form + Then I should be on the secure page + And I should see a flash message matching "You logged into a secure area" + + Scenario: rejects invalid username with an error flash + Given I am on the login page + When I enter username "foobar" and password "barfoo" + And I submit the login form + Then I should see a flash message matching "Your username is invalid" + And I should still be on the login page diff --git a/packages/selenium-devtools/example/cucumber-test/features/support/setup.js b/packages/selenium-devtools/example/cucumber-test/features/support/setup.js new file mode 100644 index 0000000..18f1e76 --- /dev/null +++ b/packages/selenium-devtools/example/cucumber-test/features/support/setup.js @@ -0,0 +1,12 @@ +/** + * Loads the @wdio/selenium-devtools plugin and configures it. + * + * Run from the package root: pnpm example:cucumber + */ + +import { DevTools } from '@wdio/selenium-devtools' + +DevTools.configure({ + screencast: { enabled: true, quality: 70, maxWidth: 1280, maxHeight: 720 }, + headless: true +}) diff --git a/packages/selenium-devtools/example/cucumber-test/features/support/steps.js b/packages/selenium-devtools/example/cucumber-test/features/support/steps.js new file mode 100644 index 0000000..59853c9 --- /dev/null +++ b/packages/selenium-devtools/example/cucumber-test/features/support/steps.js @@ -0,0 +1,41 @@ +import { strict as assert } from 'node:assert' +import { Given, When, Then } from '@cucumber/cucumber' +import { By, until } from 'selenium-webdriver' + +Given('I am on the login page', async function () { + await this.driver.get('https://the-internet.herokuapp.com/login') +}) + +When( + 'I enter username {string} and password {string}', + async function (username, password) { + await this.driver.findElement(By.id('username')).sendKeys(username) + await this.driver.findElement(By.id('password')).sendKeys(password) + } +) + +When('I submit the login form', async function () { + await this.driver.findElement(By.css('button[type="submit"]')).click() +}) + +Then('I should be on the secure page', async function () { + await this.driver.wait(until.urlContains('/secure'), 10_000) +}) + +Then( + 'I should see a flash message matching {string}', + async function (pattern) { + const flash = await this.driver.wait( + until.elementLocated(By.id('flash')), + 10_000 + ) + const text = await flash.getText() + assert.match(text, new RegExp(pattern, 'i')) + await this.driver.sleep(1500) + } +) + +Then('I should still be on the login page', async function () { + const url = await this.driver.getCurrentUrl() + assert.match(url, /\/login$/) +}) diff --git a/packages/selenium-devtools/example/cucumber-test/features/support/world.js b/packages/selenium-devtools/example/cucumber-test/features/support/world.js new file mode 100644 index 0000000..3514a68 --- /dev/null +++ b/packages/selenium-devtools/example/cucumber-test/features/support/world.js @@ -0,0 +1,24 @@ +import { setWorldConstructor, World, Before, After, setDefaultTimeout } from '@cucumber/cucumber' +import { Builder } from 'selenium-webdriver' + +setDefaultTimeout(60_000) + +class CustomWorld extends World { + constructor(options) { + super(options) + this.driver = null + } +} + +setWorldConstructor(CustomWorld) + +Before(async function () { + this.driver = await new Builder().forBrowser('chrome').build() +}) + +After(async function () { + if (this.driver) { + await this.driver.quit() + this.driver = null + } +}) diff --git a/packages/selenium-devtools/example/jest-test/jest.config.json b/packages/selenium-devtools/example/jest-test/jest.config.json new file mode 100644 index 0000000..7afd5dc --- /dev/null +++ b/packages/selenium-devtools/example/jest-test/jest.config.json @@ -0,0 +1,6 @@ +{ + "testEnvironment": "node", + "testMatch": ["/test/*.test.js"], + "testTimeout": 60000, + "transform": {} +} diff --git a/packages/selenium-devtools/example/jest-test/test/example.test.js b/packages/selenium-devtools/example/jest-test/test/example.test.js new file mode 100644 index 0000000..15e9d21 --- /dev/null +++ b/packages/selenium-devtools/example/jest-test/test/example.test.js @@ -0,0 +1,60 @@ +/** + * Login flow against the-internet.herokuapp.com under Jest. + * + * Run from the package root: pnpm example:jest + */ + +import { DevTools } from '@wdio/selenium-devtools' +import { Builder, By, until } from 'selenium-webdriver' + +const LOGIN_URL = 'https://the-internet.herokuapp.com/login' +const VALID_USERNAME = 'tomsmith' +const VALID_PASSWORD = 'SuperSecretPassword!' + +DevTools.configure({ + screencast: { enabled: true, quality: 70, maxWidth: 1280, maxHeight: 720 }, + headless: true +}) + +describe('the-internet login flow', () => { + let driver + + beforeEach(async () => { + driver = await new Builder().forBrowser('chrome').build() + }, 60000) + + afterEach(async () => { + if (driver) { + await driver.quit() + } + }) + + test('logs in with valid credentials and lands on /secure', async () => { + await driver.get(LOGIN_URL) + await driver.findElement(By.id('username')).sendKeys(VALID_USERNAME) + await driver.findElement(By.id('password')).sendKeys(VALID_PASSWORD) + await driver.findElement(By.css('button[type="submit"]')).click() + + await driver.wait(until.urlContains('/secure'), 10000) + const flash = await driver.wait(until.elementLocated(By.id('flash')), 10000) + const flashText = await flash.getText() + expect(flashText).toMatch(/You logged into a secure area/i) + + await driver.sleep(1500) + }, 60000) + + test('rejects invalid username with an error flash', async () => { + await driver.get(LOGIN_URL) + await driver.findElement(By.id('username')).sendKeys('foobar') + await driver.findElement(By.id('password')).sendKeys('barfoo') + await driver.findElement(By.css('button[type="submit"]')).click() + + const flash = await driver.wait(until.elementLocated(By.id('flash')), 10000) + const flashText = await flash.getText() + expect(flashText).toMatch(/Your username is invalid/i) + const url = await driver.getCurrentUrl() + expect(url).toMatch(/\/login$/) + + await driver.sleep(1500) + }, 60000) +}) diff --git a/packages/selenium-devtools/example/mocha-test/test/example.test.js b/packages/selenium-devtools/example/mocha-test/test/example.test.js new file mode 100644 index 0000000..be2a79e --- /dev/null +++ b/packages/selenium-devtools/example/mocha-test/test/example.test.js @@ -0,0 +1,43 @@ +/** + * Smoke test for @wdio/selenium-devtools. + * + * Run from the package root: pnpm example:mocha + */ + +import { strict as assert } from 'node:assert' +import { Builder, By, until } from 'selenium-webdriver' +import { DevTools } from '@wdio/selenium-devtools' + +DevTools.configure({ + screencast: { enabled: true, quality: 70, maxWidth: 1280, maxHeight: 720 }, + headless: true +}) + +describe('selenium-devtools smoke test', function () { + let driver + + before(async function () { + driver = await new Builder().forBrowser('chrome').build() + }) + + after(async function () { + if (driver) { + await driver.quit() + } + }) + + it('loads example.com and reads the heading', async function () { + await driver.get('https://example.com') + await driver.sleep(1500) + const heading = await driver.wait(until.elementLocated(By.css('h1')), 10000) + const text = await heading.getText() + assert.equal(text, 'Example Domain') + }) + + it('navigates and reads the page title', async function () { + await driver.get('https://example.org') + await driver.sleep(1500) + const title = await driver.getTitle() + assert.match(title, /Example/i) + }) +}) diff --git a/packages/selenium-devtools/example/vitest-test/setup.js b/packages/selenium-devtools/example/vitest-test/setup.js new file mode 100644 index 0000000..c7ee224 --- /dev/null +++ b/packages/selenium-devtools/example/vitest-test/setup.js @@ -0,0 +1,7 @@ +import { DevTools } from '@wdio/selenium-devtools' + +DevTools.configure({ + rerunCommand: 'npx vitest --testNamePattern "{{testName}}"', + screencast: { enabled: true, quality: 70, maxWidth: 1280, maxHeight: 720 }, + headless: true +}) diff --git a/packages/selenium-devtools/example/vitest-test/test/example.test.js b/packages/selenium-devtools/example/vitest-test/test/example.test.js new file mode 100644 index 0000000..01d91b4 --- /dev/null +++ b/packages/selenium-devtools/example/vitest-test/test/example.test.js @@ -0,0 +1,54 @@ +/** + * Login flow against the-internet.herokuapp.com under Vitest. + * + * Run from the package root: pnpm example:vitest + */ + +import { Builder, By, until } from 'selenium-webdriver' + +const LOGIN_URL = 'https://the-internet.herokuapp.com/login' +const VALID_USERNAME = 'tomsmith' +const VALID_PASSWORD = 'SuperSecretPassword!' + +describe('the-internet login flow', () => { + let driver + + beforeEach(async () => { + driver = await new Builder().forBrowser('chrome').build() + }) + + afterEach(async () => { + if (driver) { + await driver.quit() + } + }) + + test('logs in with valid credentials and lands on /secure', async () => { + await driver.get(LOGIN_URL) + await driver.findElement(By.id('username')).sendKeys(VALID_USERNAME) + await driver.findElement(By.id('password')).sendKeys(VALID_PASSWORD) + await driver.findElement(By.css('button[type="submit"]')).click() + + await driver.wait(until.urlContains('/secure'), 10000) + const flash = await driver.wait(until.elementLocated(By.id('flash')), 10000) + const flashText = await flash.getText() + expect(flashText).toMatch(/You logged into a secure area/i) + + await driver.sleep(1500) + }) + + test('rejects invalid username with an error flash', async () => { + await driver.get(LOGIN_URL) + await driver.findElement(By.id('username')).sendKeys('foobar') + await driver.findElement(By.id('password')).sendKeys('barfoo') + await driver.findElement(By.css('button[type="submit"]')).click() + + const flash = await driver.wait(until.elementLocated(By.id('flash')), 10000) + const flashText = await flash.getText() + expect(flashText).toMatch(/Your username is invalid/i) + const url = await driver.getCurrentUrl() + expect(url).toMatch(/\/login$/) + + await driver.sleep(1500) + }) +}) diff --git a/packages/selenium-devtools/example/vitest-test/vitest.config.js b/packages/selenium-devtools/example/vitest-test/vitest.config.js new file mode 100644 index 0000000..60e5961 --- /dev/null +++ b/packages/selenium-devtools/example/vitest-test/vitest.config.js @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + testTimeout: 60000, + hookTimeout: 60000, + include: ['example/vitest-test/test/**/*.test.js'], + setupFiles: ['./example/vitest-test/setup.js'], + // Single fork keeps one DevTools backend across files. The plugin + // patches selenium-webdriver at module load — running in worker threads + // would multiplex that against shared imports. + pool: 'forks', + poolOptions: { forks: { singleFork: true } } + } +}) diff --git a/packages/selenium-devtools/package.json b/packages/selenium-devtools/package.json new file mode 100644 index 0000000..bc811ad --- /dev/null +++ b/packages/selenium-devtools/package.json @@ -0,0 +1,68 @@ +{ + "name": "@wdio/selenium-devtools", + "version": "1.0.0", + "description": "Selenium WebDriver adapter for WebdriverIO DevTools — runner-agnostic, reuses existing backend, UI, and capture infrastructure", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/webdriverio/devtools.git", + "directory": "packages/selenium-devtools" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc", + "watch": "tsc --watch", + "clean": "rm -rf dist", + "lint": "eslint .", + "example:mocha": "mocha --require @wdio/selenium-devtools --timeout 60000 example/mocha-test/test/*.test.js", + "example:jest": "NODE_OPTIONS=--experimental-vm-modules jest --config example/jest-test/jest.config.json", + "example:vitest": "vitest run --config example/vitest-test/vitest.config.js", + "example:cucumber": "cucumber-js --config example/cucumber-test/cucumber.json" + }, + "keywords": [ + "selenium", + "selenium-webdriver", + "devtools", + "debugging", + "testing" + ], + "author": "WebdriverIO Team", + "license": "MIT", + "dependencies": { + "@wdio/devtools-backend": "workspace:*", + "@wdio/devtools-script": "workspace:*", + "@wdio/logger": "^9.6.0", + "stacktrace-parser": "^0.1.11", + "webdriverio": "^9.18.0", + "ws": "^8.18.3" + }, + "optionalDependencies": { + "fluent-ffmpeg": "^2.1.3" + }, + "devDependencies": { + "@cucumber/cucumber": "^11.1.0", + "@types/node": "25.5.2", + "@types/ws": "^8.18.1", + "chromedriver": "^147.0.1", + "jest": "^29.7.0", + "mocha": "^10.7.0", + "selenium-webdriver": "^4.27.0", + "typescript": "^6.0.2", + "vitest": "^2.1.9" + }, + "peerDependencies": { + "selenium-webdriver": ">=4.8.0" + } +} diff --git a/packages/selenium-devtools/src/assertPatcher.ts b/packages/selenium-devtools/src/assertPatcher.ts new file mode 100644 index 0000000..9cc4e8f --- /dev/null +++ b/packages/selenium-devtools/src/assertPatcher.ts @@ -0,0 +1,133 @@ +import { createRequire } from 'node:module' +import logger from '@wdio/logger' +import { ASSERT_PATCHED_SYMBOL, TRACKED_ASSERT_METHODS } from './constants.js' +import { getCallSourceFromStack } from './helpers/utils.js' +import type { CapturedCommand } from './types.js' + +const log = logger('@wdio/selenium-devtools:assertPatcher') +const require = createRequire(import.meta.url) + +function safeSerialize(value: any): any { + if (value === null || value === undefined) { + return value + } + if (value instanceof RegExp) { + return value.toString() + } + if (typeof value === 'function') { + return '[Function]' + } + if (typeof value === 'object') { + try { + return JSON.parse(JSON.stringify(value)) + } catch { + return String(value) + } + } + return value +} + +/** + * Patch `node:assert` so each tracked method emits a `CapturedCommand` to + * the supplied hook. Idempotent — calling twice doesn't double-wrap. + * + * Note: we patch BOTH the function-form (`assert(...)`) and the namespace + * methods (`assert.equal(...)`). User code that imported the methods BEFORE + * this patcher loaded will already have stale references — to be safe, + * the plugin's main entry imports node:assert before the user's test files. + */ +export function patchNodeAssert( + onCommand: (cmd: CapturedCommand) => void +): boolean { + let assertModule: any + try { + assertModule = require('node:assert') + } catch { + log.warn('node:assert not available — skipping assertion capture') + return false + } + + if ((assertModule as any)[ASSERT_PATCHED_SYMBOL]) { + return true + } + ;(assertModule as any)[ASSERT_PATCHED_SYMBOL] = true + + // Wrap each tracked method on `assert` and `assert.strict`. We don't + // overwrite `assert.strict.equal` separately because Node's strict + // namespace shares method bodies internally — patching the surface is + // enough. + const wrapMethod = (methodName: string) => { + const original = (assertModule as any)[methodName] + if (typeof original !== 'function') { + return + } + ;(assertModule as any)[methodName] = function patchedAssert( + ...args: any[] + ) { + const callInfo = getCallSourceFromStack() + const startedAt = Date.now() + const sanitizedArgs = args.map(safeSerialize) + + try { + const result = original.apply(this, args) + // Async assert methods (rejects/doesNotReject) return a Promise. + if (result && typeof result.then === 'function') { + return result.then( + (v: any) => { + onCommand({ + command: `assert.${methodName}`, + args: sanitizedArgs, + result: 'passed', + error: undefined, + callSource: callInfo.callSource, + timestamp: startedAt, + fromElement: false + }) + return v + }, + (err: any) => { + onCommand({ + command: `assert.${methodName}`, + args: sanitizedArgs, + result: undefined, + error: err instanceof Error ? err : new Error(String(err)), + callSource: callInfo.callSource, + timestamp: startedAt, + fromElement: false + }) + throw err + } + ) + } + onCommand({ + command: `assert.${methodName}`, + args: sanitizedArgs, + result: 'passed', + error: undefined, + callSource: callInfo.callSource, + timestamp: startedAt, + fromElement: false + }) + return result + } catch (err) { + onCommand({ + command: `assert.${methodName}`, + args: sanitizedArgs, + result: undefined, + error: err instanceof Error ? err : new Error(String(err)), + callSource: callInfo.callSource, + timestamp: startedAt, + fromElement: false + }) + throw err + } + } + } + + for (const m of TRACKED_ASSERT_METHODS) { + wrapMethod(m) + } + + log.info(`Patched ${TRACKED_ASSERT_METHODS.length} node:assert method(s)`) + return true +} diff --git a/packages/selenium-devtools/src/bidi.ts b/packages/selenium-devtools/src/bidi.ts new file mode 100644 index 0000000..8b2556d --- /dev/null +++ b/packages/selenium-devtools/src/bidi.ts @@ -0,0 +1,240 @@ +import { createRequire } from 'node:module' +import logger from '@wdio/logger' +import { LOG_SOURCES } from './constants.js' +import { chromeLogLevelToLogLevel, getRequestType } from './helpers/utils.js' +import type { BidiHandlerSinks, LogLevel, NetworkRequest } from './types.js' +import type { SessionCapturer } from './session.js' + +const log = logger('@wdio/selenium-devtools:bidi') + +function loadSeleniumSubmodule(subpath: string): any | null { + try { + const userRequire = createRequire(`${process.cwd()}/`) + return userRequire(`selenium-webdriver/${subpath}`) + } catch { + try { + const localRequire = createRequire(import.meta.url) + return localRequire(`selenium-webdriver/${subpath}`) + } catch { + return null + } + } +} + +// Sets webSocketUrl=true so the driver actually exposes the BiDi channel. +export function ensureBidiCapability(builder: any): void { + try { + const caps = + typeof builder?.getCapabilities === 'function' + ? builder.getCapabilities() + : null + if (!caps || typeof caps.set !== 'function') { + return + } + if (typeof caps.has === 'function' && caps.has('webSocketUrl')) { + return + } + caps.set('webSocketUrl', true) + log.info('Set webSocketUrl=true on builder capabilities (BiDi enabled)') + } catch (err) { + log.warn(`Failed to set webSocketUrl capability: ${(err as Error).message}`) + } +} + +// `--headless=old` (not `=new`) — `new` produces all-black frames under +// CDP `Page.startScreencast` on macOS (upstream Chrome bug). +export function ensureHeadlessChrome(builder: any): void { + try { + const caps = + typeof builder?.getCapabilities === 'function' + ? builder.getCapabilities() + : null + if (!caps || typeof caps.get !== 'function') { + return + } + const existing = caps.get('goog:chromeOptions') ?? {} + const args: string[] = Array.isArray(existing.args) + ? [...existing.args] + : [] + const hasHeadless = args.some( + (a) => typeof a === 'string' && a.startsWith('--headless') + ) + if (hasHeadless) { + return + } + args.push('--headless=old') + caps.set('goog:chromeOptions', { ...existing, args }) + log.info('Injected --headless=old into Chrome capabilities') + } catch (err) { + log.warn(`Failed to set headless Chrome option: ${(err as Error).message}`) + } +} + +// Returns true when at least one stream connected — caller disables the +// equivalent script-injection collectors to avoid duplicates. +export async function attachBidiHandlers( + driver: any, + sinks: BidiHandlerSinks +): Promise { + const logInspectorFactory = loadSeleniumSubmodule('bidi/logInspector') + const networkInspectorFactory = loadSeleniumSubmodule('bidi/networkInspector') + + let attached = 0 + + if (typeof logInspectorFactory === 'function') { + try { + const inspector = await logInspectorFactory(driver) + await inspector.onConsoleEntry((entry: any) => { + try { + const level = (entry?.level ?? entry?.type ?? 'info').toString() + const text = entry?.text ?? entry?.message ?? '' + sinks.pushConsoleLog({ + timestamp: Number(entry?.timestamp) || Date.now(), + type: chromeLogLevelToLogLevel(level) as LogLevel, + args: [text], + source: LOG_SOURCES.BROWSER + }) + } catch (err) { + log.warn(`onConsoleEntry handler threw: ${(err as Error).message}`) + } + }) + await inspector.onJavascriptException((exception: any) => { + try { + const text = + exception?.text ?? exception?.message ?? String(exception) + const trimmed = String(text).replace(/\s+/g, ' ').slice(0, 200) + log.warn( + `🐛 JS error in page: ${trimmed}${String(text).length > 200 ? '…' : ''}` + ) + sinks.pushConsoleLog({ + timestamp: Date.now(), + type: 'error', + args: [text], + source: LOG_SOURCES.BROWSER + }) + } catch (err) { + log.warn( + `onJavascriptException handler threw: ${(err as Error).message}` + ) + } + }) + attached++ + log.info('✓ BiDi LogInspector attached (console + JS exceptions)') + } catch (err) { + log.warn(`BiDi LogInspector attach failed: ${(err as Error).message}`) + } + } else { + log.info('selenium-webdriver/bidi/logInspector not available — skipping') + } + + if (typeof networkInspectorFactory === 'function') { + try { + const inspector = await networkInspectorFactory(driver) + const pending = new Map() + + await inspector.beforeRequestSent((event: any) => { + try { + const requestId = String(event?.request?.request ?? event?.id ?? '') + if (!requestId) { + return + } + const entry: NetworkRequest = { + id: requestId, + url: event?.request?.url ?? '', + method: event?.request?.method ?? 'GET', + requestHeaders: arrayHeadersToObject(event?.request?.headers), + timestamp: Date.now(), + startTime: Number(event?.timestamp ?? Date.now()), + type: getRequestType(event?.request?.url ?? '') + } + pending.set(requestId, entry) + sinks.pushNetworkRequest(entry) + } catch (err) { + log.warn(`beforeRequestSent threw: ${(err as Error).message}`) + } + }) + + await inspector.responseCompleted((event: any) => { + try { + const requestId = String(event?.request?.request ?? event?.id ?? '') + const previous = pending.get(requestId) + if (!previous) { + return + } + const finalized: NetworkRequest = { + ...previous, + status: Number(event?.response?.status) || previous.status, + statusText: event?.response?.statusText ?? previous.statusText, + responseHeaders: arrayHeadersToObject(event?.response?.headers), + type: getRequestType(previous.url, event?.response?.mimeType), + endTime: Number(event?.timestamp ?? Date.now()), + time: Number(event?.timestamp ?? Date.now()) - previous.startTime, + size: Number(event?.response?.bytesReceived) || undefined + } + pending.delete(requestId) + sinks.replaceNetworkRequest(requestId, finalized) + } catch (err) { + log.warn(`responseCompleted threw: ${(err as Error).message}`) + } + }) + + attached++ + log.info('✓ BiDi NetworkInspector attached (request + response)') + } catch (err) { + log.warn(`BiDi NetworkInspector attach failed: ${(err as Error).message}`) + } + } else { + log.info( + 'selenium-webdriver/bidi/networkInspector not available — skipping' + ) + } + + return attached > 0 +} + +// BiDi headers arrive as Array<{name, value:{value|type}}>; flatten to a +// lowercased dictionary. +function arrayHeadersToObject( + headers: any +): Record | undefined { + if (!Array.isArray(headers)) { + return undefined + } + const out: Record = {} + for (const h of headers) { + const name = String(h?.name ?? '').toLowerCase() + if (!name) { + continue + } + const v = h?.value + out[name] = + typeof v === 'string' + ? v + : typeof v?.value === 'string' + ? v.value + : JSON.stringify(v ?? '') + } + return out +} + +export function buildBidiSinks(capturer: SessionCapturer): BidiHandlerSinks { + return { + pushConsoleLog: (entry) => { + capturer.consoleLogs.push(entry) + capturer.sendUpstream('consoleLogs', [entry]) + }, + pushNetworkRequest: (entry) => { + capturer.networkRequests.push(entry) + capturer.sendUpstream('networkRequests', [entry]) + }, + replaceNetworkRequest: (id, entry) => { + const idx = capturer.networkRequests.findIndex((r: any) => r.id === id) + if (idx !== -1) { + capturer.networkRequests[idx] = entry + } else { + capturer.networkRequests.push(entry) + } + capturer.sendUpstream('networkRequests', [entry]) + } + } +} diff --git a/packages/selenium-devtools/src/constants.ts b/packages/selenium-devtools/src/constants.ts new file mode 100644 index 0000000..c507776 --- /dev/null +++ b/packages/selenium-devtools/src/constants.ts @@ -0,0 +1,166 @@ +/** + * Selenium WebDriver methods we don't want to surface as user commands. + * These are either internal lifecycle (quit, close, getSession), capability + * inspection (getCapabilities), or low-level helpers (sleep, schedule). + */ +export const INTERNAL_DRIVER_METHODS = [ + 'constructor', + 'getSession', + 'getCapabilities', + 'getExecutor', + 'execute', + 'schedule', + 'manage', + 'navigate', + 'switchTo', + 'actions', + 'wait', + 'sleep', + 'setFileDetector', + 'getNetworkConnection', + 'setNetworkConnection', + 'on', + 'once', + 'addListener', + 'removeListener', + 'emit', + 'eventNames', + /* Plumbing — selenium-webdriver itself calls these during BiDi/CDP setup. */ + 'getBidi', + 'getCdpTargets', + 'createCDPConnection', + 'getWsUrl', + /* These are wrapped separately — see patchSelenium quit/close interceptors. */ + 'quit', + 'close' +] as const + +/** + * WebElement methods we DO surface (everything else is skipped). + * Whitelist approach because WebElement's prototype carries fewer interesting + * action methods than WebDriver's, and skipping is cheaper. + */ +export const TRACKED_ELEMENT_METHODS = [ + 'click', + 'sendKeys', + 'clear', + 'submit', + 'getText', + 'getAttribute', + 'getCssValue', + 'getRect', + 'getTagName', + 'isDisplayed', + 'isEnabled', + 'isSelected' +] as const + +export const NAVIGATION_COMMANDS = [ + 'get', + 'navigate', + 'to', + 'back', + 'forward', + 'refresh' +] as const + +export const CONSOLE_METHODS = ['log', 'info', 'warn', 'error'] as const + +export const LOG_SOURCES = { + BROWSER: 'browser', + TEST: 'test', + TERMINAL: 'terminal' +} as const + +export const ANSI_REGEX = /\x1b\[[?]?[0-9;]*[A-Za-z]/g + +export const SPINNER_RE = /^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/u + +export const DEFAULTS = { + CID: '0-0', + SESSION_TITLE: 'Selenium Session', + FILE_NAME: 'selenium', + RETRIES: 0, + DURATION: 0 +} as const + +export const TIMING = { + UI_RENDER_DELAY: 150, + TEST_START_DELAY: 100, + SUITE_COMPLETE_DELAY: 200, + UI_CONNECTION_WAIT: 2000, + BROWSER_CLOSE_WAIT: 2000, + INITIAL_CONNECTION_WAIT: 500, + BROWSER_POLL_INTERVAL: 1000 +} as const + +export const TEST_STATE = { + PENDING: 'pending', + RUNNING: 'running', + PASSED: 'passed', + FAILED: 'failed', + SKIPPED: 'skipped' +} as const + +export const LOG_LEVEL_PATTERNS: ReadonlyArray<{ + level: 'trace' | 'debug' | 'info' | 'warn' | 'error' + pattern: RegExp +}> = [ + { level: 'trace', pattern: /\btrace\b/i }, + { level: 'debug', pattern: /\bdebug\b/i }, + { level: 'info', pattern: /\binfo\b/i }, + { level: 'warn', pattern: /\bwarn(ing)?\b/i }, + { level: 'error', pattern: /\berror\b/i } +] as const + +export const SCREENCAST_DEFAULTS = { + enabled: false, + captureFormat: 'jpeg' as const, + quality: 70, + maxWidth: 1280, + maxHeight: 720, + pollIntervalMs: 200 +} + +/** Test-state environment markers used by the rerun handshake. */ +export const REUSE_ENV = { + REUSE: 'DEVTOOLS_APP_REUSE', + HOST: 'DEVTOOLS_APP_HOST', + PORT: 'DEVTOOLS_APP_PORT', + RERUN_LABEL: 'DEVTOOLS_RERUN_LABEL', + RERUN_ENTRY_TYPE: 'DEVTOOLS_RERUN_ENTRY_TYPE' +} as const + +/** + * Decoded JPEG bytes below which a frame is treated as blank/uniform + * (Chrome's about:blank — solid colour compresses to <2KB; real renders >5KB). + */ +export const BLANK_FRAME_THRESHOLD_BYTES = 4_000 + +/** Per-prototype "already patched" guard for driverPatcher / assertPatcher. */ +export const PATCHED_SYMBOL = Symbol.for('@wdio/selenium-devtools/patched') + +/** Per-prototype guard for the (currently disabled) node:assert patcher. */ +export const ASSERT_PATCHED_SYMBOL = Symbol.for( + '@wdio/selenium-devtools/assert-patched' +) + +/** node:assert methods the (currently disabled) assertPatcher would wrap. */ +export const TRACKED_ASSERT_METHODS = [ + 'equal', + 'strictEqual', + 'deepEqual', + 'deepStrictEqual', + 'notEqual', + 'notStrictEqual', + 'notDeepEqual', + 'notDeepStrictEqual', + 'ok', + 'fail', + 'throws', + 'doesNotThrow', + 'rejects', + 'doesNotReject', + 'match', + 'doesNotMatch' +] as const diff --git a/packages/selenium-devtools/src/driverPatcher.ts b/packages/selenium-devtools/src/driverPatcher.ts new file mode 100644 index 0000000..2f39e85 --- /dev/null +++ b/packages/selenium-devtools/src/driverPatcher.ts @@ -0,0 +1,312 @@ +import { createRequire } from 'node:module' +import logger from '@wdio/logger' +import { + INTERNAL_DRIVER_METHODS, + PATCHED_SYMBOL, + TRACKED_ELEMENT_METHODS +} from './constants.js' +import { getCallSourceFromStack } from './helpers/utils.js' +import type { + DriverOriginals, + DriverPatcherHooks, + ElementOriginals +} from './types.js' + +const log = logger('@wdio/selenium-devtools:driverPatcher') + +const originals: DriverOriginals = {} +const elementOriginals: ElementOriginals = {} + +export function getDriverOriginals(): DriverOriginals { + return originals +} + +export function getElementOriginals(): ElementOriginals { + return elementOriginals +} + +// Resolve user's selenium-webdriver first, then fall back to our own. +function loadSeleniumWebdriver(): any | null { + try { + const userRequire = createRequire(`${process.cwd()}/`) + return userRequire('selenium-webdriver') + } catch { + try { + const localRequire = createRequire(import.meta.url) + return localRequire('selenium-webdriver') + } catch (err) { + log.warn( + `selenium-webdriver not found — devtools auto-attach disabled. (${(err as Error).message})` + ) + return null + } + } +} + +function isWebElementLike(v: any): boolean { + return ( + v && + typeof v === 'object' && + typeof v.getId === 'function' && + typeof v.click === 'function' + ) +} + +function safeSerialize(value: any): any { + if (value === null || value === undefined) { + return value + } + if (typeof value === 'function') { + return '[Function]' + } + if (isWebElementLike(value)) { + return webElementSummary(value) + } + if ( + typeof value === 'object' && + 'using' in value && + 'value' in value && + Object.keys(value).length === 2 + ) { + return `By.${value.using}(${JSON.stringify(value.value)})` + } + if (Array.isArray(value)) { + if (value.length > 0 && value.every(isWebElementLike)) { + return ` (count: ${value.length})` + } + return value.map(safeSerialize) + } + if (typeof value === 'object') { + try { + return JSON.parse(JSON.stringify(value)) + } catch { + return String(value) + } + } + return value +} + +function webElementSummary(el: any): string { + // `id_` is a Promise; some selenium versions stash the resolved value sync. + const peek = el?.id_?._value ?? el?.id_?.value ?? null + return peek ? `` : '' +} + +function wrapPrototype( + proto: object, + methodNames: Iterable, + fromElement: boolean, + hooks: DriverPatcherHooks +): string[] { + if ((proto as any)[PATCHED_SYMBOL]) { + return [] + } + ;(proto as any)[PATCHED_SYMBOL] = true + + const wrapped: string[] = [] + for (const methodName of methodNames) { + const original = (proto as any)[methodName] + if (typeof original !== 'function') { + continue + } + if (methodName === 'constructor' || methodName.startsWith('__')) { + continue + } + + ;(proto as any)[methodName] = function (...args: any[]): any { + const callInfo = getCallSourceFromStack() + const startedAt = Date.now() + const sanitizedArgs = args.map(safeSerialize) + const settle = (result: any, error: Error | undefined) => { + try { + hooks.onCommand({ + command: methodName, + args: sanitizedArgs, + result: error ? undefined : safeSerialize(result), + rawResult: error ? undefined : result, + error, + callSource: callInfo.callSource, + timestamp: startedAt, + fromElement + }) + } catch (hookErr) { + log.warn( + `onCommand hook threw for ${methodName}: ${(hookErr as Error).message}` + ) + } + } + + let result: any + try { + result = original.apply(this, args) + } catch (err) { + settle(undefined, err as Error) + throw err + } + + // CRITICAL: return the original thenable. findElement returns a + // WebElementPromise that carries sendKeys/click for chaining; a plain + // Promise from `.then(...)` would break `findElement(...).sendKeys(...)`. + if (result && typeof result.then === 'function') { + result.then( + (v: any) => settle(v, undefined), + (err: any) => settle(undefined, err as Error) + ) + return result + } + settle(result, undefined) + return result + } + + wrapped.push(methodName) + } + return wrapped +} + +export function patchSelenium(hooks: DriverPatcherHooks): boolean { + const sw = loadSeleniumWebdriver() + if (!sw) { + return false + } + + const Builder = sw.Builder + const WebDriver = sw.WebDriver + const WebElement = sw.WebElement + + if (!Builder || !WebDriver) { + log.warn( + 'selenium-webdriver loaded but Builder/WebDriver missing — version unsupported?' + ) + return false + } + + // Stash unwrapped originals before any patching. + const driverProto = WebDriver.prototype + if (typeof driverProto.takeScreenshot === 'function') { + const orig = driverProto.takeScreenshot + originals.takeScreenshot = (driver) => orig.call(driver) + } + if (typeof driverProto.executeScript === 'function') { + const orig = driverProto.executeScript + originals.executeScript = (driver, script, ...args) => + orig.call(driver, script, ...args) + } + if (typeof driverProto.manage === 'function') { + const orig = driverProto.manage + originals.manage = (driver) => orig.call(driver) + } + + const driverMethods = collectMethodNames(WebDriver.prototype) + const tracked = driverMethods.filter( + (m) => !INTERNAL_DRIVER_METHODS.includes(m as any) + ) + const wrappedDriver = wrapPrototype( + WebDriver.prototype, + tracked, + /* fromElement */ false, + hooks + ) + log.info(`Wrapped ${wrappedDriver.length} WebDriver method(s)`) + + // Lets onBeforeQuit flush async cleanup before runners that `process.exit()` + // tear down (those bypass node's beforeExit). + if (typeof driverProto.quit === 'function') { + const originalQuit = driverProto.quit + driverProto.quit = async function patchedQuit(this: any) { + if (hooks.onBeforeQuit) { + try { + await hooks.onBeforeQuit(this) + } catch (err) { + log.warn(`onBeforeQuit hook threw: ${(err as Error).message}`) + } + } + return originalQuit.call(this) + } + log.info('Wrapped WebDriver.quit (cleanup hook)') + } + + if (WebElement) { + const elProto = WebElement.prototype + if (typeof elProto.getText === 'function') { + const orig = elProto.getText + elementOriginals.getText = (el) => orig.call(el) + } + if (typeof elProto.getTagName === 'function') { + const orig = elProto.getTagName + elementOriginals.getTagName = (el) => orig.call(el) + } + + const wrappedEl = wrapPrototype( + WebElement.prototype, + TRACKED_ELEMENT_METHODS, + /* fromElement */ true, + hooks + ) + log.info(`Wrapped ${wrappedEl.length} WebElement method(s)`) + } + + if (!(Builder.prototype as any)[PATCHED_SYMBOL]) { + ;(Builder.prototype as any)[PATCHED_SYMBOL] = true + const originalBuild = Builder.prototype.build + Builder.prototype.build = function patchedBuild(this: any, ...args: any[]) { + if (hooks.onBeforeBuild) { + try { + hooks.onBeforeBuild(this) + } catch (err) { + log.warn(`onBeforeBuild hook threw: ${(err as Error).message}`) + } + } + const driver = originalBuild.apply(this, args) + try { + const result = hooks.onDriverCreated(driver) + if (result && typeof (result as Promise).then === 'function') { + ;(result as Promise).catch((err) => + log.warn(`onDriverCreated hook rejected: ${(err as Error).message}`) + ) + } + } catch (err) { + log.warn(`onDriverCreated hook threw: ${(err as Error).message}`) + } + + // Selenium 4: WebDriver is thenable. Extend `.then` so `await Builder.build()` + // also waits for the dashboard to connect. + const isThenable = driver && typeof (driver as any).then === 'function' + if (isThenable && hooks.waitForReady) { + const originalThen = (driver as any).then.bind(driver) + ;(driver as any).then = function patchedThen( + onFulfilled?: (value: any) => any, + onRejected?: (reason: any) => any + ) { + return originalThen(async (resolved: any) => { + try { + await hooks.waitForReady!() + } catch { + /* fall through — don't block forever on UI failures */ + } + return onFulfilled ? onFulfilled(resolved) : resolved + }, onRejected) + } + } + + return driver + } + log.info('Patched Builder.prototype.build') + } + + return true +} + +function collectMethodNames(proto: object): string[] { + const names = new Set() + let current = proto + while (current && current !== Object.prototype) { + for (const name of Object.getOwnPropertyNames(current)) { + const desc = Object.getOwnPropertyDescriptor(current, name) + if (desc && typeof desc.value === 'function') { + names.add(name) + } + } + current = Object.getPrototypeOf(current) + } + return [...names] +} diff --git a/packages/selenium-devtools/src/helpers/runtime.ts b/packages/selenium-devtools/src/helpers/runtime.ts new file mode 100644 index 0000000..eba0913 --- /dev/null +++ b/packages/selenium-devtools/src/helpers/runtime.ts @@ -0,0 +1,47 @@ +import { createRequire } from 'node:module' + +export function detectOwnVersion(): string { + try { + return createRequire(import.meta.url)('../../package.json').version + } catch { + return 'unknown' + } +} + +export function detectRunner(): string { + const argv = (process.argv[1] || '').toLowerCase() + if (argv.includes('mocha')) { + return 'mocha' + } + if (argv.includes('jest')) { + return 'jest' + } + if (argv.includes('jasmine')) { + return 'jasmine' + } + if (argv.includes('vitest')) { + return 'vitest' + } + if (argv.includes('cucumber')) { + return 'cucumber' + } + if (argv.endsWith('node') || argv.endsWith('node.exe')) { + return 'node' + } + return 'unknown' +} + +export function detectSeleniumVersion(): string | undefined { + const tryRead = (req: NodeRequire): string | undefined => { + try { + return req('selenium-webdriver/package.json').version + } catch { + return undefined + } + } + const fromUser = tryRead(createRequire(`${process.cwd()}/`)) + if (fromUser) { + return fromUser + } + return tryRead(createRequire(import.meta.url)) +} diff --git a/packages/selenium-devtools/src/helpers/suiteManager.ts b/packages/selenium-devtools/src/helpers/suiteManager.ts new file mode 100644 index 0000000..54277ec --- /dev/null +++ b/packages/selenium-devtools/src/helpers/suiteManager.ts @@ -0,0 +1,151 @@ +import { DEFAULTS, TEST_STATE } from '../constants.js' +import type { SuiteStats, TestStats } from '../types.js' +import type { TestReporter } from '../reporter.js' +import { generateStableUid } from './utils.js' + +// rootSuite = describe block (Mocha/Jest) or feature (Cucumber). +// currentParent points at the in-progress scenario sub-suite for Cucumber, +// or at rootSuite otherwise. Tests append to currentParent. +export class SuiteManager { + private rootSuite: SuiteStats | null = null + private currentParent: SuiteStats | null = null + + constructor(private testReporter: TestReporter) {} + + getOrCreateRootSuite(file: string, title: string): SuiteStats { + if (this.rootSuite) { + return this.rootSuite + } + + const suite: SuiteStats = { + uid: generateStableUid(file, title), + cid: DEFAULTS.CID, + title, + fullTitle: title, + file, + type: 'suite', + start: new Date(), + state: TEST_STATE.RUNNING, + end: null, + tests: [], + suites: [], + hooks: [], + _duration: DEFAULTS.DURATION + } + + this.rootSuite = suite + this.currentParent = suite + this.testReporter.onSuiteStart(suite) + return suite + } + + getRootSuite(): SuiteStats | null { + return this.rootSuite + } + + /** Where new tests are appended — root suite, or the open scenario sub-suite. */ + getCurrentParent(): SuiteStats | null { + return this.currentParent ?? this.rootSuite + } + + /** Open a Cucumber scenario as a sub-suite; steps attach until endScenarioSuite. */ + startScenarioSuite( + name: string, + file: string, + callSource?: string + ): SuiteStats | null { + if (!this.rootSuite) { + return null + } + const sub: SuiteStats = { + uid: generateStableUid(file, `${this.rootSuite.uid}::${name}`), + cid: DEFAULTS.CID, + title: name, + fullTitle: name, + file, + type: 'suite', + start: new Date(), + state: TEST_STATE.RUNNING, + end: null, + tests: [], + suites: [], + hooks: [], + _duration: DEFAULTS.DURATION, + callSource, + // Without `parent`, the dashboard's `!suite.parent` filter renders this + // sub-suite at the root too, duplicating it next to the feature. + parent: this.rootSuite.uid + } + this.rootSuite.suites = this.rootSuite.suites ?? [] + this.rootSuite.suites.push(sub) + this.currentParent = sub + this.testReporter.onSuiteStart(sub) + return sub + } + + endScenarioSuite(state: SuiteStats['state']): void { + const cur = this.currentParent + if (!cur || cur === this.rootSuite || cur.end) { + return + } + cur.end = new Date() + cur._duration = + cur.end.getTime() - (cur.start?.getTime() || cur.end.getTime()) + cur.state = state + this.testReporter.onSuiteEnd(cur) + this.currentParent = this.rootSuite + } + + setRootSuiteTitle(title: string, callSource?: string): void { + if (!this.rootSuite) { + return + } + let changed = false + if (title && this.rootSuite.title !== title) { + this.rootSuite.title = title + this.rootSuite.fullTitle = title + changed = true + } + if (callSource && this.rootSuite.callSource !== callSource) { + this.rootSuite.callSource = callSource + changed = true + } + if (changed) { + this.testReporter.updateSuites() + } + } + + addTest(test: TestStats): void { + const parent = this.getCurrentParent() + if (!parent) { + return + } + parent.tests.push(test) + this.testReporter.updateSuites() + } + + finalize(): void { + if (!this.rootSuite || this.rootSuite.end) { + return + } + this.rootSuite.end = new Date() + this.rootSuite._duration = + this.rootSuite.end.getTime() - + (this.rootSuite.start?.getTime() || this.rootSuite.end.getTime()) + + const failedDirect = this.rootSuite.tests.some( + (t) => typeof t !== 'string' && t.state === TEST_STATE.FAILED + ) + const failedNested = (this.rootSuite.suites ?? []).some( + (s) => s.state === TEST_STATE.FAILED + ) + this.rootSuite.state = + failedDirect || failedNested ? TEST_STATE.FAILED : TEST_STATE.PASSED + this.testReporter.onSuiteEnd(this.rootSuite) + } + + reset(): void { + this.rootSuite = null + this.currentParent = null + } +} diff --git a/packages/selenium-devtools/src/helpers/testManager.ts b/packages/selenium-devtools/src/helpers/testManager.ts new file mode 100644 index 0000000..2cc0ee4 --- /dev/null +++ b/packages/selenium-devtools/src/helpers/testManager.ts @@ -0,0 +1,177 @@ +import logger from '@wdio/logger' +import { DEFAULTS, TEST_STATE } from '../constants.js' +import type { SuiteStats, TestStats } from '../types.js' +import type { TestReporter } from '../reporter.js' +import type { SuiteManager } from './suiteManager.js' +import { deterministicUid, generateStableUid } from './utils.js' + +const log = logger('@wdio/selenium-devtools:testManager') + +/** + * Tracks the currently-active test inside the session suite. Two modes: + * - `session` (default): one synthetic test wraps the entire driver session. + * - `marked`: user calls startTest(name)/endTest(state); each pair adds a test. + * + * The proxy reads `getCurrentTest()` to tag each captured command with a uid. + */ +export class TestManager { + #currentTest: TestStats | null = null + #lastMarkedTest: TestStats | null = null + #mode: 'session' | 'marked' = 'session' + /** Set true the first time the user calls startMarkedTest. Once true we + * never auto-create the synthetic session test — orphan commands attach + * to the most-recently-marked test instead. */ + #userTookOver = false + + constructor( + private rootSuite: SuiteStats, + private testReporter: TestReporter, + private suiteManager: SuiteManager + ) {} + + /** Where new tests attach — current scenario sub-suite (Cucumber) or root. */ + private get suite(): SuiteStats { + return this.suiteManager.getCurrentParent() ?? this.rootSuite + } + + /** + * Returns the test that captured commands should attach to. Order: + * 1. The currently-running marked test, if any. + * 2. (only if the user has NOT yet used startMarkedTest) the synthetic + * session-wide test, lazily created on first command. + * 3. The most-recently-ended marked test (handles commands that fire + * between the user's it() blocks — chromedriver retries, hooks, etc). + */ + getOrEnsureTest(): TestStats | null { + if (this.#currentTest) { + return this.#currentTest + } + if (!this.#userTookOver) { + return this.#ensureSessionTest() + } + return this.#lastMarkedTest + } + + /** Lazily creates the synthetic session-wide test on first command. */ + #ensureSessionTest(): TestStats { + if (this.#currentTest && this.#mode === 'session') { + return this.#currentTest + } + + log.info('Creating synthetic session test (no startTest called yet)') + const title = DEFAULTS.SESSION_TITLE + const test: TestStats = { + uid: deterministicUid(this.suite.file, `session:${this.suite.uid}`), + cid: DEFAULTS.CID, + title, + fullTitle: title, + parent: this.suite.uid, + state: TEST_STATE.RUNNING, + start: new Date(), + end: null, + type: 'test', + file: this.suite.file, + retries: DEFAULTS.RETRIES, + _duration: DEFAULTS.DURATION, + hooks: [] + } + this.suite.tests.push(test) + this.#currentTest = test + this.testReporter.onTestStart(test) + return test + } + + /** + * Public alias retained for callers that want to force the synthetic test + * to exist. The internal code path uses `getOrEnsureTest()` instead. + */ + ensureSessionTest(): TestStats { + return this.#ensureSessionTest() + } + + /** + * Switch into marked mode and start a new test. The first time this is + * called, any pre-existing synthetic session test is removed from the suite + * (along with any commands that referenced it) — once the user takes over + * the test boundaries, the synthetic just adds noise. + */ + startMarkedTest( + name: string, + opts: { file?: string; callSource?: string } = {} + ): TestStats { + if (!this.#userTookOver) { + this.#userTookOver = true + // Drop the synthetic session test if it was lazy-created during the + // gap between driver creation and the user's first startTest. Any + // commands captured against it stay on disk in the worker buffer but + // are no longer reachable from the suite tree — cleaner UI. + if (this.#currentTest && this.#mode === 'session') { + log.info('Removing synthetic session test (user has taken over)') + const idx = this.suite.tests.indexOf(this.#currentTest) + if (idx !== -1) { + this.suite.tests.splice(idx, 1) + } + this.#currentTest = null + } + } + if (this.#mode === 'marked' && this.#currentTest) { + this.endCurrent('passed') + } + + this.#mode = 'marked' + const file = opts.file || this.suite.file + const test: TestStats = { + // Scope by parent so two suites with the same test/step name don't + // collide on signatureCounter disambiguation across rerun processes. + uid: generateStableUid(file, `${this.suite.uid}::${name}`), + cid: DEFAULTS.CID, + title: name, + fullTitle: name, + parent: this.suite.uid, + state: TEST_STATE.RUNNING, + start: new Date(), + end: null, + type: 'test', + file, + retries: DEFAULTS.RETRIES, + _duration: DEFAULTS.DURATION, + hooks: [], + callSource: opts.callSource + } + log.info( + `Started marked test "${name}" (callSource: ${opts.callSource || 'n/a'})` + ) + this.suite.tests.push(test) + this.#currentTest = test + this.#lastMarkedTest = test + this.testReporter.onTestStart(test) + return test + } + + endCurrent(state: TestStats['state']): void { + const test = this.#currentTest + if (!test) { + return + } + test.state = state + test.end = new Date() + test._duration = test.end.getTime() - (test.start?.getTime() ?? Date.now()) + this.testReporter.onTestEnd(test) + this.#currentTest = null + } + + getCurrentTest(): TestStats | null { + return this.#currentTest + } + + /** Called when the driver session is closing (process exit / quit). */ + finalizeSession(): void { + if (this.#currentTest) { + this.endCurrent( + this.#currentTest.state === TEST_STATE.RUNNING + ? 'passed' + : this.#currentTest.state + ) + } + } +} diff --git a/packages/selenium-devtools/src/helpers/utils.ts b/packages/selenium-devtools/src/helpers/utils.ts new file mode 100644 index 0000000..3f023a2 --- /dev/null +++ b/packages/selenium-devtools/src/helpers/utils.ts @@ -0,0 +1,222 @@ +import * as net from 'node:net' +import { parse as parseStackTrace } from 'stacktrace-parser' +import logger from '@wdio/logger' +import { ANSI_REGEX, LOG_LEVEL_PATTERNS, LOG_SOURCES } from '../constants.js' +import type { ConsoleLog, LogLevel } from '../types.js' + +const log = logger('@wdio/selenium-devtools:utils') + +export const stripAnsiCodes = (text: string): string => + text.replace(ANSI_REGEX, '') + +export function detectLogLevel(text: string): LogLevel { + const normalised = stripAnsiCodes(text).toLowerCase() + for (const { level, pattern } of LOG_LEVEL_PATTERNS) { + if (pattern.test(normalised)) { + return level + } + } + return 'log' +} + +export function createConsoleLogEntry( + type: LogLevel, + args: any[], + source: string = LOG_SOURCES.TEST +): ConsoleLog { + return { timestamp: Date.now(), type, args, source } +} + +export function chromeLogLevelToLogLevel( + level: string | { value?: number; name?: string } +): LogLevel { + const levelName = ( + typeof level === 'object' ? (level?.name ?? '') : (level ?? '') + ).toUpperCase() + switch (levelName) { + case 'SEVERE': + return 'error' + case 'WARNING': + return 'warn' + case 'INFO': + return 'info' + case 'DEBUG': + return 'debug' + default: + return 'log' + } +} + +const signatureCounters = new Map() + +export function generateStableUid(file: string, name: string): string { + const signature = `${file}::${name}` + const count = signatureCounters.get(signature) || 0 + signatureCounters.set(signature, count + 1) + const hashInput = count > 0 ? `${signature}::${count}` : signature + const hash = hashInput + .split('') + .reduce((acc, char) => ((acc << 5) - acc + char.charCodeAt(0)) | 0, 0) + return `stable-${Math.abs(hash).toString(36)}` +} + +export function deterministicUid(...parts: string[]): string { + const hash = parts + .join('::') + .split('') + .reduce((acc, char) => ((acc << 5) - acc + char.charCodeAt(0)) | 0, 0) + return `stable-${Math.abs(hash).toString(36)}` +} + +export function resetSignatureCounters() { + signatureCounters.clear() +} + +function isUserCodeFrame(frame: { + file?: string | null +}): frame is { file: string } { + const { file } = frame + return !!( + file && + !file.includes('/node_modules/') && + !file.includes('') && + !file.includes('node:internal') && + !file.includes('/dist/') && + !file.endsWith('/index.js') + ) +} + +function normalizeFilePath(filePath: string): string { + return filePath.replace(/^file:\/\//, '').split(':')[0] +} + +export function getCallSourceFromStack(): { + filePath: string | undefined + callSource: string +} { + const stack = new Error().stack + if (!stack) { + return { filePath: undefined, callSource: 'unknown:0' } + } + + const frame = parseStackTrace(stack).find(isUserCodeFrame) + if (!frame?.file) { + return { filePath: undefined, callSource: 'unknown:0' } + } + + const filePath = normalizeFilePath(frame.file) + return { filePath, callSource: `${filePath}:${frame.lineNumber ?? 0}` } +} + +// Source-scan for `it/test/specify('title', ...)` (or `describe/context/suite` +// when kind='suite'). Stack-walking from inside the runner's beforeEach +// hooks doesn't reach the user's test body. +import * as fs from 'node:fs' + +export function findTestLineInFile( + filePath: string, + title: string, + kind: 'test' | 'suite' = 'test' +): number | null { + try { + if (!fs.existsSync(filePath)) { + return null + } + const lines = fs.readFileSync(filePath, 'utf-8').split('\n') + const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const keywords = + kind === 'suite' ? 'describe|context|suite' : 'it|test|specify' + const re = new RegExp(`\\b(?:${keywords})\\s*\\(\\s*['"\`]${escaped}['"\`]`) + for (let i = 0; i < lines.length; i++) { + if (re.test(lines[i])) { + return i + 1 + } + } + } catch { + /* ignore — fall back to file:0 */ + } + return null +} + +export function isPortInUse(port: number, hostname: string): Promise { + return new Promise((resolve) => { + const server = net.createServer() + server.once('error', () => resolve(true)) + server.once('listening', () => server.close(() => resolve(false))) + server.listen(port, hostname) + }) +} + +export async function findFreePort( + startPort: number, + hostname: string +): Promise { + let port = startPort + while (await isPortInUse(port, hostname)) { + log.warn(`Port ${port} is in use, trying ${port + 1}...`) + port++ + } + return port +} + +/** + * Capture the command line that launched the current process so the UI's + * "rerun" button can re-execute the same script. Falls back to the raw + * argv when npm script context is unavailable. + */ +/** Derive a human-readable request type from URL and MIME type. */ +export function getRequestType(url: string, mimeType?: string): string { + const contentType = mimeType?.toLowerCase() ?? '' + const urlLower = url.toLowerCase() + if (contentType.includes('text/html')) { + return 'document' + } + if (contentType.includes('text/css')) { + return 'stylesheet' + } + if ( + contentType.includes('javascript') || + contentType.includes('ecmascript') + ) { + return 'script' + } + if (contentType.includes('image/')) { + return 'image' + } + if (contentType.includes('font/') || contentType.includes('woff')) { + return 'font' + } + if (contentType.includes('application/json')) { + return 'fetch' + } + if (urlLower.endsWith('.html') || urlLower.endsWith('.htm')) { + return 'document' + } + if (urlLower.endsWith('.css')) { + return 'stylesheet' + } + if (urlLower.endsWith('.js') || urlLower.endsWith('.mjs')) { + return 'script' + } + if (/\.(png|jpg|jpeg|gif|svg|webp|ico)$/.test(urlLower)) { + return 'image' + } + if (/\.(woff|woff2|ttf|eot|otf)$/.test(urlLower)) { + return 'font' + } + return 'xhr' +} + +export function captureLaunchCommand(): string { + const npmScript = process.env.npm_lifecycle_event + const npmConfigUserAgent = process.env.npm_config_user_agent ?? '' + if (npmScript) { + const tool = npmConfigUserAgent.startsWith('pnpm') + ? 'pnpm' + : npmConfigUserAgent.startsWith('yarn') + ? 'yarn' + : 'npm' + return tool === 'npm' ? `npm run ${npmScript}` : `${tool} ${npmScript}` + } + return [process.argv0, ...process.argv.slice(1)].join(' ') +} diff --git a/packages/selenium-devtools/src/helpers/videoEncoder.ts b/packages/selenium-devtools/src/helpers/videoEncoder.ts new file mode 100644 index 0000000..ef662bf --- /dev/null +++ b/packages/selenium-devtools/src/helpers/videoEncoder.ts @@ -0,0 +1,123 @@ +// VP8/WebM encoder for screencast frames. + +import fs from 'node:fs/promises' +import path from 'node:path' +import os from 'node:os' +import { createRequire } from 'node:module' + +import logger from '@wdio/logger' + +import type { ScreencastFrame, ScreencastOptions } from '../types.js' + +const require = createRequire(import.meta.url) +const log = logger('@wdio/selenium-devtools:VideoEncoder') + +export async function encodeToVideo( + frames: ScreencastFrame[], + outputPath: string, + options: Pick = {} +): Promise { + if (frames.length === 0) { + throw new Error('VideoEncoder: no frames to encode') + } + + const span = frames[frames.length - 1].timestamp - frames[0].timestamp + const totalBytes = frames.reduce( + (sum, f) => sum + Math.floor((f.data?.length ?? 0) * 0.75), + 0 + ) + log.info( + `🎬 Encoding ${frames.length} frame(s), captured over ${(span / 1000).toFixed(1)}s ` + + `(~${(totalBytes / 1024 / 1024).toFixed(1)} MB raw)` + ) + + let ffmpeg: any + try { + ffmpeg = require('fluent-ffmpeg') + } catch { + throw new Error( + 'VideoEncoder: fluent-ffmpeg is required for screencast encoding. ' + + 'Install it with: npm install fluent-ffmpeg' + ) + } + + const ext = options.captureFormat === 'png' ? 'png' : 'jpg' + const tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'selenium-devtools-screencast-') + ) + + try { + const manifestLines: string[] = ['ffconcat version 1.0'] + + for (let i = 0; i < frames.length; i++) { + const frameName = `frame-${String(i).padStart(6, '0')}.${ext}` + const framePath = path.join(tmpDir, frameName) + await fs.writeFile(framePath, Buffer.from(frames[i].data, 'base64')) + const nextTs = frames[i + 1]?.timestamp ?? frames[i].timestamp + 100 + const durationSecs = Math.max((nextTs - frames[i].timestamp) / 1000, 0.01) + manifestLines.push(`file '${framePath}'`) + manifestLines.push(`duration ${durationSecs.toFixed(6)}`) + } + + const lastFramePath = path.join( + tmpDir, + `frame-${String(frames.length - 1).padStart(6, '0')}.${ext}` + ) + manifestLines.push(`file '${lastFramePath}'`) + + const manifestPath = path.join(tmpDir, 'manifest.txt') + await fs.writeFile(manifestPath, manifestLines.join('\n')) + + log.info(`encoding ${frames.length} frames → ${outputPath}`) + + await new Promise((resolve, reject) => { + ffmpeg() + .input(manifestPath) + .inputOptions(['-f', 'concat', '-safe', '0']) + .videoCodec('libvpx') + .outputOptions([ + '-b:v', + '1M', + '-pix_fmt', + 'yuv420p', + // CFR @ 10fps — VFR WebMs don't write Cues reliably, so the + // dashboard's