From df2bf12494a0a1501974b9c9f40e46609becb3fb Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Mon, 12 Oct 2026 17:21:28 +0530 Subject: [PATCH 01/18] fix(selenium-devtools): capture a plain script and let it exit A runnerless script that never called DevTools.startTest dropped every command: only a start call created the TestManager, so the "Selenium Session" row the README promises never appeared. One is now created for a script whose runner is undetected. Such a script also never exited. Teardown runs on beforeExit, which the open worker socket stopped from firing; the socket is now unref'd once connected and re-ref'd while closing, so the close still completes. --- .changeset/selenium-plain-script-capture.md | 5 + .../selenium-devtools/src/helpers/runtime.ts | 9 +- packages/selenium-devtools/src/index.ts | 2 + packages/selenium-devtools/src/session.ts | 14 +++ .../selenium-devtools/src/test-management.ts | 13 ++- packages/selenium-devtools/src/types.ts | 4 + .../tests/plain-script.test.ts | 91 +++++++++++++++++++ 7 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 .changeset/selenium-plain-script-capture.md create mode 100644 packages/selenium-devtools/tests/plain-script.test.ts diff --git a/.changeset/selenium-plain-script-capture.md b/.changeset/selenium-plain-script-capture.md new file mode 100644 index 00000000..0a9d8c65 --- /dev/null +++ b/.changeset/selenium-plain-script-capture.md @@ -0,0 +1,5 @@ +--- +'@wdio/selenium-devtools': patch +--- + +A plain `node script.js` run with no test runner is now captured without calling `DevTools.startTest`: its commands land under the "Selenium Session" row the README describes, where before every command was dropped. Such a script also exits once its work is done, where before the open dashboard connection kept the process alive indefinitely, which also left a rerun of a plain script running after it finished. diff --git a/packages/selenium-devtools/src/helpers/runtime.ts b/packages/selenium-devtools/src/helpers/runtime.ts index eba09138..1fc21133 100644 --- a/packages/selenium-devtools/src/helpers/runtime.ts +++ b/packages/selenium-devtools/src/helpers/runtime.ts @@ -1,5 +1,7 @@ import { createRequire } from 'node:module' +import type { DetectedRunner } from '../types.js' + export function detectOwnVersion(): string { try { return createRequire(import.meta.url)('../../package.json').version @@ -8,7 +10,7 @@ export function detectOwnVersion(): string { } } -export function detectRunner(): string { +export function detectRunner(): DetectedRunner { const argv = (process.argv[1] || '').toLowerCase() if (argv.includes('mocha')) { return 'mocha' @@ -31,6 +33,11 @@ export function detectRunner(): string { return 'unknown' } +/** A plain script: no runner hook declares its test boundaries. */ +export function isRunnerless(runner: DetectedRunner): boolean { + return runner === 'node' || runner === 'unknown' +} + export function detectSeleniumVersion(): string | undefined { const tryRead = (req: NodeRequire): string | undefined => { try { diff --git a/packages/selenium-devtools/src/index.ts b/packages/selenium-devtools/src/index.ts index 25ce477a..3d59ae85 100644 --- a/packages/selenium-devtools/src/index.ts +++ b/packages/selenium-devtools/src/index.ts @@ -32,6 +32,7 @@ import { startScenario as tmStartScenario, endScenario as tmEndScenario, flushPendingTestActions as tmFlushPendingTestActions, + ensureRunnerlessTracking, buildStartTestMeta, type StartTestMeta, type StartScenarioMeta, @@ -520,6 +521,7 @@ class SeleniumDevToolsPlugin { #flushPendingTestActions() { tmFlushPendingTestActions(this.#getInternals()) + ensureRunnerlessTracking(this.#getInternals(), DETECTED_RUNNER) // The first test's startTest fires before the driver/capturer exists, so // its per-test boundary is recorded here once capture is live. if (this.#options.traceGranularity === 'test') { diff --git a/packages/selenium-devtools/src/session.ts b/packages/selenium-devtools/src/session.ts index 99eb4481..6450cce8 100644 --- a/packages/selenium-devtools/src/session.ts +++ b/packages/selenium-devtools/src/session.ts @@ -1,3 +1,5 @@ +import type { IncomingMessage } from 'node:http' + import logger from '@wdio/logger' import { COLLECTOR_READY_EXPRESSION, @@ -40,6 +42,7 @@ export class SessionCapturer extends SessionCapturerBase { #clientConnected = false #clientConnectedWaiters: Array<() => void> = [] #onClientDisconnected?: () => void + #socket: IncomingMessage['socket'] | undefined constructor( devtoolsOptions: { hostname?: string; port?: number } = {}, @@ -47,6 +50,11 @@ export class SessionCapturer extends SessionCapturerBase { ) { super(devtoolsOptions) this.#driver = driver + // Teardown runs on `beforeExit`, which a ref'd socket stops from ever firing. + this.ws?.once('upgrade', (res: IncomingMessage) => { + this.#socket = res.socket + this.#socket.unref() + }) // Skip console patching when running under Jest's CustomConsole / Vitest — // those reroute writes through their own console, which causes our patched @@ -97,6 +105,12 @@ export class SessionCapturer extends SessionCapturerBase { this.#driver = driver } + /** Re-ref'd first so the close handshake keeps the process alive until it lands. */ + override async closeWebSocket(): Promise { + this.#socket?.ref() + await super.closeWebSocket() + } + awaitClientConnected(): Promise { if (this.#clientConnected) { return Promise.resolve() diff --git a/packages/selenium-devtools/src/test-management.ts b/packages/selenium-devtools/src/test-management.ts index e62d17de..b8b6cd96 100644 --- a/packages/selenium-devtools/src/test-management.ts +++ b/packages/selenium-devtools/src/test-management.ts @@ -10,12 +10,13 @@ import logger from '@wdio/logger' import { TestManager } from './helpers/testManager.js' +import { isRunnerless } from './helpers/runtime.js' import { getCallSourceFromStack } from './helpers/utils.js' import { DEFAULTS } from './constants.js' import type { SessionCapturer } from './session.js' import type { TestReporter } from './reporter.js' import type { SuiteManager } from './helpers/suiteManager.js' -import type { TestStats } from './types.js' +import type { DetectedRunner, TestStats } from './types.js' import type { RetryTracker } from '@wdio/devtools-core' const log = logger('@wdio/selenium-devtools:test-management') @@ -256,6 +257,16 @@ export function ensureSuiteAndTestManager( } } +/** Without a TestManager every command is dropped, so a plain script gets the session one. */ +export function ensureRunnerlessTracking( + ctx: TestManagementCtx, + runner: DetectedRunner +): void { + if (!ctx.testManager && isRunnerless(runner)) { + ensureSuiteAndTestManager(ctx, DEFAULTS.SESSION_TITLE) + } +} + /** Apply any startTest/endTest calls buffered before testManager existed. */ export function flushPendingTestActions(ctx: TestManagementCtx): void { if (ctx.pendingTestActions.length === 0) { diff --git a/packages/selenium-devtools/src/types.ts b/packages/selenium-devtools/src/types.ts index 784daa1c..7de5b1de 100644 --- a/packages/selenium-devtools/src/types.ts +++ b/packages/selenium-devtools/src/types.ts @@ -58,6 +58,10 @@ import type { } from '@wdio/devtools-shared' export type { ScreencastFrame, ScreencastOptions } from '@wdio/devtools-shared' +/** What `detectRunner` reads off the process; `node` and `unknown` mean no runner. */ +export type DetectedRunner = + 'mocha' | 'jest' | 'jasmine' | 'vitest' | 'cucumber' | 'node' | 'unknown' + /** * Minimal shape of a selenium-webdriver `WebDriver` instance that the plugin * relies on. We don't import the type from selenium-webdriver to avoid a hard diff --git a/packages/selenium-devtools/tests/plain-script.test.ts b/packages/selenium-devtools/tests/plain-script.test.ts new file mode 100644 index 00000000..2e34114a --- /dev/null +++ b/packages/selenium-devtools/tests/plain-script.test.ts @@ -0,0 +1,91 @@ +import { createServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import { Socket } from 'node:net' + +import { RetryTracker } from '@wdio/devtools-core' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { WebSocketServer } from 'ws' + +import { SuiteManager } from '../src/helpers/suiteManager.js' +import { isRunnerless } from '../src/helpers/runtime.js' +import { TestReporter } from '../src/reporter.js' +import { SessionCapturer } from '../src/session.js' +import { + ensureRunnerlessTracking, + type TestManagementCtx +} from '../src/test-management.js' +import type { DetectedRunner } from '../src/types.js' + +function makeCtx(): TestManagementCtx { + const testReporter = new TestReporter(vi.fn()) + return { + retryTracker: new RetryTracker(), + testReporter, + sessionCapturer: undefined, + suiteManager: new SuiteManager(testReporter), + testManager: undefined, + testFilePath: undefined, + pendingTestActions: [], + pendingScenario: null + } +} + +describe('a plain script that never calls startTest', () => { + // Regression: only a start call created the TestManager, so a plain script dropped every command. + it.each(['unknown', 'node'])( + 'gets a session test under runner %s', + (runner) => { + const ctx = makeCtx() + ensureRunnerlessTracking(ctx, runner) + expect(ctx.testManager?.getOrEnsureTest()?.title).toBe('Selenium Session') + } + ) + + it.each(['mocha', 'jest', 'cucumber'])( + 'leaves %s to declare its own test boundaries', + (runner) => { + const ctx = makeCtx() + ensureRunnerlessTracking(ctx, runner) + expect(ctx.testManager).toBeUndefined() + expect(isRunnerless(runner)).toBe(false) + } + ) +}) + +describe('the worker socket', () => { + const servers: Array<() => Promise> = [] + afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(servers.splice(0).map((close) => close())) + }) + + async function backend(): Promise { + const server = createServer() + const wss = new WebSocketServer({ server }) + await new Promise((r) => server.listen(0, '127.0.0.1', r)) + servers.push(async () => { + for (const c of wss.clients) { + c.terminate() + } + wss.close() + await new Promise((r) => server.close(r)) + }) + return (server.address() as AddressInfo).port + } + + // Regression: a ref'd socket kept `beforeExit` from firing, so a finished plain script hung. + it('does not hold the process open, but holds it while closing', async () => { + const unref = vi.spyOn(Socket.prototype, 'unref') + const ref = vi.spyOn(Socket.prototype, 'ref') + const capturer = new SessionCapturer({ + hostname: '127.0.0.1', + port: await backend() + }) + await capturer.waitForConnection(2000) + expect(unref).toHaveBeenCalled() + + await capturer.closeWebSocket() + expect(ref).toHaveBeenCalled() + capturer.cleanup() + }) +}) From 4e1378e6898afea54479c263059a46f56ee81f9b Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Mon, 12 Oct 2026 17:21:28 +0530 Subject: [PATCH 02/18] refactor(shared): one replaceCommand rule for the app and the backend The app and the backend's baseline store each carried their own copy of how a replaceCommand payload finds its row: by stable id, else the last row carrying the old timestamp. It now lives once in shared, with a test pinning the case that matters: two rows completing in one millisecond. --- packages/app/src/controller/contextUpdates.ts | 29 +-------------- packages/backend/src/baselineStore.ts | 13 +------ packages/shared/src/command-replace.ts | 34 +++++++++++++++++ packages/shared/src/index.ts | 1 + packages/shared/tests/command-replace.test.ts | 37 +++++++++++++++++++ 5 files changed, 75 insertions(+), 39 deletions(-) create mode 100644 packages/shared/src/command-replace.ts create mode 100644 packages/shared/tests/command-replace.test.ts diff --git a/packages/app/src/controller/contextUpdates.ts b/packages/app/src/controller/contextUpdates.ts index 41b2a8d0..c17a4dff 100644 --- a/packages/app/src/controller/contextUpdates.ts +++ b/packages/app/src/controller/contextUpdates.ts @@ -9,39 +9,12 @@ import { deviceFromCapabilities, - type CommandLog, type NetworkRequest, type Metadata, type MetadataBySession } from '@wdio/devtools-shared' -/** - * Replace an existing command entry (matched first by stable `id`, then by - * `timestamp` as a fallback for runners that don't surface ids). When no - * match is found, the new entry is appended. - */ -export function replaceCommand( - current: CommandLog[], - oldTimestamp: number, - newCommand: CommandLog -): CommandLog[] { - 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 next = [...current] - next[idx] = newCommand - return next - } - return [...current, newCommand] -} +export { replaceCommand } from '@wdio/devtools-shared' /** * Merge incoming network requests into the current list, deduping by `id`. diff --git a/packages/backend/src/baselineStore.ts b/packages/backend/src/baselineStore.ts index 85b1ebf1..3c73ace0 100644 --- a/packages/backend/src/baselineStore.ts +++ b/packages/backend/src/baselineStore.ts @@ -3,7 +3,7 @@ * into an accumulator, then time-window-filters per test/suite on demand. */ import logger from '@wdio/logger' -import type { Metadata } from '@wdio/devtools-shared' +import { replaceCommandIndex, type Metadata } from '@wdio/devtools-shared' import type { ActiveRun, @@ -102,18 +102,9 @@ class BaselineStore { return this.#activeRun } - // Mirrors the app's command replacement: match by stable `id`, then by the - // old timestamp, appending only when neither locates the original. #replaceCommand(oldTimestamp: number | undefined, command: CommandLogLike) { const cmds = this.#activeRun.commands - const newId = (command as { id?: number }).id - let idx = -1 - if (typeof newId === 'number') { - idx = cmds.findIndex((c) => (c as { id?: number }).id === newId) - } - if (idx === -1 && oldTimestamp !== undefined) { - idx = cmds.map((c) => c.timestamp).lastIndexOf(oldTimestamp) - } + const idx = replaceCommandIndex(cmds, oldTimestamp, command) if (idx === -1) { cmds.push(command) } else { diff --git a/packages/shared/src/command-replace.ts b/packages/shared/src/command-replace.ts new file mode 100644 index 00000000..5bf30f3f --- /dev/null +++ b/packages/shared/src/command-replace.ts @@ -0,0 +1,34 @@ +import type { CommandLog } from './types.js' + +/** Same stable `id`, else the last row with the old timestamp (two rows can share a millisecond), else -1. */ +export function replaceCommandIndex( + current: readonly CommandLog[], + oldTimestamp: number | undefined, + command: CommandLog +): number { + if (typeof command.id === 'number') { + const byId = current.findIndex((c) => c.id === command.id) + if (byId !== -1) { + return byId + } + } + return oldTimestamp === undefined + ? -1 + : current.map((c) => c.timestamp).lastIndexOf(oldTimestamp) +} + +/** A copy of `current` with the replacement applied, appended when it locates nothing. */ +export function replaceCommand( + current: readonly CommandLog[], + oldTimestamp: number | undefined, + command: CommandLog +): CommandLog[] { + const next = [...current] + const at = replaceCommandIndex(current, oldTimestamp, command) + if (at === -1) { + next.push(command) + } else { + next[at] = command + } + return next +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 841d024a..cd9963e2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -8,6 +8,7 @@ export * from './console.js' export * from './device.js' export * from './element-scripts.js' export * from './collector.js' +export * from './command-replace.js' export * from './files.js' export * from './image.js' export * from './locator-dialect.js' diff --git a/packages/shared/tests/command-replace.test.ts b/packages/shared/tests/command-replace.test.ts new file mode 100644 index 00000000..8ad538cc --- /dev/null +++ b/packages/shared/tests/command-replace.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' + +import { replaceCommand, replaceCommandIndex } from '../src/command-replace.js' +import type { CommandLog } from '../src/types.js' + +const row = (command: string, timestamp: number, id?: number): CommandLog => ({ + command, + args: [], + timestamp, + ...(id === undefined ? {} : { id }) +}) + +describe('replaceCommand', () => { + // Regression: two rows completing in one millisecond, matched by timestamp + // alone, sent the second row's update onto the first and erased it. + it('targets the row by id when two rows share a timestamp', () => { + const current = [row('sendKeys', 42, 4), row('findElement', 42, 5)] + const next = replaceCommand(current, 42, row('findElement', 42, 5)) + expect(next.map((r) => r.command)).toEqual(['sendKeys', 'findElement']) + }) + + it('falls back to the last row with the old timestamp', () => { + const current = [row('a', 42), row('b', 42)] + expect(replaceCommandIndex(current, 42, row('c', 42))).toBe(1) + }) + + it('appends when nothing matches', () => { + const next = replaceCommand([row('a', 1)], 999, row('b', 999)) + expect(next.map((r) => r.command)).toEqual(['a', 'b']) + }) + + it('does not mutate the list it was given', () => { + const current = [row('a', 1, 1)] + replaceCommand(current, 1, row('b', 1, 1)) + expect(current[0].command).toBe('a') + }) +}) From ec141fd30410c227ef496eb7a92532755000f3d4 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Mon, 12 Oct 2026 17:21:28 +0530 Subject: [PATCH 03/18] refactor(shared): move RunCapabilities and RunnerOptions into shared They describe what an adapter puts in Metadata.options for the run controls, which crosses the wire, so they belong beside Metadata. The app keeps its names through a re-export. --- packages/app/src/components/sidebar/types.ts | 15 +-------------- packages/shared/src/types.ts | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/packages/app/src/components/sidebar/types.ts b/packages/app/src/components/sidebar/types.ts index 5375ba9c..b3b03fd5 100644 --- a/packages/app/src/components/sidebar/types.ts +++ b/packages/app/src/components/sidebar/types.ts @@ -12,20 +12,7 @@ export interface TestEntry { suiteType?: string } -export interface RunCapabilities { - canRunSuites: boolean - canRunTests: boolean - canRunAll: boolean -} - -export interface RunnerOptions { - framework?: string - configFile?: string - configFilePath?: string - runCapabilities?: Partial - rerunCommand?: string - launchCommand?: string -} +export type { RunCapabilities, RunnerOptions } from '@wdio/devtools-shared' export interface TestRunDetail { uid: string diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index c1e0b582..919fe4bb 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -470,6 +470,23 @@ export interface CucumberPickle { steps?: CucumberPickleStep[] } +/** Which run controls the dashboard offers for a session. */ +export interface RunCapabilities { + canRunSuites: boolean + canRunTests: boolean + canRunAll: boolean +} + +/** What an adapter puts in `Metadata.options` for the run controls; the backend spawns these commands. */ +export interface RunnerOptions { + framework?: string + configFile?: string + configFilePath?: string + runCapabilities?: Partial + rerunCommand?: string + launchCommand?: string +} + export interface Metadata { type: TraceType url?: string From c2c4f8312f869ffc4b0928be67554276f5d5231f Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Mon, 12 Oct 2026 17:21:28 +0530 Subject: [PATCH 04/18] fix(selenium-devtools-py): carry the browser's error message, not str() A WebDriverException's str() prefixes "Message: " and appends the driver's stacktrace, so the dashboard's Errors tab showed both around the browser's text. The row now carries `msg`, the browser's own message, as the JavaScript adapters do; other exceptions keep their str(). --- .../changes/browser-error-message.md | 5 +++++ .../src/selenium_devtools/frames.py | 4 +++- packages/selenium-devtools-py/tests/test_frames.py | 11 +++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 packages/selenium-devtools-py/changes/browser-error-message.md diff --git a/packages/selenium-devtools-py/changes/browser-error-message.md b/packages/selenium-devtools-py/changes/browser-error-message.md new file mode 100644 index 00000000..68a287b1 --- /dev/null +++ b/packages/selenium-devtools-py/changes/browser-error-message.md @@ -0,0 +1,5 @@ +--- +patch +--- + +A failed command's row now carries the browser's own error message, as the JavaScript adapters send it. It used to carry the exception's string form, so the dashboard's Errors tab showed `Message: ` before the text and the driver's stacktrace after it. diff --git a/packages/selenium-devtools-py/src/selenium_devtools/frames.py b/packages/selenium-devtools-py/src/selenium_devtools/frames.py index c8ea8a07..e4ef51ba 100644 --- a/packages/selenium-devtools-py/src/selenium_devtools/frames.py +++ b/packages/selenium-devtools-py/src/selenium_devtools/frames.py @@ -92,7 +92,9 @@ def command_log( if error is not None: entry["error"] = { "name": type(error).__name__, - "message": str(error), + # A WebDriverException's str() adds "Message: " and the driver's + # stacktrace; `msg` is the browser's own text, which the JS adapters send. + "message": getattr(error, "msg", None) or str(error), } if screenshot: entry["screenshot"] = screenshot diff --git a/packages/selenium-devtools-py/tests/test_frames.py b/packages/selenium-devtools-py/tests/test_frames.py index 5a3ffc4e..99c263e2 100644 --- a/packages/selenium-devtools-py/tests/test_frames.py +++ b/packages/selenium-devtools-py/tests/test_frames.py @@ -49,6 +49,17 @@ def test_command_log_includes_error_only_when_present(self): ) self.assertEqual(bad["error"], {"name": "ValueError", "message": "nope"}) + def test_a_webdriver_error_carries_the_browsers_message_not_its_str(self): + # Regression: str() gave the Errors tab "Message: ...\nStacktrace: ...". + from selenium.common.exceptions import WebDriverException + + error = WebDriverException("net::ERR_FAILED at /broken", stacktrace=["#0 0x55d0"]) + row = frames.command_log( + command="get", args=[], error=error, + timestamp=2, start_time=1, call_source=None, command_id=5, + ) + self.assertEqual(row["error"]["message"], "net::ERR_FAILED at /broken") + def test_suite_and_test_stats_are_json_serializable(self): t = frames.test_stats( uid="n::t", title="t", full_title="m › t", parent="m", From 6e848a8b5fac3d006c091f32faf23b03dd66568d Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Mon, 12 Oct 2026 17:21:28 +0530 Subject: [PATCH 05/18] test: add a cross-adapter conformance suite Each adapter runs one scenario as its own process against a fake W3C WebDriver that records what really happened in the browser, reporting into a recording backend through the rerun reuse handshake, so no adapter gains a test-only path. One checker judges every recorded frame stream against behavioural rules, with and without BiDi. The fake recognises the collector by its own source rather than any adapter's idiom, and the recording backend serves the real collector bundle. Known gaps need a reason and fail once they stop occurring. Every run writes its stream and verdict to out/, and an HTML report places each violation on the event it concerns, naming the adapter responsibility the broken rule tests. Covers selenium-devtools and selenium-devtools-py. Refs #300 --- .gitignore | 2 + packages/conformance/package.json | 26 ++ .../conformance/scenarios/python_scenario.py | 37 +++ packages/conformance/scenarios/selenium.ts | 32 ++ packages/conformance/src/allowlist.ts | 38 +++ packages/conformance/src/checker.ts | 247 ++++++++++++++ packages/conformance/src/constants.ts | 120 +++++++ packages/conformance/src/fake-bidi.ts | 47 +++ packages/conformance/src/fake-webdriver.ts | 300 ++++++++++++++++++ packages/conformance/src/harness.ts | 70 ++++ packages/conformance/src/recording-backend.ts | 75 +++++ packages/conformance/src/report-html.ts | 177 +++++++++++ packages/conformance/src/report.ts | 45 +++ packages/conformance/src/rule-guide.ts | 78 +++++ packages/conformance/src/timeline.ts | 81 +++++ packages/conformance/src/types.ts | 136 ++++++++ packages/conformance/tests/checker.test.ts | 216 +++++++++++++ packages/conformance/tests/python.test.ts | 74 +++++ packages/conformance/tests/report.test.ts | 149 +++++++++ packages/conformance/tests/selenium.test.ts | 52 +++ packages/conformance/tsconfig.json | 4 + pnpm-lock.yaml | 86 ++--- pnpm-workspace.yaml | 1 + 23 files changed, 2038 insertions(+), 55 deletions(-) create mode 100644 packages/conformance/package.json create mode 100644 packages/conformance/scenarios/python_scenario.py create mode 100644 packages/conformance/scenarios/selenium.ts create mode 100644 packages/conformance/src/allowlist.ts create mode 100644 packages/conformance/src/checker.ts create mode 100644 packages/conformance/src/constants.ts create mode 100644 packages/conformance/src/fake-bidi.ts create mode 100644 packages/conformance/src/fake-webdriver.ts create mode 100644 packages/conformance/src/harness.ts create mode 100644 packages/conformance/src/recording-backend.ts create mode 100644 packages/conformance/src/report-html.ts create mode 100644 packages/conformance/src/report.ts create mode 100644 packages/conformance/src/rule-guide.ts create mode 100644 packages/conformance/src/timeline.ts create mode 100644 packages/conformance/src/types.ts create mode 100644 packages/conformance/tests/checker.test.ts create mode 100644 packages/conformance/tests/python.test.ts create mode 100644 packages/conformance/tests/report.test.ts create mode 100644 packages/conformance/tests/selenium.test.ts create mode 100644 packages/conformance/tsconfig.json diff --git a/.gitignore b/.gitignore index ad7b9c30..1ca4ecec 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,8 @@ examples/**/trace-*/ # test results examples/**/test-results*/ +packages/conformance/**/test-results*/ +packages/conformance/out/ # vitest --coverage output coverage/ diff --git a/packages/conformance/package.json b/packages/conformance/package.json new file mode 100644 index 00000000..73df2f02 --- /dev/null +++ b/packages/conformance/package.json @@ -0,0 +1,26 @@ +{ + "name": "@wdio/devtools-conformance", + "version": "1.0.0", + "private": true, + "author": "Vishnu Vardhan", + "description": "Cross-adapter conformance suite: drives each adapter through one scenario against a fake WebDriver server, records the frames it sends, and checks them against behavioural rules. Test-only, never published or bundled.", + "repository": { + "type": "git", + "url": "git+https://github.com/webdriverio/devtools.git", + "directory": "packages/conformance" + }, + "type": "module", + "scripts": { + "lint": "eslint ." + }, + "license": "MIT", + "devDependencies": { + "@types/node": "26.6.4", + "@types/selenium-webdriver": "^4.35.6", + "@types/ws": "^8.18.2", + "@wdio/devtools-shared": "workspace:^", + "@wdio/selenium-devtools": "workspace:^", + "selenium-webdriver": "^4.50.0", + "ws": "^8.22.0" + } +} diff --git a/packages/conformance/scenarios/python_scenario.py b/packages/conformance/scenarios/python_scenario.py new file mode 100644 index 00000000..bb2e6d7a --- /dev/null +++ b/packages/conformance/scenarios/python_scenario.py @@ -0,0 +1,37 @@ +"""The conformance scenario, driven through selenium-devtools-py. + +Run by the conformance harness, which points ``enable()`` at its recording +backend through the reuse handshake and Selenium at its fake WebDriver. +""" + +import os + +from selenium import webdriver +from selenium.common.exceptions import WebDriverException +from selenium.webdriver.common.by import By + +import selenium_devtools + + +def env(name: str) -> str: + value = os.environ.get(name) + if not value: + raise SystemExit(f"{name} is not set; run this through the conformance harness") + return value + + +selenium_devtools.enable() +driver = webdriver.Remote(command_executor=env("CONFORMANCE_WEBDRIVER"), options=webdriver.ChromeOptions()) +try: + driver.get(env("CONFORMANCE_LOGIN_URL")) + driver.find_element(By.CSS_SELECTOR, "#username").send_keys("tom") + driver.find_element(By.CSS_SELECTOR, "#password").send_keys("secret") + driver.find_element(By.CSS_SELECTOR, 'button[type="submit"]').click() + driver.find_element(By.CSS_SELECTOR, "#logout").click() + try: + driver.get(env("CONFORMANCE_BROKEN_URL")) + except WebDriverException: + pass +finally: + driver.quit() + selenium_devtools.disable() diff --git a/packages/conformance/scenarios/selenium.ts b/packages/conformance/scenarios/selenium.ts new file mode 100644 index 00000000..dc617d16 --- /dev/null +++ b/packages/conformance/scenarios/selenium.ts @@ -0,0 +1,32 @@ +// Imported first: the import is what patches selenium-webdriver. +import { DevTools } from '@wdio/selenium-devtools' +import { Builder, By } from 'selenium-webdriver' + +import { SCENARIO_ENV } from '../src/constants.js' + +DevTools.configure({ openUi: false }) + +const env = (name: string): string => { + const value = process.env[name] + if (!value) { + throw new Error( + `${name} is not set; run this through the conformance harness` + ) + } + return value +} + +const driver = await new Builder() + .forBrowser('chrome') + .usingServer(env(SCENARIO_ENV.webdriver)) + .build() +try { + await driver.get(env(SCENARIO_ENV.login)) + await driver.findElement(By.css('#username')).sendKeys('tom') + await driver.findElement(By.css('#password')).sendKeys('secret') + await driver.findElement(By.css('button[type="submit"]')).click() + await driver.findElement(By.css('#logout')).click() + await driver.get(env(SCENARIO_ENV.broken)).catch(() => undefined) +} finally { + await driver.quit() +} diff --git a/packages/conformance/src/allowlist.ts b/packages/conformance/src/allowlist.ts new file mode 100644 index 00000000..361f89bd --- /dev/null +++ b/packages/conformance/src/allowlist.ts @@ -0,0 +1,38 @@ +import type { AdapterId, Judgement, KnownGap, Violation } from './types.js' + +export const KNOWN_GAPS: KnownGap[] = [ + { + adapter: 'selenium-devtools', + bidi: false, + rules: ['every-document-anchored', 'navigation-row-shows-destination'], + reason: + 'Without BiDi the collector is appended as a ' + const html = renderReport([ + run({ + frames: [ + { + scope: 'commands', + data: [ + { + command: 'sendKeys', + args: [hostile], + startTime: 1000, + timestamp: 1010 + } + ] + } + ], + output: hostile + }) + ]) + expect(html).not.toContain('