diff --git a/CLAUDE.md b/CLAUDE.md index 37505686..9196819f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -266,8 +266,10 @@ Documented divergences from the conventions above. They exist today as debt to b - **Two unrelated events share the `clearExecutionData` scope, and the receiver cannot tell them apart from the uid.** A run STARTING (`backend/src/index.ts` `handleTestRun`, one per `POST /api/tests/run`) and ONE ENTRY resetting inside a run already in flight (`nightwatch-devtools/src/cucumber-lifecycle.ts`, which re-emits a scenario suite and must not wipe its siblings) arrive under the same scope with the same shape. The app inferred the difference by comparing the uid against `rerunState.activeRerunSuiteUid` — a latch that outlived its rerun, so the *next* run start at a different scope read as a child clear of the last one and **skipped its wipe entirely**: rerun a suite, then the file or Tests, and the Actions/Console/Network tabs kept the previous run's rows and grew with each rerun. `ClearExecutionDataWsPayload.runStart` now states it on the wire (it has to be on the wire, not local to the clicking window — popouts see only WS events), and the app clears both latches when it is set. A backend test asserts the flag actually ships: the app-side fix reads it, so dropping it would restore the bug with every app test still green. - Still open, same class: `app/src/components/browser/snapshot.ts` `#videos` is only ever pushed to, so the screencast "Recording N" dropdown accumulates every session of every run for the life of the page (observed at 17). That component listens only to the `screencast-ready` window event and never learns a run started. - **A rerun's process collects a SUBSET, so anything it derives from "this collection" is wrong for the tree it merges into.** Two bugs of that one shape, both found by rerunning a single pytest test: (a) `SuiteStats.order` — which `test-entry-state.ts` `orderedChildren` sorts a suite's tests and child suites by — was pytest's `enumerate(session.items)` index, so a rerun restamped its one test as position 0 and the row jumped above the class it was written below. It is now the item's **source line**, a property of the test rather than of the collection; within a module pytest collects in definition order, so the two agree wherever both are meaningful (a plugin that reorders collection is the exception, and there the line is the more stable answer anyway). (b) `suite-merge.ts` `resetStaleChildrenOnRerun` flipped every settled child *suite* to `pending` whenever an incoming suite arrived `pending` — but a single-test rerun re-emits the parent as `pending` carrying only the one test it collected, so a sibling class suite was set spinning and never reported again, keeping the spinner for the rest of the session with all of its own tests still green. `mergeTests` already froze sibling *tests* on `activeRerunTestUid`; that guard now covers child suites too. A suite on the path to the target is unaffected either way — it re-reports its own state. +- **A trace archive is a full recording of the page, and there is no redaction policy anywhere in capture.** Whatever the run put on screen or typed is in the zip, usually several times over: measured on the Python login example, its demo credential appears ~103 times across six places — the page's own displayed text (90x, the-internet prints it), the DOM mutation stream, the `Element.fill` command args, the transcript, the captured test source, and `*-elements.json`. `shared/element-scripts.ts` blanks an `` value, which is worth having because nothing downstream reads that field, but it removes **2 of those ~103** and closes nothing on its own. `buildElementScripts` now projects a captured record down to what is actually read (`selector` + `boundingBox` + context), so `value` and `href` leave the archive entirely — justified as dead data, **not** as a redaction: the same archive still carries 15 hrefs in `trace.mutations` independent of `elements.json`, and 29 `value` attribute mutations recording a typed string keystroke by keystroke (`t`, `to`, `tom`, ...). `@wdio/elements` keeps returning the full `BrowserElementInfo` from its own live call, which is its documented API. A real policy has to act at the collector and the command-arg serializer — a masking-selector or `maskInputs` option — not at one resource. Until then, treat a trace zip as sensitive as the run that produced it. +- **An `ActionSnapshot` carries no session identity, in any adapter.** `shared`'s type has never had one and `core/action-snapshot.ts` records none, so per-action captures from two concurrently-driven sessions land in one list and are resolved purely by the command's completion timestamp. `claimAfter` is an exact keyed lookup, so the window is narrow — two commands completing in the **same millisecond**, where `trace-frame-snapshots.ts` breaks the tie by "keep the richest capture" (largest screenshot), which is session-blind — plus the documented `latestAtOrBefore` fallback for a command that took no capture of its own. Python is not worse than the JS adapters here and leans on that fallback less, since it stamps each snapshot with its own command's `row["timestamp"]`; reaching the failure at all needs threaded drivers in one process (pytest's function-scoped fixtures are sequential, and `-n` is multi-process). Fixing it is a shared-contract change: `sessionId` on the snapshot, and an index keyed by the pair. - **Chrome discards all WebDriver-synthesized input to a tab after a breached credential is submitted.** The first time a test types a `(username, password)` pair that Chrome's password-leak check finds in a breach corpus into an `` and submits a form whose destination no longer shows that login form, Chrome queries `passwordsleakcheck-pa.googleapis.com` and ~0.3-0.9 s later stops delivering **all** synthesized input — mouse *and* keyboard — to that tab. chromedriver returns HTTP 200 for every subsequent Element Click / Send Keys; nothing reaches the page. Untrusted JS (`element.click()`) still works and direct CDP `Input.dispatchMouseEvent`/`dispatchKeyEvent` are equally dead, so this is Chrome, not chromedriver and not our capture. `tomsmith` / `SuperSecretPassword!` — the-internet's demo credential — triggers it; changing only the *username* does not, nor does a random password. - - **Workaround: add `--host-resolver-rules=MAP passwordsleakcheck-pa.googleapis.com 127.0.0.1` to the browser args.** Both examples do. Verified 3/3 on the WDIO mocha example and on the Nightwatch example, where it also fixes the **within-one-test** logout click that a session reset never could. `--guest` also works (3/3); `--incognito` works at the raw-WebDriver level but WebdriverIO rejects it at session creation; disabling the password manager via `prefs` does **not** (6/6 still fail). + - **Workaround: add `--host-resolver-rules=MAP passwordsleakcheck-pa.googleapis.com 127.0.0.1` to the browser args.** Every example that submits the demo credential carries it — WDIO, Nightwatch, and both Python ones (`login.py` was missing it and its logout click silently did nothing, which is exactly the symptom). Verified 3/3 on the WDIO mocha example and on the Nightwatch example, where it also fixes the **within-one-test** logout click that a session reset never could. `--guest` also works (3/3); `--incognito` works at the raw-WebDriver level but WebdriverIO rejects it at session creation; disabling the password manager via `prefs` does **not** (6/6 still fail). - **Not a version regression, not headless-specific, not the site, not "the Nth navigation".** Measured identically on Chrome 149.0.7827.155 / 150.0.7871.124 / 151.0.7922.77 / 152.0.7977.30 with matched chromedrivers (5/5 each), headless and headed, and on a purely local two-page static form. It fires **once per browser profile** on a wall clock — a liveness probe that never navigates again goes dead 904 ms after the submit — so the historical ~25% intermittency was the race between the next input command and that round trip. Do **not** pin `browserVersion` to 149; every part of the earlier "Chrome 150 regression, fixed in 151" attribution is contradicted. - Minimal reproduction (own HTTP server, raw `fetch` to chromedriver, no repo, no client library, no framework) is in the session scratchpad as `minimal-repro.mjs`; it is what an upstream chromedriver bug report needs. If a session is already stuck, navigating away and back or opening a new tab restores input (4/4 each); `refresh()`, ESC, JS focus/blur and a 10 s wait do not (0/4 each). - **Live mode has no per-action DOM snapshot, so its replay is only as fresh as the last drain.** Per-action snapshots cost two injected scripts plus a screenshot and stay trace-only; all three adapters instead drain the collector after a command that could have moved the page. Service: `#drainAfterLiveCommand`. Selenium: `commandPostActions.ts` `warrantsLiveDrain` + `SessionCapturer.drainAfterLiveCommand`, the same deny-list shape over its own command vocabulary plus `mapAssertCommand` (a node:assert row never reaches the browser) — the predicate is *not* in core because the vocabularies are per-framework and only two `includes` calls would be shared. Without it Selenium drained only at navigation, and that hook is deferred behind an injection and a 500 ms settle: measured on the login example, **2 mutation entries and 2 anchors for a 16-row run**, with the page test 1 spent most of its life on never anchored, so all 11 of its rows replayed the page the test *ended* on (2 → 24 entries, 2 → 3 anchors, 0 → 21 field-state mutations after the fix). Selenium's drain is serialized on a tail because the driver patcher does not await `onCommand`, and the app scans the mutation stream in order and stops at the first entry past a row's window — an overtaken batch strands every row after it. @@ -336,6 +338,7 @@ Most entries below don't trigger the `max-lines` lint rule after `skipBlankLines - `packages/nightwatch-devtools/src/index.ts` (783 raw / 676 logic). Cucumber/test/run-lifecycle, session-init, event-hub and now the screencast seam (`plugin-screencast.ts`, 105 raw / 60 logic) are extracted; the remainder is the `PluginInternals` accessor bag plus per-method delegators plus the factory. The bag is deliberately declarative — accept as-is. - `packages/selenium-devtools/src/index.ts` (~644 raw, down from ~758 — the dead `scriptInjected` accessor pair and setter are gone). Session/test-lifecycle **and** the per-test-artifact seam are now extracted: the sink cache + input snapshot + produce/attach flow live in `selenium-devtools/src/test-artifacts.ts` as `SeleniumTestArtifacts` (mirrors Nightwatch's twin — a typed input bag threading the Allure sink + flushed-trace promise), and the plugin keeps only a thin bag-building delegator. Remainder is the `PluginInternals` accessor bag plus onCommand/onDriverCreated wiring. Still over the 500 **raw** soft cap (under the logic-line cap after `skipBlankLines`/`skipComments`); the accessor bag / command wiring is the next extraction candidate if it grows. - `packages/nightwatch-devtools/src/session.ts` (519 raw, under the logic-line cap after `skipBlankLines`/`skipComments`). `captureNetworkFromPerformanceLogs` + `captureBrowserLogs` + `drainCollector` are tightly coupled to NightwatchBrowser state. Coverage at 78% after recent backfill; further extraction would need rewriting the browser-coupling. +- `packages/selenium-devtools-py/src/selenium_devtools/instrumentation.py` (975 raw). Every seam here reads the module-level `_state` bag — the per-action snapshot capture, its two accessors and the element-scripts handoff all key off `trace`/`a11y`/`element_scripts` alongside the screencast and session entries — so an extraction is a move of state ownership, not a lift of a function. The next change that touches `_state` itself should split it per concern first; `action_snapshot.py` is then a clean lift. ### Test coverage gaps (worst-risk-first) diff --git a/examples/selenium/python-test/login.py b/examples/selenium/python-test/login.py index cf0cd14e..0cd5820d 100644 --- a/examples/selenium/python-test/login.py +++ b/examples/selenium/python-test/login.py @@ -33,6 +33,9 @@ options = Options() options.add_argument("--headless=new") # drop this line to watch the browser options.add_argument("--window-size=1280,1024") +options.add_argument( + "--host-resolver-rules=MAP passwordsleakcheck-pa.googleapis.com 127.0.0.1" +) driver = webdriver.Chrome(options=options) wait = WebDriverWait(driver, TIMEOUT) @@ -44,16 +47,17 @@ driver.find_element(By.ID, "password").send_keys(PASSWORD) driver.find_element(By.CSS_SELECTOR, 'button[type="submit"]').click() - # wait.until(EC.visibility_of_element_located((By.ID, "flash"))) - assert "/secure" in driver.current_url, driver.current_url + current_url = driver.current_url + assert "/secure" in current_url, current_url flash = driver.find_element(By.ID, "flash").text assert "You logged into a secure area" in flash, flash # wait.until(EC.visibility_of_element_located((By.CSS_SELECTOR, "a.button"))) - driver.find_element(By.CSS_SELECTOR, "a.button").click() + driver.find_element(By.XPATH, '//a[contains(., "Logout")]').click() # wait.until(EC.visibility_of_element_located((By.ID, "username"))) - assert "/login" in driver.current_url, driver.current_url + current_url = driver.current_url + assert "/login" in current_url, current_url print("[TEST] logged back out") finally: driver.quit() diff --git a/examples/selenium/python-test/test_login_pytest.py b/examples/selenium/python-test/test_login_pytest.py index 9e5fac5a..79435591 100644 --- a/examples/selenium/python-test/test_login_pytest.py +++ b/examples/selenium/python-test/test_login_pytest.py @@ -69,7 +69,11 @@ class TestLogin: def test_logs_in_with_valid_credentials(self, driver): flash = _login(driver, USERNAME, PASSWORD) - assert "/secure" in driver.current_url, driver.current_url + # Bound to a local like `flash`, not asserted on the property: a failing + # `assert cond, msg` evaluates `msg` too, and both are the same browser + # round trip — so the trace grows a second, identical command row. + current_url = driver.current_url + assert "/secure" in current_url, current_url assert "You logged into a secure area" in flash, flash WebDriverWait(driver, TIMEOUT).until( @@ -79,11 +83,13 @@ def test_logs_in_with_valid_credentials(self, driver): WebDriverWait(driver, TIMEOUT).until( EC.visibility_of_element_located((By.ID, "username")) ) - assert "/login" in driver.current_url, driver.current_url + current_url = driver.current_url + assert "/login" in current_url, current_url def test_rejects_invalid_credentials(self, driver): flash = _login(driver, USERNAME, "wrong-password") - assert "/login" in driver.current_url, driver.current_url + current_url = driver.current_url + assert "/login" in current_url, current_url assert "Your password is invalid" in flash, flash @@ -93,4 +99,5 @@ def test_the_login_page_loads(driver): WebDriverWait(driver, TIMEOUT).until( EC.visibility_of_element_located((By.ID, "login")) ) - assert driver.title == "The Internet", driver.title + title = driver.title + assert title == "The Internet", title diff --git a/packages/backend/src/baseline/types.ts b/packages/backend/src/baseline/types.ts index 8c25a45b..0ad8e203 100644 --- a/packages/backend/src/baseline/types.ts +++ b/packages/backend/src/baseline/types.ts @@ -1,4 +1,5 @@ import type { + ActionSnapshot, CommandLog, ConsoleLog, Metadata, @@ -55,6 +56,11 @@ export interface ActiveRun { /** Raw `logs` frames, the trace's transcript source. Only the JS adapters * send these, so this is routinely empty. */ traceLogs: string[] + /** Per-action snapshots: the page's element tree beside each action, which + * is what the A11y tab reads. A defined TraceLog scope no adapter sent + * until now — the exporter synthesizes bare ones from command screenshots + * when it gets none, which carries a picture but no elements. */ + actionSnapshots: ActionSnapshot[] /** Dense screencast frames for the trace filmstrip. The JS adapters hand * their recorder's buffer straight to the exporter in-process; an adapter * that exports through here has to send them, so they accumulate like any diff --git a/packages/backend/src/baseline/utils.ts b/packages/backend/src/baseline/utils.ts index 4ad48326..c3eeb0f9 100644 --- a/packages/backend/src/baseline/utils.ts +++ b/packages/backend/src/baseline/utils.ts @@ -10,7 +10,8 @@ export function freshRun(): ActiveRun { nodes: new Map(), startedAt: Date.now(), traceLogs: [], - screencastFrames: [] + screencastFrames: [], + actionSnapshots: [] } } diff --git a/packages/backend/src/baselineStore.ts b/packages/backend/src/baselineStore.ts index 9b89c948..85b1ebf1 100644 --- a/packages/backend/src/baselineStore.ts +++ b/packages/backend/src/baselineStore.ts @@ -82,6 +82,9 @@ class BaselineStore { case 'logs': appendArray(this.#activeRun.traceLogs, data) return + case 'actionSnapshots': + appendArray(this.#activeRun.actionSnapshots, data) + return case 'screencastFrames': // Sent in batches: a run's buffer can reach the recorder's cap, and one // message carrying all of it would sit near the socket's payload limit. diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 3f7adca4..e6f433b9 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -23,6 +23,10 @@ import { BASELINE_WS_SCOPE, COLLECTOR_API, COLLECTOR_CONTENT_TYPE, + ELEMENT_SCRIPTS_API, + ELEMENT_SCRIPTS_CONTENT_TYPE, + buildElementScripts, + isTestRunnerId, TRACE_API, WORKER_WS_QUERY, WS_PATHS, @@ -402,6 +406,33 @@ function registerCollectorRoute(s: FastifyInstance, source: string): void { ) } +/** + * Serve the page-side element scripts, generated per runner. + * + * The collector route above exists because an adapter cannot be expected to + * carry its own copy of page code; these are the same thing for the element + * tree. Generated per request rather than read once like the collector, because + * the scripts bake in the caller's locator dialect — the runner decides whether + * a text locator comes out as `a*=Logout` or as XPath. + */ +function registerElementScriptsRoute(s: FastifyInstance): void { + s.get( + ELEMENT_SCRIPTS_API.get, + async ( + request: FastifyRequest<{ Querystring: { runner?: string } }>, + reply + ) => { + const raw = request.query?.runner + // Narrowed, never trusted: an unknown value would otherwise reach + // locatorDialect and silently pick a dialect for a runner that is not one. + const runner = isTestRunnerId(raw) ? raw : undefined + return reply + .type(ELEMENT_SCRIPTS_CONTENT_TYPE) + .send(JSON.stringify(buildElementScripts(runner))) + } + ) +} + function registerTraceRoute( s: FastifyInstance, trace: TracePlayerData | undefined @@ -435,6 +466,7 @@ export async function start( registerTraceRoute(server, opts.trace) registerCollectorRoute(server, collectorSource) + registerElementScriptsRoute(server) registerTestRoutes(server, host, port) registerBaselineRoutes(server) registerClientWebSocket(server) diff --git a/packages/backend/src/trace-export.ts b/packages/backend/src/trace-export.ts index 91c4686d..17c98c13 100644 --- a/packages/backend/src/trace-export.ts +++ b/packages/backend/src/trace-export.ts @@ -7,7 +7,12 @@ * shape and the exporter's, plus the two derivations the wire does not carry. */ -import type { TestMetadataMap, TraceExportRequest } from '@wdio/devtools-shared' +import type { + ActionSnapshot, + TestMetadataMap, + TraceExportRequest +} from '@wdio/devtools-shared' +import { serializeWebSnapshot } from '@wdio/devtools-trace/a11y-snapshot' import { writeTraceZip, type TraceCapturer @@ -37,6 +42,30 @@ export function testMetadataFromNodes( return metadata } +/** + * Serialize any raw accessibility tree into the text the A11y tab parses. + * + * An adapter that exports through here captured the tree with a page-side + * script but cannot serialize it — that transform is TypeScript. A snapshot + * that already carries `snapshotText` is left alone: the JS adapters serialize + * in-process and theirs is authoritative. + */ +function serializeTrees(snapshots: ActionSnapshot[]): ActionSnapshot[] { + return snapshots.map((snap) => { + if (snap.snapshotText || !snap.accessibilityTree?.length) { + return snap + } + const { accessibilityTree, ...rest } = snap + return { + ...rest, + snapshotText: serializeWebSnapshot(accessibilityTree, { + url: snap.url, + title: snap.title + }) + } + }) +} + /** * Adapt the accumulator to the exporter's input. Only `sources` needs * reshaping — the accumulator stores the canonical shared types for @@ -82,6 +111,12 @@ export async function exportActiveRunTrace( sessionId: request.sessionId, ...(request.format ? { format: request.format } : {}), ...(request.fileStem ? { fileStem: request.fileStem } : {}), + // Absence is meaningful here too, and differently: given none, the + // exporter synthesizes bare snapshots from commands carrying screenshots, + // so an adapter that sends nothing still gets pictures — just no elements. + ...(run.actionSnapshots.length + ? { actionSnapshots: serializeTrees(run.actionSnapshots) } + : {}), // Omitted when empty rather than passed as []: the exporter treats absence // as "no dense filmstrip" and keeps the sparse per-action one, which is // what an adapter that did not ask for frames should still get. diff --git a/packages/backend/tests/baselineStore.test.ts b/packages/backend/tests/baselineStore.test.ts index 206c0d71..86754f71 100644 --- a/packages/backend/tests/baselineStore.test.ts +++ b/packages/backend/tests/baselineStore.test.ts @@ -548,6 +548,22 @@ describe('baselineStore — scopes the trace export reads', () => { expect(baselineStore.activeRun().traceLogs).toEqual(['one', 'two', 'three']) }) + // Both are streams an adapter that exports through the backend has to send, + // because it has no in-process handle to the exporter. + it('accumulates action snapshots and screencast frames', () => { + baselineStore.recordEvent('actionSnapshots', [ + { timestamp: 1, command: 'clickElement', elements: [{ selector: '#go' }] } + ]) + baselineStore.recordEvent('screencastFrames', [{ data: 'a', timestamp: 1 }]) + baselineStore.recordEvent('screencastFrames', [{ data: 'b', timestamp: 2 }]) + baselineStore.recordEvent('actionSnapshots', 'not-an-array') + + expect(baselineStore.activeRun().actionSnapshots).toHaveLength(1) + expect( + baselineStore.activeRun().screencastFrames.map((f) => f.data) + ).toEqual(['a', 'b']) + }) + it('starts a new run with neither carried over', () => { baselineStore.recordEvent('metadata', { sessionId: 'a' }) baselineStore.recordEvent('logs', ['one']) diff --git a/packages/backend/tests/element-scripts-route.test.ts b/packages/backend/tests/element-scripts-route.test.ts new file mode 100644 index 00000000..437f5a36 --- /dev/null +++ b/packages/backend/tests/element-scripts-route.test.ts @@ -0,0 +1,64 @@ +/** + * The page-side element scripts, served like the collector. + * + * They are what fills the A11y tab. `core/action-snapshot.ts` runs them + * in-process for the JS adapters, but an adapter that cannot import that + * package — the Python one — had no route to them at all, which is why a + * Python trace carried no `*-elements.json` while a JS Selenium trace of the + * same flow carried 12. + */ + +import { describe, it, expect } from 'vitest' +import { + ELEMENT_SCRIPTS_API, + ELEMENT_SCRIPTS_CONTENT_TYPE, + accessibilityTreeScript, + elementsScript, + isTestRunnerId +} from '@wdio/devtools-shared' + +describe('element scripts contract', () => { + it('serves JSON from a path both sides agree on', () => { + // The Python adapter generates this path into `_contract.py` from shared, + // so a rename fails its drift check rather than 404ing at runtime. + expect(ELEMENT_SCRIPTS_API.get).toBe('/api/element-scripts') + expect(ELEMENT_SCRIPTS_CONTENT_TYPE).toContain('json') + }) + + // A JSON envelope rather than raw source, because a caller needs both. + it('carries the two scripts the JS adapters run', () => { + const a11y = accessibilityTreeScript(true, 'selenium-webdriver') + const elements = elementsScript(true, true, 'selenium-webdriver') + + for (const script of [a11y, elements]) { + expect(script.startsWith('(function () {')).toBe(true) + expect(script.length).toBeGreaterThan(1000) + } + }) +}) + +describe('the runner decides the locator dialect', () => { + // Baked in at generation, which is why this is generated per request rather + // than read once like the collector: a caller cannot patch a dialect into an + // already-served string. + it('produces different source for a WDIO runner than a protocol-level one', () => { + expect(elementsScript(true, true, 'mocha')).not.toBe( + elementsScript(true, true, 'selenium-webdriver') + ) + }) + + // An unknown value must not reach locatorDialect and silently pick a dialect + // for a runner that is not one. + it('narrows an untrusted runner before using it', () => { + expect(isTestRunnerId('selenium-webdriver')).toBe(true) + expect(isTestRunnerId('bogus')).toBe(false) + expect(isTestRunnerId(undefined)).toBe(false) + + // What the route does with a query param: narrow, or drop. + const raw: string = 'bogus' + const narrowed = isTestRunnerId(raw) ? raw : undefined + expect(elementsScript(true, true, narrowed)).toBe( + elementsScript(true, true, undefined) + ) + }) +}) diff --git a/packages/backend/tests/trace-export.test.ts b/packages/backend/tests/trace-export.test.ts index ad4017d6..09920095 100644 --- a/packages/backend/tests/trace-export.test.ts +++ b/packages/backend/tests/trace-export.test.ts @@ -255,6 +255,144 @@ describe('exportActiveRunTrace', () => { expect(JSON.stringify(options[0])).toContain('chrome') }) + // The A11y tab reads these. Given none, the exporter synthesizes bare + // snapshots from commands carrying screenshots — a picture per action and no + // elements, which is what a Python trace had: 16 frame snapshots, 0 + // *-elements.json, against 12 in a JS Selenium trace of the same flow. + it('writes the element tree beside an action when one was streamed', async () => { + const outputDir = await tmpDir() + const zipPath = await exportActiveRunTrace( + run({ + actionSnapshots: [ + { + timestamp: 1200, + command: 'clickElement', + screenshot: JPEG_1PX, + elements: [{ selector: '#go', role: 'button', name: 'Go' }] + } + ] + }), + { outputDir, sessionId: 'sess-a11y' } + ) + + const files = unzipSync(new Uint8Array(await fs.readFile(zipPath))) + const elements = Object.keys(files).filter((n) => + n.endsWith('-elements.json') + ) + expect(elements).toHaveLength(1) + expect(strFromU8(files[elements[0]!]!)).toContain('#go') + }) + + // An adapter exporting through here captures the tree with a page-side + // script but cannot serialize it — that transform is TypeScript. Capturing + // only `elements` left the A11y tab reporting "no accessibility snapshot" + // with 39 element files in the same archive. + it('serializes a raw accessibility tree into the text the A11y tab parses', async () => { + const outputDir = await tmpDir() + const node = ( + role: string, + name: string, + selector: string, + depth: number + ) => ({ + role, + name, + selector, + depth, + level: '', + disabled: '', + checked: '', + expanded: '', + selected: '', + pressed: '', + required: '', + readonly: '', + isInViewport: true + }) + const zipPath = await exportActiveRunTrace( + run({ + actionSnapshots: [ + { + timestamp: 1200, + command: 'clickElement', + url: 'https://x/login', + title: 'The Internet', + screenshot: JPEG_1PX, + accessibilityTree: [node('button', 'Login', '#go', 0)] + } + ] + }), + { outputDir, sessionId: 'sess-tree' } + ) + + const files = unzipSync(new Uint8Array(await fs.readFile(zipPath))) + const snap = Object.keys(files).find((n) => n.endsWith('-snapshot.txt')) + expect(snap).toBeDefined() + const text = strFromU8(files[snap!]!) + expect(text).toContain('The Internet') + expect(text).toContain('button "Login"') + }) + + // The JS adapters serialize in-process; theirs is authoritative. + it('leaves a snapshotText the sender already produced alone', async () => { + const outputDir = await tmpDir() + const zipPath = await exportActiveRunTrace( + run({ + actionSnapshots: [ + { + timestamp: 1200, + command: 'clickElement', + screenshot: JPEG_1PX, + snapshotText: 'ALREADY SERIALIZED', + accessibilityTree: [ + { + role: 'button', + name: 'Login', + selector: '#go', + depth: 0, + level: '', + disabled: '', + checked: '', + expanded: '', + selected: '', + pressed: '', + required: '', + readonly: '' + } + ] + } + ] + }), + { outputDir, sessionId: 'sess-keep' } + ) + + const files = unzipSync(new Uint8Array(await fs.readFile(zipPath))) + const snap = Object.keys(files).find((n) => n.endsWith('-snapshot.txt')) + expect(strFromU8(files[snap!]!)).toBe('ALREADY SERIALIZED') + }) + + it('still writes a picture per action when none were streamed', async () => { + const outputDir = await tmpDir() + const zipPath = await exportActiveRunTrace( + run({ + commands: [ + { + command: 'clickElement', + args: ['#go'], + timestamp: 1200, + screenshot: JPEG_1PX + } + ] + }), + { outputDir, sessionId: 'sess-bare' } + ) + + const files = unzipSync(new Uint8Array(await fs.readFile(zipPath))) + const names = Object.keys(files) + expect(names.filter((n) => n.endsWith('-elements.json'))).toEqual([]) + expect(names.some((n) => n.endsWith('.jpeg'))).toBe(true) + }) + // The JS adapters hand their recorder's buffer straight to the exporter // in-process; an adapter exporting through the backend has to send it, so the // frames arrive as a stream and have to survive the round trip into the zip. diff --git a/packages/core/src/action-snapshot.ts b/packages/core/src/action-snapshot.ts index c3b729b7..c5fa0c8e 100644 --- a/packages/core/src/action-snapshot.ts +++ b/packages/core/src/action-snapshot.ts @@ -2,7 +2,7 @@ // `runScript`, `takeScreenshot`, etc. shim so the actual capture pipeline // (timeouts, fallbacks, snapshot serialization) lives in one place. -import { accessibilityTreeScript, elementsScript } from './element-scripts.js' +import { buildElementScripts } from '@wdio/devtools-shared/element-scripts' import { serializeWebSnapshot, serializeMobileSnapshot @@ -129,6 +129,10 @@ export async function captureActionSnapshot( try { const timestamp = input.timestamp ?? Date.now() const isNativeMobile = !input.runScript && !!input.getPageSource + // The same pair the backend serves the Python adapter, so the two cannot + // capture different things. `elements` carries bounds — the per-action + // element rects drive A8 input points. + const scripts = buildElementScripts(input.runner) // Probe order is load-bearing, not cosmetic. A driver serialises requests // per session, so `Promise.all` starting them together still has them served @@ -143,15 +147,10 @@ export async function captureActionSnapshot( isNativeMobile ? probe(input.getPageSource) : undefined, runWith( input.runScript, - accessibilityTreeScript(true, input.runner), - [] - ), - runWith( - input.runScript, - // includeBounds: the per-action element rects drive A8 input points. - elementsScript(true, true, input.runner), + scripts.accessibilityTree, [] ), + runWith(input.runScript, scripts.elements, []), probe(input.takeScreenshot) ]) diff --git a/packages/core/src/element-scripts.ts b/packages/core/src/element-scripts.ts index b92922d3..1e18b51c 100644 --- a/packages/core/src/element-scripts.ts +++ b/packages/core/src/element-scripts.ts @@ -1,425 +1,3 @@ -/** - * Browser-injectable script strings for element extraction. - * - * Each function returns a self-contained JavaScript string designed to run - * inside a browser page via `browser.execute(script)`. The scripts have no - * external dependencies and must be ES5-compatible. - * - * WDIO-dependent wrappers that call `browser.execute(script)` live in - * `@wdio/elements` — these are just the script bodies. - */ - -import { locatorDialect } from '@wdio/devtools-shared' -import type { - LocatorDialect, - TestRunnerId, - TextLocatorDialect -} from '@wdio/devtools-shared' - -/** Shared by both injected scripts below — the same visibility gate decides - * which elements each one reports. */ -const IS_VISIBLE_SCRIPT = ` - function isVisible(el) { - if (typeof el.checkVisibility === 'function') { - return el.checkVisibility({ opacityProperty: true, visibilityProperty: true, contentVisibilityAuto: true }) - } - var style = window.getComputedStyle(el) - return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' && el.offsetWidth > 0 && el.offsetHeight > 0 - } -` - -/** XPath 1.0 has no string escape, so a value carrying both quote kinds is - * stitched from single-kind literals; a literal double quote can only enter the - * expression as its own single-quoted token. */ -const XPATH_TEXT_LITERAL_SCRIPT = ` - function xpathTextLiteral(value) { - if (value.indexOf('"') === -1) { return '"' + value + '"' } - if (value.indexOf("'") === -1) { return "'" + value + "'" } - var quoteToken = "'" + '"' + "'" - var parts = value.split('"') - var pieces = [] - for (var p = 0; p < parts.length; p++) { - if (parts[p]) { pieces.push('"' + parts[p] + '"') } - if (p < parts.length - 1) { pieces.push(quoteToken) } - } - // concat() takes at least two arguments — a value that is nothing but - // double quotes yields one piece and needs no concat. - return pieces.length > 1 ? 'concat(' + pieces.join(', ') + ')' : pieces[0] - } -` - -/** The meaning-bearing CSS branches: portable across all runners, so they are - * dialect-independent. Null when none of them identifies the element uniquely, - * which is what lets a caller order them against the text branch. */ -const SEMANTIC_CSS_SELECTOR_SCRIPT = ` - function semanticCssSelector(element, tag) { - var ariaLabel = element.getAttribute('aria-label') - if (ariaLabel && ariaLabel.length <= 200) { - var sel = '[aria-label="' + CSS.escape(ariaLabel) + '"]' - if (document.querySelectorAll(sel).length === 1) { return sel } - } - var testId = element.getAttribute('data-testid') - if (testId) { - var testSel = '[data-testid="' + CSS.escape(testId) + '"]' - if (document.querySelectorAll(testSel).length === 1) { return testSel } - } - if (element.id) { - var idSel = '#' + CSS.escape(element.id) - if (document.querySelectorAll(idSel).length === 1) { return idSel } - } - var nameAttr = element.getAttribute('name') - if (nameAttr) { - var nameSel = tag + '[name="' + CSS.escape(nameAttr) + '"]' - if (document.querySelectorAll(nameSel).length === 1) { return nameSel } - } - var typeAttr = element.getAttribute('type') - if (typeAttr) { - var typeSel = tag + '[type="' + CSS.escape(typeAttr) + '"]' - if (document.querySelectorAll(typeSel).length === 1) { return typeSel } - } - if (element.className && typeof element.className === 'string') { - var classes = element.className.trim().split(/\\s+/).filter(Boolean) - for (var i = 0; i < classes.length; i++) { - var clsSel = tag + '.' + CSS.escape(classes[i]) - if (document.querySelectorAll(clsSel).length === 1) { return clsSel } - } - if (classes.length >= 2) { - var twoClsSel = tag + classes.slice(0, 2).map(function(c) { return '.' + CSS.escape(c) }).join('') - if (document.querySelectorAll(twoClsSel).length === 1) { return twoClsSel } - } - } - return null - } -` - -/** Last resort: a positional `:nth-of-type` path, which always resolves but - * carries no meaning — hence every other branch getting first refusal. */ -const POSITIONAL_SELECTOR_SCRIPT = ` - function positionalSelector(element) { - var current = element - var path = [] - while (current && current !== document.documentElement) { - var seg = current.tagName.toLowerCase() - if (current.id) { path.unshift('#' + CSS.escape(current.id)); break } - var parent = current.parentElement - if (parent) { - var siblings = Array.from(parent.children).filter(function(c) { return c.tagName === current.tagName }) - if (siblings.length > 1) { seg += ':nth-of-type(' + (siblings.indexOf(current) + 1) + ')' } - } - path.unshift(seg) - current = current.parentElement - if (path.length >= 4) { break } - } - return path.join(' > ') - } -` - -/** The text branch's return expression. WebdriverIO's `tag*=text` compiles - * internally to XPath with `"` quoting, so a text carrying a double quote would - * yield a broken expression — those keep the XPath form, which it also resolves. */ -function textLocatorExpression(dialect: TextLocatorDialect): string { - const xpath = "'//' + tag + '[contains(., ' + xpathTextLiteral(text) + ')]'" - return dialect === 'webdriverio' - ? `text.indexOf('"') === -1 ? tag + '*=' + text : ${xpath}` - : xpath -} - -/** Identify the element by its own text, in `dialect`'s grammar. Null when the - * text neither exists nor singles it out, so it composes with the CSS branches - * in either order. */ -function textSelectorScript(dialect: TextLocatorDialect): string { - return ` - function textSelector(element, tag) { - var text = (element.textContent || '').trim().replace(/\\s+/g, ' ') - if (!text || text.length > 120) { return null } - var sameTagElements = document.querySelectorAll(tag) - var matchCount = 0 - sameTagElements.forEach(function(el) { if (el.textContent.includes(text)) { matchCount++ } }) - // The DOM predicate this counts is exactly XPath's - // \`//tag[contains(., text)]\`, so a single match here is a unique match - // there — the emitted expression carries the uniqueness just checked. - if (matchCount !== 1) { return null } - return ${textLocatorExpression(dialect)} - } -` -} - -/** Shared by both injected scripts below, so one grammar produces the locator in - * `-snapshot.txt` and `-elements.json`. The dialect decides both what the text - * branch emits and where it sits; the positional path stays last either way. */ -function getSelectorScript(dialect: LocatorDialect): string { - const preferred = - dialect.textBranch === 'first' - ? ['textSelector(element, tag)', 'semanticCssSelector(element, tag)'] - : ['semanticCssSelector(element, tag)', 'textSelector(element, tag)'] - return ` - ${XPATH_TEXT_LITERAL_SCRIPT} - ${SEMANTIC_CSS_SELECTOR_SCRIPT} - ${POSITIONAL_SELECTOR_SCRIPT} - ${textSelectorScript(dialect.text)} - - function getSelector(element) { - var tag = element.tagName.toLowerCase() - return ${preferred[0]} || ${preferred[1]} || positionalSelector(element) - } -` -} - -/** - * Accessibility tree walk — returns a flat array of AccessibilityNode. - * - * Walks the DOM from `document.body`, assigning semantic roles (button, link, - * textbox, heading, img, statictext, …) based on tag name, ARIA attributes, - * and visibility. Each node carries a unique locator, in `runner`'s own text - * dialect — omit it for the portable XPath form every runner resolves. - */ -export function accessibilityTreeScript( - inViewportOnly: boolean, - runner?: TestRunnerId -): string { - return `(function () { - var INPUT_TYPE_ROLES = { - text: 'textbox', search: 'searchbox', email: 'textbox', url: 'textbox', - tel: 'textbox', password: 'textbox', number: 'spinbutton', - checkbox: 'checkbox', radio: 'radio', range: 'slider', - submit: 'button', reset: 'button', image: 'button', file: 'button', color: 'button' - } - - var CONTAINER_ROLES = new Set([ - 'navigation', 'banner', 'contentinfo', 'complementary', 'main', - 'form', 'region', 'group', 'list', 'listitem', 'table', 'row', 'rowgroup', 'generic' - ]) - - function getRole(el) { - var explicit = el.getAttribute('role') - if (explicit) { return explicit.split(' ')[0] } - var tag = el.tagName.toLowerCase() - switch (tag) { - case 'button': return 'button' - case 'a': return el.hasAttribute('href') ? 'link' : null - case 'input': { - var type = (el.getAttribute('type') || 'text').toLowerCase() - if (type === 'hidden') { return null } - return INPUT_TYPE_ROLES[type] || 'textbox' - } - case 'select': return 'combobox' - case 'textarea': return 'textbox' - case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': return 'heading' - case 'img': return 'img' - case 'nav': return 'navigation' - case 'main': return 'main' - case 'header': return !el.closest('article,aside,main,nav,section') ? 'banner' : null - case 'footer': return !el.closest('article,aside,main,nav,section') ? 'contentinfo' : null - case 'aside': return 'complementary' - case 'dialog': return 'dialog' - case 'form': return 'form' - case 'section': return el.hasAttribute('aria-label') || el.hasAttribute('aria-labelledby') ? 'region' : null - case 'summary': return 'button' - case 'details': return 'group' - case 'progress': return 'progressbar' - case 'meter': return 'meter' - case 'ul': case 'ol': return 'list' - case 'li': return 'listitem' - case 'table': return 'table' - } - if (el.contentEditable === 'true') { return 'textbox' } - if (el.hasAttribute('tabindex') && parseInt(el.getAttribute('tabindex') || '-1', 10) >= 0) { return 'generic' } - if (getDirectText(el)) { return 'statictext' } - return null - } - - function getAccessibleName(el, role) { - var ariaLabel = el.getAttribute('aria-label') - if (ariaLabel) { return ariaLabel.trim() } - var labelledBy = el.getAttribute('aria-labelledby') - if (labelledBy) { - var texts = labelledBy.split(/\\s+/).map(function(id) { return (document.getElementById(id)?.textContent || '').trim() }).filter(Boolean) - if (texts.length > 0) { return texts.join(' ').slice(0, 200) } - } - var tag = el.tagName.toLowerCase() - if (tag === 'img' || (tag === 'input' && el.getAttribute('type') === 'image')) { - var alt = el.getAttribute('alt') - if (alt !== null) { return alt.trim() } - } - if (['input', 'select', 'textarea'].indexOf(tag) !== -1) { - var id = el.getAttribute('id') - if (id) { - var label = document.querySelector('label[for="' + CSS.escape(id) + '"]') - if (label) { return (label.textContent || '').trim() } - } - var parentLabel = el.closest('label') - if (parentLabel) { - var clone = parentLabel.cloneNode(true) - clone.querySelectorAll('input,select,textarea').forEach(function(n) { n.remove() }) - var lt = (clone.textContent || '').trim() - if (lt) { return lt } - } - } - var ph = el.getAttribute('placeholder') - if (ph) { return ph.trim() } - var title = el.getAttribute('title') - if (title) { return title.trim() } - var childImg = el.querySelector('img') - if (childImg) { - var imgAlt = childImg.getAttribute('alt') - if (imgAlt) { return imgAlt.trim() } - } - if (role && CONTAINER_ROLES.has(role)) { return '' } - return ((el.textContent || '').trim().replace(/\\s+/g, ' ') || '').slice(0, 200) - } - - ${getSelectorScript(locatorDialect(runner))} - - function getDirectText(el) { - var text = '' - for (var i = 0; i < el.childNodes.length; i++) { - if (el.childNodes[i].nodeType === 3) { text += el.childNodes[i].textContent } - } - return text.trim().replace(/\\s+/g, ' ') - } - - ${IS_VISIBLE_SCRIPT} - - function isInViewport(el) { - var rect = el.getBoundingClientRect() - return rect.top >= 0 && rect.left >= 0 && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && rect.right <= (window.innerWidth || document.documentElement.clientWidth) - } - - function getLevel(el) { - var m = el.tagName.toLowerCase().match(/^h([1-6])$/) - if (m) { return parseInt(m[1], 10) } - var ariaLevel = el.getAttribute('aria-level') - if (ariaLevel) { return parseInt(ariaLevel, 10) } - return undefined - } - - function getState(el) { - var inputEl = el - var isCheckable = ['input', 'menuitemcheckbox', 'menuitemradio'].indexOf(el.tagName.toLowerCase()) !== -1 || ['checkbox', 'radio', 'switch'].indexOf(el.getAttribute('role') || '') !== -1 - return { - disabled: el.getAttribute('aria-disabled') === 'true' || inputEl.disabled ? 'true' : '', - checked: isCheckable && inputEl.checked ? 'true' : el.getAttribute('aria-checked') || '', - expanded: el.getAttribute('aria-expanded') || '', - selected: el.getAttribute('aria-selected') || '', - pressed: el.getAttribute('aria-pressed') || '', - required: inputEl.required || el.getAttribute('aria-required') === 'true' ? 'true' : '', - readonly: inputEl.readOnly || el.getAttribute('aria-readonly') === 'true' ? 'true' : '' - } - } - - var result = [] - - function walk(el, depth) { - if (depth > 200) { return } - if (!isVisible(el)) { return } - var role = getRole(el) - var inViewport = isInViewport(el) - if (!role) { - for (var i = 0; i < el.children.length; i++) { walk(el.children[i], depth + 1) } - return - } - if (${inViewportOnly} && !inViewport) { - for (var i = 0; i < el.children.length; i++) { walk(el.children[i], depth + 1) } - return - } - var name = getAccessibleName(el, role) - var selector = getSelector(el) - var node = { role: role, name: name, selector: selector, depth: depth, level: getLevel(el) ?? '', isInViewport: inViewport } - var state = getState(el) - for (var k in state) { node[k] = state[k] } - result.push(node) - for (var i = 0; i < el.children.length; i++) { walk(el.children[i], depth + 1) } - } - - for (var i = 0; i < document.body.children.length; i++) { walk(document.body.children[i], 0) } - return result - })()` -} - -/** - * Interactable element query — returns a flat array of BrowserElementInfo. - * - * Uses `querySelectorAll` with a broad interactable-selector list, then - * filters by visibility and (optionally) viewport containment. Each element - * gets a computed accessible name and a unique locator, in `runner`'s own text - * dialect — omit it for the portable XPath form every runner resolves. - */ -export function elementsScript( - includeBounds: boolean, - inViewportOnly: boolean, - runner?: TestRunnerId -): string { - return `(function () { - var interactableSelectors = [ - 'a[href]', 'button', 'input:not([type="hidden"])', 'select', 'textarea', - '[role="button"]', '[role="link"]', '[role="checkbox"]', '[role="radio"]', - '[role="tab"]', '[role="menuitem"]', '[role="combobox"]', '[role="option"]', - '[role="switch"]', '[role="slider"]', '[role="textbox"]', '[role="searchbox"]', - '[role="spinbutton"]', '[contenteditable="true"]', '[tabindex]:not([tabindex="-1"])' - ].join(',') - - ${IS_VISIBLE_SCRIPT} - - function getAccessibleName(el) { - var ariaLabel = el.getAttribute('aria-label') - if (ariaLabel) { return ariaLabel.trim() } - var labelledBy = el.getAttribute('aria-labelledby') - if (labelledBy) { - var texts = labelledBy.split(/\\s+/).map(function(id) { return (document.getElementById(id)?.textContent || '').trim() }).filter(Boolean) - if (texts.length > 0) { return texts.join(' ').slice(0, 200) } - } - var tag = el.tagName.toLowerCase() - if (tag === 'img' || (tag === 'input' && el.getAttribute('type') === 'image')) { - var alt = el.getAttribute('alt') - if (alt !== null) { return alt.trim() } - } - if (['input', 'select', 'textarea'].indexOf(tag) !== -1) { - var id = el.getAttribute('id') - if (id) { - var label = document.querySelector('label[for="' + CSS.escape(id) + '"]') - if (label) { return (label.textContent || '').trim() } - } - var parentLabel = el.closest('label') - if (parentLabel) { - var clone = parentLabel.cloneNode(true) - clone.querySelectorAll('input,select,textarea').forEach(function(n) { n.remove() }) - var lt = (clone.textContent || '').trim() - if (lt) { return lt } - } - } - var ph = el.getAttribute('placeholder') - if (ph) { return ph.trim() } - var title = el.getAttribute('title') - if (title) { return title.trim() } - return ((el.textContent || '').trim().replace(/\\s+/g, ' ') || '').slice(0, 200) - } - - ${getSelectorScript(locatorDialect(runner))} - - var elements = [] - var seen = new Set() - - document.querySelectorAll(interactableSelectors).forEach(function(el) { - if (seen.has(el)) { return } - seen.add(el) - var htmlEl = el - if (!isVisible(htmlEl)) { return } - var inputEl = htmlEl - var rect = htmlEl.getBoundingClientRect() - var isInVp = rect.top >= 0 && rect.left >= 0 && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && rect.right <= (window.innerWidth || document.documentElement.clientWidth) - if (${inViewportOnly} && !isInVp) { return } - var entry = { - tagName: htmlEl.tagName.toLowerCase(), - name: getAccessibleName(htmlEl), - type: htmlEl.getAttribute('type') || '', - value: inputEl.value || '', - href: htmlEl.getAttribute('href') || '', - selector: getSelector(htmlEl), - isInViewport: isInVp - } - ${includeBounds ? 'entry.boundingBox = { x: rect.x + window.scrollX, y: rect.y + window.scrollY, width: rect.width, height: rect.height }' : ''} - elements.push(entry) - }) - return elements - })()` -} +// The source lives in `shared` so the backend can serve it; this shim stays +// because an exports map may not point outside its own package. +export * from '@wdio/devtools-shared/element-scripts' diff --git a/packages/core/src/element-snapshot.ts b/packages/core/src/element-snapshot.ts index 09b971ed..1f627724 100644 --- a/packages/core/src/element-snapshot.ts +++ b/packages/core/src/element-snapshot.ts @@ -12,9 +12,17 @@ import type { SnapshotResult } from './element-types.js' +// The web serializer lives in `trace` because the backend builds the trace for +// an adapter that cannot, and §2.2 bars the backend from importing core. +export { + serializeWebSnapshot, + type WebSnapshotOptions +} from '@wdio/devtools-trace/a11y-snapshot' + import { SNAPSHOT_INDENT_UNIT, - SNAPSHOT_PAGE_HEADER, + INTERACTIVE_ROLES, + isStatictextEchoedByParent, SNAPSHOT_LOCATOR_DELIM, SNAPSHOT_PURPOSE_TOKEN, xpathLocatorTag @@ -28,172 +36,10 @@ import { } from './locators/constants.js' import { getSuggestedLocators } from './locators/locator-generation.js' -/** - * Roles that can be interacted with — rendered with `→ selector`. - * Structural roles (heading, img, form, nav, …) are intentionally excluded. - */ -const INTERACTIVE_ROLES = new Set([ - 'button', - 'link', - 'textbox', - 'checkbox', - 'radio', - 'combobox', - 'slider', - 'searchbox', - 'spinbutton', - 'switch', - 'tab', - 'menuitem', - 'option' -]) - -/** - * Walk backwards from `index` to find the nearest ancestor or preceding - * structural sibling with a non-empty name. Same-depth nodes are only - * used when they are structural (img, heading, statictext, …) — never - * another interactive element. - */ -function inferPurpose( - nodes: AccessibilityNode[], - index: number -): string | undefined { - const myDepth = nodes[index].depth - for (let i = index - 1; i >= 0; i--) { - if (nodes[i].depth <= myDepth && nodes[i].name) { - // Same-depth sibling: only structural elements count - if (nodes[i].depth === myDepth && INTERACTIVE_ROLES.has(nodes[i].role)) { - continue - } - return nodes[i].name - } - } - return undefined -} - -export interface WebSnapshotOptions { - /** Only include nodes whose bounding rect intersects the viewport (default true). */ - inViewportOnly?: boolean -} - -/** - * Serialize a web accessibility tree into a depth-indented text snapshot. - * - * @param nodes Flat ordered node list from getBrowserAccessibilityTree() - * @param context Optional page context for the header line - * @param options {@link WebSnapshotOptions} - */ -export function serializeWebSnapshot( - nodes: AccessibilityNode[], - context?: { url?: string; title?: string }, - options: WebSnapshotOptions = {} -): string { - const { inViewportOnly = true } = options - - let header = SNAPSHOT_PAGE_HEADER - if (context?.title) { - header += `: ${context.title}` - } - if (context?.url) { - header += ` — ${context.url}` - } - header += ']' - - const lines: string[] = [header] - - for (let i = 0; i < nodes.length; i++) { - const node = nodes[i] - - // When viewport filtering is on, skip nodes that are known to be off-screen. - // Nodes from a tree captured with inViewportOnly=false will have - // isInViewport populated; nodes from a pre-filtered tree all have - // isInViewport=true (or undefined for pre-existing data). - if (inViewportOnly && node.isInViewport === false) { - continue - } - - const indent = SNAPSHOT_INDENT_UNIT.repeat(node.depth + 1) // +1 indents everything under the header - const isInteractive = INTERACTIVE_ROLES.has(node.role) - - if (isStatictextEchoedByParent(nodes, i)) { - continue - } - - // Heading gets level suffix: heading[2] - const roleLabel = - node.role === 'heading' && node.level - ? `heading[${node.level}]` - : node.role - - if (isInteractive) { - // No selector → agent can't act on this node; skip entirely - if (!node.selector) { - continue - } - const purpose = inferPurpose(nodes, i) - if (node.name) { - // Show parent context when available — disambiguates - // duplicate selectors like six "Add to Wishlist" buttons. - lines.push( - purpose - ? `${indent}${roleLabel} "${node.name}" ${SNAPSHOT_PURPOSE_TOKEN} "${purpose}" ${SNAPSHOT_LOCATOR_DELIM} ${node.selector}` - : `${indent}${roleLabel} "${node.name}" ${SNAPSHOT_LOCATOR_DELIM} ${node.selector}` - ) - } else if (purpose) { - lines.push( - `${indent}${roleLabel} ${SNAPSHOT_PURPOSE_TOKEN} "${purpose}" ${SNAPSHOT_LOCATOR_DELIM} ${node.selector}` - ) - } else { - lines.push( - `${indent}${roleLabel} ${SNAPSHOT_LOCATOR_DELIM} ${node.selector}` - ) - } - } else { - // Container / structural: show role + name when present, no selector - lines.push( - node.name - ? `${indent}${roleLabel} "${node.name}"` - : `${indent}${roleLabel}` - ) - } - } - - return lines.join('\n') -} - // --------------------------------------------------------------------------- // Mobile snapshot helpers // --------------------------------------------------------------------------- -/** - * Returns true when `nodes[index]` is a statictext whose accessible name - * is already echoed by its immediate interactive parent — such a node - * adds no information and should be suppressed from the output. - */ -function isStatictextEchoedByParent( - nodes: AccessibilityNode[], - index: number -): boolean { - const node = nodes[index]! - if (node.role !== 'statictext' || !node.name) { - return false - } - for (let j = index - 1; j >= 0; j--) { - if (nodes[j]!.depth < node.depth) { - const parent = nodes[j]! - if ( - INTERACTIVE_ROLES.has(parent.role) && - parent.name && - parent.name.includes(node.name) - ) { - return true - } - break - } - } - return false -} - /** Shorten fully-qualified Android/iOS class names to the last segment. */ function simplifyTag(tagName: string): string { const dot = tagName.lastIndexOf('.') diff --git a/packages/core/src/element-types.ts b/packages/core/src/element-types.ts index 9dc510ea..694b6735 100644 --- a/packages/core/src/element-types.ts +++ b/packages/core/src/element-types.ts @@ -6,39 +6,13 @@ * scripts and mobile page-source parsing. They have no WebdriverIO dependency. */ -export interface AccessibilityNode { - role: string - name: string - selector: string - depth: number - level: number | string - disabled: string - checked: string - expanded: string - selected: string - pressed: string - required: string - readonly: string - /** Whether the element's bounding rect intersects the viewport. */ - isInViewport?: boolean -} - -export interface BrowserElementInfo { - tagName: string - name: string // computed accessible name (ARIA spec) - type: string - value: string - href: string - selector: string - isInViewport: boolean - boundingBox?: { x: number; y: number; width: number; height: number } -} - -export interface GetBrowserElementsOptions { - includeBounds?: boolean - /** Only return elements whose bounding rect intersects the viewport (default true). */ - inViewportOnly?: boolean -} +// These describe what the page-side scripts return, so they live beside those +// scripts in `shared` — where the backend, which serves them, can reach them. +export type { + AccessibilityNode, + BrowserElementInfo, + GetBrowserElementsOptions +} from '@wdio/devtools-shared' // Re-export mobile types from locators for convenience. // Downstream consumers can also import directly from @wdio/devtools-core/locators. diff --git a/packages/core/tests/element-snapshot.test.ts b/packages/core/tests/element-snapshot.test.ts new file mode 100644 index 00000000..245e42da --- /dev/null +++ b/packages/core/tests/element-snapshot.test.ts @@ -0,0 +1,35 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it } from 'vitest' + +import { accessibilityTreeScript } from '@wdio/devtools-shared' +import type { AccessibilityNode } from '@wdio/devtools-shared' +import { accessibilityNodesToSnapshotNodes } from '../src/element-snapshot.js' + +/** The script is injectable source, so it is run the way the adapters run it. */ +function a11yNodes(html: string, runner: 'mocha' | 'nightwatch') { + document.body.innerHTML = html + return new Function( + `return ${accessibilityTreeScript(false, runner)}` + )() as AccessibilityNode[] +} + +beforeEach(() => { + // happy-dom has no layout engine, so the script's visibility gate would reject + // every element and the walk would return nothing. + Element.prototype.checkVisibility = () => true +}) + +describe('accessibilityNodesToSnapshotNodes', () => { + it('yields its tag from a captured locator in either dialect', () => { + // The serializer reads the tag back out of the locator it was handed, so a + // dialect it can't parse would report the ARIA role as the tag instead. + for (const runner of ['mocha', 'nightwatch'] as const) { + const nodes = accessibilityNodesToSnapshotNodes( + a11yNodes(' Logout', runner), + { inViewportOnly: false } + ) + + expect(nodes.find((n) => n.name === 'Logout')?.tagName).toBe('a') + } + }) +}) diff --git a/packages/selenium-devtools-py/scripts/gen_contract.py b/packages/selenium-devtools-py/scripts/gen_contract.py index b54452f4..836ad9d9 100644 --- a/packages/selenium-devtools-py/scripts/gen_contract.py +++ b/packages/selenium-devtools-py/scripts/gen_contract.py @@ -45,6 +45,8 @@ # The dense filmstrip. The JS adapters hand their recorder's buffer to the # exporter in-process; an adapter exporting through the backend sends it. "SCOPE_SCREENCAST_FRAMES": "screencastFrames", + # Per-action element trees — what the trace's A11y tab reads. + "SCOPE_ACTION_SNAPSHOTS": "actionSnapshots", } @@ -90,6 +92,25 @@ def _trace_export_scopes(trace_export_ts: str) -> dict[str, str]: return dict(re.findall(r"(\w+):\s*'([^']+)'", m.group(1))) +def _element_scripts_path(element_scripts_ts: str) -> str: + """`ELEMENT_SCRIPTS_API.get` — where the backend serves the page-side + element scripts. Python cannot import them; this route is the only way it + reaches the code that fills the A11y tab.""" + m = re.search( + r"export const ELEMENT_SCRIPTS_API = \{(.*?)\n\} as const", + element_scripts_ts, + re.DOTALL, + ) + if not m: + raise SystemExit( + "could not find `ELEMENT_SCRIPTS_API` in shared/element-scripts.ts" + ) + got = re.search(r"get:\s*'([^']+)'", m.group(1)) + if not got: + raise SystemExit("`ELEMENT_SCRIPTS_API` has no `get` path") + return got.group(1) + + def _collector_path(collector_ts: str) -> str: """The route the backend serves the page-side collector from.""" m = re.search(r"export const COLLECTOR_API = \{(.*?)\} as const", collector_ts, re.DOTALL) @@ -180,6 +201,9 @@ def main() -> int: data_keys = _trace_log_keys(types_ts) runner_ids = _test_runner_ids(types_ts) collector_path = _collector_path((shared / "src" / "collector.ts").read_text()) + element_scripts_path = _element_scripts_path( + (shared / "src" / "element-scripts.ts").read_text() + ) trace_export = _trace_export_scopes( (shared / "src" / "trace-export.ts").read_text() ) @@ -262,6 +286,7 @@ def main() -> int: f"CONTROL_SCOPES = frozenset({sorted(control.values())!r})", "", f'COLLECTOR_PATH = "{collector_path}"', + f'ELEMENT_SCRIPTS_PATH = "{element_scripts_path}"', f'RUNNER_ID = "{REQUIRED_RUNNER_ID}"', f"TEST_RUNNER_IDS = frozenset({sorted(runner_ids)!r})", "", diff --git a/packages/selenium-devtools-py/src/selenium_devtools/__init__.py b/packages/selenium-devtools-py/src/selenium_devtools/__init__.py index 8f70c70b..b36cda99 100644 --- a/packages/selenium-devtools-py/src/selenium_devtools/__init__.py +++ b/packages/selenium-devtools-py/src/selenium_devtools/__init__.py @@ -21,7 +21,14 @@ import threading from typing import Optional -from . import backend, instrumentation, lifecycle, rerun, trace_export +from . import ( + backend, + element_scripts, + instrumentation, + lifecycle, + rerun, + trace_export, +) from ._contract import CONTRACT_VERSION from .capturer import SessionCapturer from .output_dir import resolve_adapter_output_dir @@ -30,6 +37,7 @@ DEFAULT_HOST, DEFAULT_PORT, ENV_HOST, + ENV_A11Y, ENV_FILMSTRIP, ENV_PORT, ENV_TRACE, @@ -92,6 +100,18 @@ def _filmstrip_enabled(filmstrip: Optional[bool]) -> bool: return value.lower() not in ("0", "false", "no", "off", "") +def _a11y_enabled(a11y: Optional[bool]) -> bool: + """Whether trace mode captures the per-action element tree. Default ON — + the A11y tab is empty without it — opt out with DEVTOOLS_A11Y=0. Two extra + round trips per command is the cost, which live mode never pays.""" + if a11y is not None: + return a11y + value = os.environ.get(ENV_A11Y) + if value is None: + return True + return value.lower() not in ("0", "false", "no", "off", "") + + def _trace_enabled(trace: Optional[bool]) -> bool: """Whether this run writes a trace archive. The argument wins over the environment so a script can opt out of an exported default.""" @@ -193,6 +213,11 @@ def _export_trace( if mark is None or f.get("timestamp", 0) >= mark ] sent = trace_export.send_frames(_active["transport"], pending) + # Same pass: both are streams the backend has no other way to get, and + # both have to land before the request that reads them. + trace_export.send_action_snapshots( + _active["transport"], instrumentation.action_snapshots() + ) if sent: _active["filmstrip_mark"] = pending[sent - 1].get("timestamp", mark) return trace_export.export( @@ -212,6 +237,7 @@ def enable( webdriver_cls: Optional[type] = None, trace: Optional[bool] = None, filmstrip: Optional[bool] = None, + a11y: Optional[bool] = None, ) -> Optional[SessionCapturer]: """Connect to the backend and instrument Selenium. Idempotent. @@ -227,6 +253,7 @@ def enable( # window and the teardown export all branch on this. trace_mode = _trace_enabled(trace) filmstrip_mode = trace_mode and _filmstrip_enabled(filmstrip) + a11y_mode = trace_mode and _a11y_enabled(a11y) # Before the backend is launched: the directory a rerun spawns in travels # through the environment the backend process inherits. A framework plugin @@ -262,8 +289,17 @@ def enable( capturer = SessionCapturer(transport) instrumentation.install( - capturer, webdriver_cls, trace=trace_mode, filmstrip=filmstrip_mode + capturer, + webdriver_cls, + trace=trace_mode, + filmstrip=filmstrip_mode, + a11y=a11y_mode, ) + if a11y_mode: + # Fetched here, not per action: the scripts are the same all run, and + # a backend too old to serve them should cost one request, not one per + # command. None leaves the capture a no-op. + instrumentation.set_element_scripts(element_scripts.fetch(host, port)) # Plain scripts only: a framework plugin calls # `set_external_suites`, which turns this back off. instrumentation.start_assertion_tracing(capturer) diff --git a/packages/selenium-devtools-py/src/selenium_devtools/_contract.py b/packages/selenium-devtools-py/src/selenium_devtools/_contract.py index 74ea76c0..d07778de 100644 --- a/packages/selenium-devtools-py/src/selenium_devtools/_contract.py +++ b/packages/selenium-devtools-py/src/selenium_devtools/_contract.py @@ -11,12 +11,14 @@ SCOPE_SOURCES = "sources" SCOPE_MUTATIONS = "mutations" SCOPE_SCREENCAST_FRAMES = "screencastFrames" +SCOPE_ACTION_SNAPSHOTS = "actionSnapshots" SCOPE_REPLACE_COMMAND = "replaceCommand" DATA_SCOPES = frozenset(['actionSnapshots', 'commands', 'config', 'consoleLogs', 'logs', 'metadata', 'mutations', 'networkRequests', 'screencast', 'screencastFrames', 'sources', 'suites']) CONTROL_SCOPES = frozenset(['clearCommands', 'clearExecutionData', 'clientConnected', 'clientDisconnected', 'config', 'replaceCommand', 'testStopped']) COLLECTOR_PATH = "/api/collector" +ELEMENT_SCRIPTS_PATH = "/api/element-scripts" RUNNER_ID = "selenium-webdriver" TEST_RUNNER_IDS = frozenset(['cucumber', 'jasmine', 'mocha', 'nightwatch', 'nightwatch-cucumber', 'selenium-webdriver']) diff --git a/packages/selenium-devtools-py/src/selenium_devtools/assertions.py b/packages/selenium-devtools-py/src/selenium_devtools/assertions.py index 7b5e7d4c..ae9e76f5 100644 --- a/packages/selenium-devtools-py/src/selenium_devtools/assertions.py +++ b/packages/selenium-devtools-py/src/selenium_devtools/assertions.py @@ -29,10 +29,27 @@ # Getting this backwards silently swaps every containment row's two values. _EXPECTATION_ON_LEFT = frozenset({"in", "not in"}) -# The row label. One name for every assertion, because Python's `assert` has no -# matcher vocabulary to name it by — the expression itself carries the meaning -# and travels as the row's argument. -ASSERT_COMMAND = "assert" +# The row label. `assert.`, never a bare `assert`: shared's ACTION_MAP +# matches `^(?:assert|verify|expect)\.(\w+)$`, and the trace exporter silently +# drops a command it cannot map — so a bare name produced rows that showed in +# live mode and vanished from every trace. +ASSERT_COMMAND = "assert.ok" + +# Python's operators named as node:assert's methods, so a row renders the way +# the JS adapters' do. Anything without a direct equivalent — the orderings, +# containment, and `assert x` with no operator at all — is `ok`, which is +# exactly what node:assert calls a bare truthiness check. +_OPERATOR_METHODS = { + "==": "equal", + "!=": "notEqual", + "is": "strictEqual", + "is not": "notStrictEqual", +} + + +def assert_command(op: Optional[str] = None) -> str: + """The command name for an assertion on ``op``.""" + return f"assert.{_OPERATOR_METHODS.get(op or '', 'ok')}" def expected_and_actual(op: str, left: Any, right: Any) -> Tuple[Any, Any]: @@ -63,8 +80,12 @@ def collapsed_result( result: Dict[str, Any] = {"passed": passed} if op is not None: expected, actual = expected_and_actual(op, left, right) - result["expected"] = expected - result["actual"] = actual + # Whichever side resolved. `"/login" in driver.current_url` can only ever + # know the literal, and reporting one value beats reporting none. + if expected is not _UNRESOLVED: + result["expected"] = expected + if actual is not _UNRESOLVED: + result["actual"] = actual # `assert cond, msg` makes the message whatever `msg` evaluated to, and the # idiomatic `assert needle in haystack, haystack` makes that the actual value # — so it would render as a third row repeating the second verbatim. @@ -118,9 +139,10 @@ def parse_assert_statement( """(condition source, operands) read from a plain script's `assert` line. A script's assert is never rewritten, so this is the only route to the values - — and it is deliberately partial. Operands come back only for a single - comparison whose sides are both safe to read; everything else yields the - condition text alone, which is still more than the bare message. + — and it is deliberately partial. Operands come back for a single comparison + with at least one side safe to read, the unreadable side left as the + ``_UNRESOLVED`` sentinel for `collapsed_result` to drop; everything else + yields the condition text alone, which is still more than the bare message. """ text = (line or "").strip() # Falls back to the text with the keyword removed, so an assert this cannot @@ -145,7 +167,10 @@ def parse_assert_statement( return source, None left = _resolve_operand(test.left, frame) right = _resolve_operand(test.comparators[0], frame) - if left is _UNRESOLVED or right is _UNRESOLVED: + # One side is enough. Requiring both dropped the literal in the shape a + # browser test asserts most — `"/login" in driver.current_url` — where the + # unreadable side is exactly the one that must not be re-run. + if left is _UNRESOLVED and right is _UNRESOLVED: return source, None return source, (op, left, right) diff --git a/packages/selenium-devtools-py/src/selenium_devtools/capturer.py b/packages/selenium-devtools-py/src/selenium_devtools/capturer.py index ffac269f..e705156d 100644 --- a/packages/selenium-devtools-py/src/selenium_devtools/capturer.py +++ b/packages/selenium-devtools-py/src/selenium_devtools/capturer.py @@ -23,7 +23,7 @@ SCOPE_SOURCES, SCOPE_SUITES, ) -from .types import CommandLog, SuiteStats +from .types import CommandLog, SuiteStats, Viewport from .utils import now_ms, to_jsonable @@ -45,7 +45,11 @@ def __init__(self, transport: Transport) -> None: # ── metadata ─────────────────────────────────────────────────────────────── def ensure_metadata( - self, session_id: str, capabilities: Optional[dict], url: Optional[str] + self, + session_id: str, + capabilities: Optional[dict], + url: Optional[str], + viewport: Optional[Viewport] = None, ) -> None: """Announce a session once. Keyed by id, not a boolean: one process can drive several sessions (a function-scoped pytest fixture makes a driver @@ -62,6 +66,7 @@ def ensure_metadata( to_jsonable(capabilities or {}), url, run_options=rerun.run_options(), + viewport=viewport, ), ) @@ -77,6 +82,7 @@ def capture_command( start_time: int, call_source: Optional[str], screenshot: Optional[str] = None, + selector: Optional[str] = None, ) -> CommandLog: with self._lock: self._command_counter += 1 @@ -92,6 +98,7 @@ def capture_command( call_source=call_source, command_id=command_id, screenshot=screenshot, + selector=selector, ) self._tx.send_json(SCOPE_COMMANDS, [entry]) # Returned so a caller that learns more about the command AFTER it was diff --git a/packages/selenium-devtools-py/src/selenium_devtools/constants.py b/packages/selenium-devtools-py/src/selenium_devtools/constants.py index 0e281dbd..4ee79f21 100644 --- a/packages/selenium-devtools-py/src/selenium_devtools/constants.py +++ b/packages/selenium-devtools-py/src/selenium_devtools/constants.py @@ -76,6 +76,10 @@ # floor its dependencies require; below it the process starts and then dies on # syntax it cannot parse, which surfaces here only as "exited before reporting # a port". Checked up front so the message names the real problem. +MIN_NODE_MAJOR = 18 +NODE_VERSION_TIMEOUT_S = 5.0 + +# ── Trace mode ─────────────────────────────────────────────────────────────── # How long to wait for the backend to answer a trace export. The archive is # assembled from a whole run's frames, so it is not instant; but a run that # captured everything and then hung waiting for a file is worse than one that @@ -89,14 +93,22 @@ #: adapters). Falsy values disable it. ENV_FILMSTRIP = "DEVTOOLS_FILMSTRIP" +#: Opt OUT of per-action element capture in trace mode (on by default). +ENV_A11Y = "DEVTOOLS_A11Y" + #: Filmstrip frames per websocket message. The buffer can hold #: SCREENCAST_MAX_BUFFER_FRAMES JPEGs, which in one message would approach the #: socket's payload limit; the transport also masks payloads in a per-byte #: Python loop (~57 MB/s measured), so smaller messages keep the stall short. SCREENCAST_FRAME_BATCH = 50 -MIN_NODE_MAJOR = 18 -NODE_VERSION_TIMEOUT_S = 5.0 +#: Action snapshots per message. Smaller than the frame batch: each carries a +#: screenshot AND an element tree, so a batch is heavier per item. +ACTION_SNAPSHOT_BATCH = 20 + +#: How long to wait for the backend to hand over page-side source. Short: it is +#: a loopback request to a process we launched, and a hang here stalls the run. +ELEMENT_SCRIPTS_FETCH_TIMEOUT_S = 5.0 # ── Instrumentation ────────────────────────────────────────────────────────── # Selenium commands that are bookkeeping/noise rather than user-meaningful. @@ -113,6 +125,17 @@ # do not, so their row reports the document's own url instead. NAVIGATION_COMMANDS = frozenset({"get", "refresh", "goBack", "goForward"}) +# Commands carrying a `{using, value}` locator — the only point at which the +# selector behind an element handle is visible. The child forms also carry the +# parent handle's id, which scopes the selector they produce. +FIND_CHILD_COMMANDS = frozenset({"findChildElement", "findChildElements"}) +FIND_COMMANDS = frozenset({"findElement", "findElements"}) | FIND_CHILD_COMMANDS + +# Element handles whose locator is remembered, before the oldest is evicted. A +# handle costs two short strings, and a page interacted with more than a few +# hundred elements deep has long since stopped resembling a readable trace. +ELEMENT_LOCATOR_CACHE_SIZE = 200 + # Stack-frame path fragment to skip when resolving a command's call source — # the adapter's own package. The selenium library dir is added at runtime by # instrumentation (resolved from selenium.__file__), NOT matched by the diff --git a/packages/selenium-devtools-py/src/selenium_devtools/element_locators.py b/packages/selenium-devtools-py/src/selenium_devtools/element_locators.py new file mode 100644 index 00000000..fcbc8431 --- /dev/null +++ b/packages/selenium-devtools-py/src/selenium_devtools/element_locators.py @@ -0,0 +1,131 @@ +"""Recovers the locator an element command acted through. + +Selenium consumes the locator at ``findElement`` time and hands back an opaque +handle, so ``clickElement``/``sendKeysToElement`` see only ``{"id": "f.93A…"}``. +Without this, the row reaches the trace with no ``selector`` — and the player's +element overlay, which resolves each row's locator in the replayed document, +has an element id where a selector should be and draws nothing. + +The JS Selenium adapter solves the same problem in `helpers/element-locators.ts`, +and this follows its mapping table — including leaving link text unmapped. It +diverges in three places, each forced by the binding: it keys on the element ID +rather than on handle identity (Python's ``WebElement.id`` is a plain string the +moment the find returns, while the JS ``WeakMap`` exists only because ``id_`` is +a promise there); it needs no shorthand-hash forms, which are a JS-only API; and +its ``[id="x"]`` pattern makes the ``*`` optional, because selenium-python's own +LocatorConverter emits the form without it. + +Bounded rather than a plain dict: a run that finds elements in a loop would +otherwise pin every handle it ever saw for the length of the run. +""" + +from __future__ import annotations + +import re +from collections import OrderedDict +from typing import Any, Optional + +from .constants import ( + ELEMENT_LOCATOR_CACHE_SIZE, + FIND_CHILD_COMMANDS, + FIND_COMMANDS, +) + +#: `By.ID` reaches the wire as this CSS form (selenium's own LocatorConverter), +#: while the captured element records carry `#x` — and the point-matching +#: compares the two by string, so the shorter form is the one to store. +_BY_ID_CSS_RE = re.compile(r'^\*?\[id="([\w-]+)"\]$') + +#: Element id → the selector that produced it. Insertion-ordered so the oldest +#: entry is the one evicted. +_selectors: "OrderedDict[str, str]" = OrderedDict() + + +def reset() -> None: + """Drop every remembered locator. Called on teardown, so a re-enable in the + same process cannot serve a selector from the previous run's handles.""" + _selectors.clear() + + +def _canonicalize_css(value: str) -> str: + match = _BY_ID_CSS_RE.match(value) + return f"#{match.group(1)}" if match else value + + +def locator_to_selector(using: Any, value: Any) -> Optional[str]: + """A W3C ``{using, value}`` pair as the selector string the rest of the + system speaks, or None for strategies with no selector equivalent. + + Link text is unmapped, exactly as in the JS adapter: its XPath equivalent + needs the quote-stitching `shared` already implements for captured text + locators, and a second implementation here is the copy-per-language that + the element scripts are fetched from the backend to avoid. + """ + if not isinstance(using, str) or not isinstance(value, str) or not value: + return None + if using == "css selector": + return _canonicalize_css(value) + if using in ("xpath", "tag name"): + return value + return None + + +def _compose(parent: Optional[str], child: str) -> str: + """A child find scoped to its parent, when both are plain CSS. + + ``element.find_element(By.CLASS_NAME, "row")`` yields ``.row``, which the + overlay would resolve against the whole document and box the page's first + match — a wrong box being worse than no box. XPath is left alone: an + expression is not concatenable this way. + """ + if not parent or parent.startswith("/") or child.startswith("/"): + return child + return f"{parent} {child}" + + +def _remember(result: Any, selector: str) -> None: + """Attribute a selector to the handle(s) a find returned. + + ``findElements`` yields a list; every element gets the plural locator, whose + first match is what a box would be drawn on. + """ + for element in result if isinstance(result, list) else [result]: + element_id = getattr(element, "id", None) + if not isinstance(element_id, str) or not element_id: + continue + _selectors[element_id] = selector + _selectors.move_to_end(element_id) + while len(_selectors) > ELEMENT_LOCATOR_CACHE_SIZE: + _selectors.popitem(last=False) + + +def selector_for_command( + command: str, params: Any, result: Any = None +) -> Optional[str]: + """The selector to stamp on this command's row. + + A find learns one and remembers it against the handle it produced; every + later command on that handle reads it back. Returns None when the locator + was never seen or has no selector form — the row then behaves as it did + before, carrying no selector at all. + """ + if not isinstance(params, dict): + return None + if command in FIND_COMMANDS: + selector = locator_to_selector(params.get("using"), params.get("value")) + if selector is None: + return None + if command in FIND_CHILD_COMMANDS: + parent = params.get("id") + selector = _compose( + _selectors.get(parent) if isinstance(parent, str) else None, + selector, + ) + _remember(result, selector) + return selector + element_id = params.get("id") + if not isinstance(element_id, str): + # `switchToFrame` also takes `id`, as an index or a serialized element — + # neither is a handle this registry ever stored. + return None + return _selectors.get(element_id) diff --git a/packages/selenium-devtools-py/src/selenium_devtools/element_scripts.py b/packages/selenium-devtools-py/src/selenium_devtools/element_scripts.py new file mode 100644 index 00000000..6be520db --- /dev/null +++ b/packages/selenium-devtools-py/src/selenium_devtools/element_scripts.py @@ -0,0 +1,89 @@ +"""The page-side element scripts, fetched from the backend. + +These are what fills the trace's A11y tab: one script reads the accessibility +tree, the other the interactable elements and their rects. Both are browser +source, and both live in `@wdio/devtools-shared` — TypeScript this adapter +cannot import. So the backend serves them, exactly as it already serves the +page collector, and for the same reason: a copy per language is a copy that +drifts, and #284/#285/#286 are three fixes that reached the JS adapters and +never reached this one. + +The runner matters. The scripts bake in a locator dialect — a WDIO run wants +`a*=Logout`, a protocol-level one wants XPath — so the request names the runner +and the backend generates accordingly. Getting that wrong yields locators that +look right and select nothing. + +Fetched once per run and cached, because they are re-run after every command; +a failure is cached too, so a backend without the route (an older one) costs one +request rather than one per action. Nothing here may raise into the user's test: +a missing A11y tab is worth less than a broken run. +""" + +from __future__ import annotations + +import json +import logging +import urllib.error +import urllib.parse +import urllib.request +from typing import Optional + +from ._contract import ELEMENT_SCRIPTS_PATH, RUNNER_ID +from .constants import ELEMENT_SCRIPTS_FETCH_TIMEOUT_S, LOGGER_NAME +from .types import ElementScripts + +_log = logging.getLogger(f"{LOGGER_NAME}.elements") + +#: Keyed by the backend it came from: a rerun attaches to a different one, and +#: serving a script generated by another version would be worse than none. +_cache: dict = {"origin": None, "scripts": None, "settled": False} + + +def reset_cache() -> None: + """Drop the cached scripts. Called on teardown so a re-enable re-fetches.""" + _cache.update(origin=None, scripts=None, settled=False) + + +def scripts_url(host: str, port: int) -> str: + """The endpoint on a backend, with the runner this adapter reports as. + + A bare IPv6 literal has to be bracketed or urllib reads the last colon as + the port separator — the same handling `collector_url` needs. + """ + url_host = f"[{host}]" if ":" in host and not host.startswith("[") else host + query = urllib.parse.urlencode({"runner": RUNNER_ID}) + return f"http://{url_host}:{port}{ELEMENT_SCRIPTS_PATH}?{query}" + + +def fetch(host: str, port: int) -> Optional[ElementScripts]: + """``{"accessibilityTree": str, "elements": str}`` from the backend, or None. + + None on anything at all — no route (a backend older than this feature), a + refused connection, a body that is not the shape promised. The caller then + captures no element tree, which costs the A11y tab and nothing else. + """ + origin = (host, port) + if _cache["settled"] and _cache["origin"] == origin: + return _cache["scripts"] + + scripts: Optional[ElementScripts] = None + try: + with urllib.request.urlopen( # noqa: S310 — a loopback backend we launched + scripts_url(host, port), timeout=ELEMENT_SCRIPTS_FETCH_TIMEOUT_S + ) as response: + body = json.loads(response.read().decode("utf-8")) + if isinstance(body, dict) and all( + isinstance(body.get(k), str) and body.get(k) + for k in ("accessibilityTree", "elements") + ): + scripts = { + "accessibilityTree": body["accessibilityTree"], + "elements": body["elements"], + } + else: + _log.debug("element scripts: unexpected response shape") + except (urllib.error.URLError, OSError, ValueError) as exc: + _log.debug("element scripts unavailable (%s); no A11y tree this run", exc) + + _cache.update(origin=origin, scripts=scripts, settled=True) + return scripts diff --git a/packages/selenium-devtools-py/src/selenium_devtools/frames.py b/packages/selenium-devtools-py/src/selenium_devtools/frames.py index e0bfc561..99f1ce00 100644 --- a/packages/selenium-devtools-py/src/selenium_devtools/frames.py +++ b/packages/selenium-devtools-py/src/selenium_devtools/frames.py @@ -20,6 +20,7 @@ ScreencastInfo, SuiteStats, TestStats, + Viewport, ) from .utils import iso @@ -29,9 +30,10 @@ def metadata( capabilities: Optional[dict] = None, url: Optional[str] = None, run_options: Optional[dict] = None, + viewport: Optional[Viewport] = None, ) -> Metadata: caps = capabilities or {} - return { + entry: Metadata = { "type": "testrunner", # TraceType.Testrunner "sessionId": session_id, "url": url, @@ -48,6 +50,11 @@ def metadata( if run_options else {"runCapabilities": dict(RUN_CAPABILITIES_NONE)}, } + # Omitted rather than sent empty: the trace reader's own 1280x720 default is + # a better frame than a zero-sized one, and the app treats absent as unknown. + if viewport: + entry["viewport"] = dict(viewport) + return entry def command_log( @@ -61,6 +68,7 @@ def command_log( call_source: Optional[str], command_id: int, screenshot: Optional[str] = None, + selector: Optional[str] = None, ) -> CommandLog: entry: CommandLog = { "command": command, @@ -71,6 +79,8 @@ def command_log( "callSource": call_source, "id": command_id, } + if selector: + entry["selector"] = selector if error is not None: entry["error"] = { "name": type(error).__name__, diff --git a/packages/selenium-devtools-py/src/selenium_devtools/instrumentation.py b/packages/selenium-devtools-py/src/selenium_devtools/instrumentation.py index a3d13ec0..54d04e3b 100644 --- a/packages/selenium-devtools-py/src/selenium_devtools/instrumentation.py +++ b/packages/selenium-devtools-py/src/selenium_devtools/instrumentation.py @@ -17,13 +17,14 @@ import sys import threading import weakref -from typing import Any, Optional +from typing import Any, List, Optional -from . import assertions, bidi, bidi_preload, frames, performance +from . import assertions, bidi, bidi_preload, element_locators, frames, performance from .assert_tracer import ScriptAssertionTracer from .capturer import SessionCapturer from .cdp_screencast import start_push_screencast from .collector_source import reset_cache as reset_collector_cache +from .element_scripts import reset_cache as reset_element_scripts_cache from .constants import ( BIDI_CAPABILITY, DEFAULT_TEST_TITLE, @@ -41,6 +42,7 @@ start_snapshot_capture, ) from .sources import read_source +from .types import ActionSnapshot, ElementScripts, Viewport from .utils import call_source, now_ms # Operational logging — surfaced in the dashboard Console (the 'runner' stream). @@ -129,7 +131,7 @@ def report(*, passed, source, operands, location, error) -> None: # noqa: ANN00 now = now_ms() try: capturer.capture_command( - command=assertions.ASSERT_COMMAND, + command=assertions.assert_command(op), args=[source] if source else [], result=assertions.collapsed_result( passed=passed, op=op, left=left, right=right, @@ -248,6 +250,12 @@ def _capture_source(capturer: SessionCapturer, call_src: Optional[str]) -> None: "run_failed": False, # Trace mode. Set by install(). "trace": False, + # Capture the page's element tree per action, for the trace's A11y tab. + "a11y": False, + # The page-side scripts, fetched from the backend once per run. + "element_scripts": None, + # One per action that returned elements; sent before the export. + "action_snapshots": [], # Record a dense filmstrip into the trace. Only meaningful in trace mode; # the recorder runs in live mode regardless, for the dashboard video. "filmstrip": False, @@ -330,6 +338,18 @@ def _attach_performance( _log.debug("could not replace the navigation row: %s", exc) +def action_snapshots() -> List[ActionSnapshot]: + """Per-action element trees captured this run, oldest first.""" + return list(_state["action_snapshots"]) + + +def set_element_scripts(scripts: Optional[ElementScripts]) -> None: + """Hand over the page-side scripts fetched from the backend. Without them + `_capture_action_snapshot` is a no-op, which is the state on a backend too + old to serve them.""" + _state["element_scripts"] = scripts + + def screencast_frames() -> list: """Every frame this run's recorders buffered, in time order. @@ -370,6 +390,48 @@ def resolved_output_dir() -> Optional[str]: return _state.get("output_dir") +def _capture_action_snapshot( + driver: Any, command: str, timestamp: int, shot: Optional[str] +) -> None: + """Read the page's element tree beside one action, for the trace's A11y tab. + + Trace mode only, and only with the scripts in hand — the JS adapters run + them in-process, this adapter fetches them from the backend. Two extra + round trips per command is the cost; live mode pays neither. + + Goes through the guarded executor, or each read lands back in this same hook + and the timeline grows an `executeScript` row beside every action — the bug + the CDP window-handle read caused, in a path that runs far more often. + """ + if not _state["trace"] or not _state["a11y"]: + return + scripts = _state.get("element_scripts") + if not scripts: + return + run = _guarded_execute_script(driver) + snapshot: ActionSnapshot = {"timestamp": timestamp, "command": command} + if shot: + snapshot["screenshot"] = shot + # Two reads, two panes: `elements` carries the interactable boxes, the + # accessibility tree becomes the A11y tab's text. Capturing only the first + # left that tab reporting "no accessibility snapshot for this command" with + # 39 element files sitting in the same archive. + for key, script in ( + ("elements", scripts["elements"]), + ("accessibilityTree", scripts["accessibilityTree"]), + ): + try: + value = run(f"return {script}") + except Exception as exc: # noqa: BLE001 — a missing pane, not a failed run + _log.debug("%s read failed: %s", key, exc) + continue + if isinstance(value, list) and value: + snapshot[key] = value + if "elements" not in snapshot and "accessibilityTree" not in snapshot: + return + _state["action_snapshots"].append(snapshot) + + def _begin_screencast_run(entry: Optional[dict], shot: Optional[str] = None) -> None: """Let a pushed stream start keeping frames. Idempotent, never raises. @@ -516,6 +578,33 @@ def _finalize_screencast( _log.info("screencast saved: %s", info.get("video_path")) +def _viewport(driver: Any) -> Optional[Viewport]: + """The page's own viewport, for the player's frame geometry. + + Without it the reader falls back to a hard-coded 1280x720 and the replay is + framed at proportions the run never had. `window.innerWidth/Height` rather + than `get_window_size`, which reports the OS window including its chrome — + the service reads `window.visualViewport` for the same reason. + + Guarded, or the read lands back in the command hook as an `executeScript` + row at the head of every run. + """ + run = _guarded_execute_script(driver) + try: + size = run("return [window.innerWidth, window.innerHeight]") + except Exception as exc: # noqa: BLE001 — a default frame, not a failed run + _log.debug("viewport read failed: %s", exc) + return None + if not isinstance(size, list) or len(size) != 2: + return None + width, height = size + if not isinstance(width, int) or not isinstance(height, int): + return None + if width <= 0 or height <= 0: + return None + return {"width": width, "height": height} + + def _ensure_session_setup(driver: Any, capturer: SessionCapturer) -> Optional[dict]: """Bring capture up for this driver, once, and return its state. @@ -553,7 +642,9 @@ def _ensure_session_setup(driver: Any, capturer: SessionCapturer) -> Optional[di except TypeError: # not weak-referenceable _log.warning("driver cannot be tracked; capture disabled for it") return None - capturer.ensure_metadata(session_id, getattr(driver, "caps", None), None) + capturer.ensure_metadata( + session_id, getattr(driver, "caps", None), None, viewport=_viewport(driver) + ) _log.info("session %s started", session_id) _send_default_suite(capturer, "running") # tree entry for plain-script runs try: @@ -685,7 +776,7 @@ def _capture_unwinding_assertion(capturer: SessionCapturer) -> None: now = now_ms() try: capturer.capture_command( - command=assertions.ASSERT_COMMAND, + command=assertions.assert_command(op), args=[condition or raw] if (condition or raw) else [], result=assertions.collapsed_result( passed=False, @@ -778,6 +869,7 @@ def install( *, trace: bool = False, filmstrip: bool = False, + a11y: bool = False, ) -> None: if _state["installed"]: return @@ -818,6 +910,9 @@ def patched_execute(self, driver_command: str, params: Any = None): # noqa: ANN error=exc, start_time=start, call_source=src, + selector=element_locators.selector_for_command( + driver_command, params + ), ) raise @@ -835,7 +930,11 @@ def patched_execute(self, driver_command: str, params: Any = None): # noqa: ANN start_time=start, call_source=src, screenshot=shot, + selector=element_locators.selector_for_command( + driver_command, params, value + ), ) + _capture_action_snapshot(self, driver_command, row.get("timestamp", start), shot) if driver_command in NAVIGATION_COMMANDS: _attach_performance(capturer, self, row, params) # No per-command line here: the Actions timeline lists every command as @@ -859,6 +958,7 @@ def patched_execute(self, driver_command: str, params: Any = None): # noqa: ANN installed=True, cls=webdriver_cls, orig=orig_execute, sessions=weakref.WeakKeyDictionary(), output_dir=None, default_suite=None, trace=trace, filmstrip=filmstrip, filmstrip_frames=[], + a11y=a11y, element_scripts=None, action_snapshots=[], ) @@ -870,6 +970,12 @@ def uninstall() -> None: # may point at a different one, so the cache goes with the rest of the # per-run state. reset_collector_cache() + # Same reasoning: a re-enable may attach to a different backend, and a + # script generated by another version is worse than none. + reset_element_scripts_cache() + # Element handles do not survive the session that issued them, so a stale + # entry could only ever attribute the wrong selector to a new run's row. + element_locators.reset() # Never leave a recorder running past teardown, for any session still live. # Keep the buffer first: `disable()` uninstalls BEFORE its fallback export, # and `sessions` is replaced below, so a session that never quit would diff --git a/packages/selenium-devtools-py/src/selenium_devtools/lifecycle.py b/packages/selenium-devtools-py/src/selenium_devtools/lifecycle.py index d328a193..df027c82 100644 --- a/packages/selenium-devtools-py/src/selenium_devtools/lifecycle.py +++ b/packages/selenium-devtools-py/src/selenium_devtools/lifecycle.py @@ -251,6 +251,29 @@ def _close_handle() -> None: handle.close() +def _on_process_exit() -> None: + """Tear capture down when the process simply ends. + + Every other route to teardown runs disable(): Ctrl-C and SIGTERM through + `_on_signal`, the dashboard window closing through `on_control`. A script + that just reaches the end of its `finally` had only ever closed the window + here — invisible in live mode, where capture streams as it happens and + teardown adds nothing, but the trace archive is written AT teardown, so a + plain script in trace mode produced no file at all. + + No `_has_waiter` check, unlike `_trigger_shutdown`: a waiter that would own + teardown has necessarily returned by the time atexit runs. + """ + global _shutting_down + with _shutdown_lock: + if _shutting_down: + return # a signal or the dashboard already tore this run down + _shutting_down = True + _shutdown_event.set() + _run_disable() + _close_handle() + + def on_control(scope: str, data: dict) -> None: """WS control-frame handler: shut down when the dashboard client leaves. @@ -334,7 +357,7 @@ def register_exit_handlers( return _handlers_registered = True - atexit.register(_close_handle) + atexit.register(_on_process_exit) if threading.current_thread() is threading.main_thread(): try: @@ -352,7 +375,7 @@ def unregister_exit_handlers() -> None: _close_handle() if _handlers_registered: try: - atexit.unregister(_close_handle) + atexit.unregister(_on_process_exit) except Exception: pass if threading.current_thread() is threading.main_thread(): diff --git a/packages/selenium-devtools-py/src/selenium_devtools/pytest_plugin.py b/packages/selenium-devtools-py/src/selenium_devtools/pytest_plugin.py index 3327b774..2a446f67 100644 --- a/packages/selenium-devtools-py/src/selenium_devtools/pytest_plugin.py +++ b/packages/selenium-devtools-py/src/selenium_devtools/pytest_plugin.py @@ -509,7 +509,7 @@ def _emit_assertion( op, left, right = comparison if comparison else (None, None, None) now = now_ms() capturer.capture_command( - command=assertions.ASSERT_COMMAND, + command=assertions.assert_command(op), args=[source] if source else [], result=assertions.collapsed_result( passed=passed, op=op, left=left, right=right diff --git a/packages/selenium-devtools-py/src/selenium_devtools/trace_export.py b/packages/selenium-devtools-py/src/selenium_devtools/trace_export.py index a6fbb335..7b5ff744 100644 --- a/packages/selenium-devtools-py/src/selenium_devtools/trace_export.py +++ b/packages/selenium-devtools-py/src/selenium_devtools/trace_export.py @@ -28,8 +28,13 @@ from dataclasses import dataclass from typing import Any, Optional -from ._contract import SCOPE_SCREENCAST_FRAMES, SCOPE_TRACE_EXPORT +from ._contract import ( + SCOPE_ACTION_SNAPSHOTS, + SCOPE_SCREENCAST_FRAMES, + SCOPE_TRACE_EXPORT, +) from .constants import ( + ACTION_SNAPSHOT_BATCH, LOGGER_NAME, SCREENCAST_FRAME_BATCH, TRACE_EXPORT_TIMEOUT_S, @@ -112,6 +117,30 @@ def send_frames(transport: Any, frames: list) -> int: return sent +def send_action_snapshots(transport: Any, snapshots: list) -> int: + """Stream the per-action element trees ahead of the export request. + + Batched like the filmstrip and for the same reason: each snapshot carries a + screenshot and an element tree, so a run's worth in one message is large + enough to matter on a socket that masks its payload a byte at a time. + """ + if not snapshots or transport is None: + return 0 + sent = 0 + for start in range(0, len(snapshots), ACTION_SNAPSHOT_BATCH): + batch = snapshots[start : start + ACTION_SNAPSHOT_BATCH] + try: + if not transport.send_json(SCOPE_ACTION_SNAPSHOTS, batch): + break + except Exception as exc: # noqa: BLE001 — never break the test + _log.debug("action snapshot batch dropped: %s", exc) + break + sent += len(batch) + if sent: + _log.debug("streamed %d action snapshot(s) for the trace", sent) + return sent + + def export( transport: Any, *, diff --git a/packages/selenium-devtools-py/src/selenium_devtools/types.py b/packages/selenium-devtools-py/src/selenium_devtools/types.py index 49b0e1cf..0d18ebcc 100644 --- a/packages/selenium-devtools-py/src/selenium_devtools/types.py +++ b/packages/selenium-devtools-py/src/selenium_devtools/types.py @@ -33,6 +33,30 @@ class CommandLog(TypedDict, total=False): callSource: Optional[str] id: int screenshot: str + selector: str + + +class Viewport(TypedDict): + width: int + height: int + + +class ElementScripts(TypedDict): + """Body of the backend's element-scripts route: two injectable expressions.""" + + accessibilityTree: str + elements: str + + +class ActionSnapshot(TypedDict, total=False): + """One action's view of the page. `accessibilityTree` ships raw because the + serializer is TypeScript — the backend turns it into the A11y tab's text.""" + + timestamp: int + command: str + screenshot: str + elements: List[Any] + accessibilityTree: List[Any] class ConsoleLog(TypedDict): @@ -83,6 +107,7 @@ class Metadata(TypedDict, total=False): testEnv: str runner: str options: Dict[str, Any] + viewport: Viewport class TestStats(TypedDict, total=False): diff --git a/packages/selenium-devtools-py/tests/test_a11y_elements.py b/packages/selenium-devtools-py/tests/test_a11y_elements.py new file mode 100644 index 00000000..713753e5 --- /dev/null +++ b/packages/selenium-devtools-py/tests/test_a11y_elements.py @@ -0,0 +1,232 @@ +"""Per-action element capture — what fills the trace's A11y tab. + +The JS adapters run these scripts in-process from `core`. This adapter cannot +import that, so the backend serves them and the capture runs per action here. +A Python trace carried 0 `*-elements.json` where a JS Selenium trace of the +same flow carried 12; this is the gap. +""" + +import json +import unittest +from unittest import mock + +from selenium_devtools import element_scripts, instrumentation, trace_export +from selenium_devtools._contract import ( + ELEMENT_SCRIPTS_PATH, + RUNNER_ID, + SCOPE_ACTION_SNAPSHOTS, +) +from selenium_devtools.constants import ACTION_SNAPSHOT_BATCH + +SCRIPTS = {"accessibilityTree": "(function(){})", "elements": "(function(){})"} + + +class FakeTransport: + def __init__(self, *, sends=True): + self.connected = True + self.sent = [] + self._sends = sends + + def send_json(self, scope, data): + self.sent.append((scope, data)) + return self._sends + + def close(self): + self.connected = False + + +class TestFetchingTheScripts(unittest.TestCase): + def setUp(self): + element_scripts.reset_cache() + + def tearDown(self): + element_scripts.reset_cache() + + def _urlopen(self, body, *, raises=None): + class Response: + def __enter__(self_inner): + return self_inner + + def __exit__(self_inner, *a): + return False + + def read(self_inner): + return body.encode() + + if raises is not None: + return mock.patch("urllib.request.urlopen", side_effect=raises) + return mock.patch("urllib.request.urlopen", return_value=Response()) + + def test_the_url_names_this_adapters_runner(self): + # The scripts bake in a locator dialect; asking without the runner + # yields locators that look right and select nothing. + url = element_scripts.scripts_url("localhost", 1234) + self.assertIn(ELEMENT_SCRIPTS_PATH, url) + self.assertIn(f"runner={RUNNER_ID}", url) + + def test_a_bare_ipv6_host_is_bracketed(self): + # Otherwise urllib reads the last colon as the port separator. + self.assertIn("[::1]:1234", element_scripts.scripts_url("::1", 1234)) + + def test_a_well_formed_response_is_returned_and_cached(self): + with self._urlopen(json.dumps(SCRIPTS)) as opened: + first = element_scripts.fetch("localhost", 1) + second = element_scripts.fetch("localhost", 1) + self.assertEqual(first, SCRIPTS) + self.assertEqual(second, SCRIPTS) + self.assertEqual(opened.call_count, 1, "re-fetched a cached script") + + # A backend too old to serve them should cost one request, not one per + # action — so a failure is cached too. + def test_a_failure_is_cached_rather_than_retried_per_action(self): + with self._urlopen("", raises=OSError("no route")) as opened: + self.assertIsNone(element_scripts.fetch("localhost", 1)) + self.assertIsNone(element_scripts.fetch("localhost", 1)) + self.assertEqual(opened.call_count, 1) + + def test_a_response_of_the_wrong_shape_is_refused(self): + for body in ['{"elements": "x"}', '{"elements": 1, "accessibilityTree": 2}', + '"not-an-object"', "{}"]: + with self.subTest(body=body): + element_scripts.reset_cache() + with self._urlopen(body): + self.assertIsNone(element_scripts.fetch("localhost", 1)) + + def test_a_different_backend_re_fetches(self): + # A rerun attaches to another one, and a script from a different + # version is worse than none. + with self._urlopen(json.dumps(SCRIPTS)) as opened: + element_scripts.fetch("localhost", 1) + element_scripts.fetch("localhost", 2) + self.assertEqual(opened.call_count, 2) + + +class TestCapturingPerAction(unittest.TestCase): + class Driver: + def __init__(self, elements=None, raises=False): + self.session_id = "s" + self._elements = elements + self._raises = raises + self.scripts_run = [] + + def execute_script(self, script, *args): + self.scripts_run.append(script) + if self._raises: + raise RuntimeError("no such session") + return self._elements + + def _capture(self, driver, *, trace=True, a11y=True, scripts=SCRIPTS): + with mock.patch.dict( + instrumentation._state, + { + "trace": trace, + "a11y": a11y, + "element_scripts": scripts, + "action_snapshots": [], + }, + ): + instrumentation._capture_action_snapshot( + driver, "clickElement", 1200, "c2hvdA==" + ) + return instrumentation.action_snapshots() + + # Two reads, two panes. Capturing only `elements` left the A11y tab + # reporting "no accessibility snapshot for this command" while 39 element + # files sat in the same archive — the tab reads the serialized TREE. + def test_both_the_elements_and_the_accessibility_tree_are_read(self): + driver = self.Driver(elements=[{"selector": "#go"}]) + snaps = self._capture(driver) + self.assertEqual(len(driver.scripts_run), 2, "read only one of the two") + self.assertIn("elements", snaps[0]) + self.assertIn("accessibilityTree", snaps[0]) + + def test_one_read_failing_does_not_lose_the_other(self): + class Half: + session_id = "s" + + def __init__(self): + self.calls = 0 + + def execute_script(self, script, *args): + self.calls += 1 + if self.calls == 1: + raise RuntimeError("element read blew up") + return [{"role": "button"}] + + snaps = self._capture(Half()) + self.assertEqual(len(snaps), 1) + self.assertNotIn("elements", snaps[0]) + self.assertIn("accessibilityTree", snaps[0]) + + def test_an_element_tree_is_captured_beside_the_action(self): + driver = self.Driver(elements=[{"selector": "#go", "role": "button"}]) + snaps = self._capture(driver) + self.assertEqual(len(snaps), 1) + self.assertEqual(snaps[0]["command"], "clickElement") + self.assertEqual(snaps[0]["timestamp"], 1200) + self.assertEqual(snaps[0]["screenshot"], "c2hvdA==") + self.assertEqual(snaps[0]["elements"][0]["selector"], "#go") + + # The reads must not land back in the capture hook and grow the timeline an + # `executeScript` row per action — the bug the CDP window-handle read caused. + def test_the_reads_run_through_the_guarded_executor(self): + driver = self.Driver(elements=[{"selector": "#go"}]) + seen = [] + original = instrumentation._guarded_execute_script + + def spy(d): + seen.append(True) + return original(d) + + with mock.patch.object(instrumentation, "_guarded_execute_script", spy): + self._capture(driver) + self.assertTrue(seen, "read the page without the capture guard") + + def test_nothing_is_captured_outside_trace_mode(self): + driver = self.Driver(elements=[{"selector": "#go"}]) + self.assertEqual(self._capture(driver, trace=False), []) + self.assertEqual(driver.scripts_run, [], "paid for a read in live mode") + + def test_nothing_is_captured_with_a11y_off(self): + driver = self.Driver(elements=[{"selector": "#go"}]) + self.assertEqual(self._capture(driver, a11y=False), []) + self.assertEqual(driver.scripts_run, []) + + def test_without_the_scripts_it_is_a_no_op(self): + # A backend too old to serve them. + driver = self.Driver(elements=[{"selector": "#go"}]) + self.assertEqual(self._capture(driver, scripts=None), []) + self.assertEqual(driver.scripts_run, []) + + # An empty tree carries nothing the A11y tab can show, and a snapshot with + # no elements makes the exporter write no *-elements.json anyway. + def test_an_empty_or_failed_read_records_no_snapshot(self): + self.assertEqual(self._capture(self.Driver(elements=[])), []) + self.assertEqual(self._capture(self.Driver(elements=None)), []) + self.assertEqual(self._capture(self.Driver(raises=True)), []) + + +class TestStreamingTheSnapshots(unittest.TestCase): + def test_they_go_out_under_the_action_snapshots_scope(self): + tx = FakeTransport() + snaps = [{"timestamp": i, "command": "click"} for i in range(3)] + + self.assertEqual(trace_export.send_action_snapshots(tx, snaps), 3) + self.assertEqual(tx.sent[0][0], SCOPE_ACTION_SNAPSHOTS) + + def test_a_long_run_is_batched(self): + tx = FakeTransport() + total = ACTION_SNAPSHOT_BATCH * 2 + 3 + snaps = [{"timestamp": i, "command": "click"} for i in range(total)] + + self.assertEqual(trace_export.send_action_snapshots(tx, snaps), total) + self.assertEqual(len(tx.sent), 3) + + def test_a_refused_socket_stops_without_raising(self): + tx = FakeTransport(sends=False) + self.assertEqual(trace_export.send_action_snapshots(tx, [{"a": 1}]), 0) + self.assertEqual(trace_export.send_action_snapshots(None, [{"a": 1}]), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/selenium-devtools-py/tests/test_assert_tracer.py b/packages/selenium-devtools-py/tests/test_assert_tracer.py index 15788849..37e5bfde 100644 --- a/packages/selenium-devtools-py/tests/test_assert_tracer.py +++ b/packages/selenium-devtools-py/tests/test_assert_tracer.py @@ -216,7 +216,11 @@ def run(): # Once, by the assert itself — the tracer added no read of its own. self.assertEqual(len(reads), 1) [call] = [c for c in self.recorder.calls if c["source"]] - self.assertIsNone(call["operands"]) + # The literal side is reported; the property's value is not, because it + # was never read. + op, left, right = call["operands"] + self.assertEqual((op, left), ("in", "/secure")) + self.assertNotIsInstance(right, str) def test_lines_that_are_not_asserts_report_nothing(self): def run(): diff --git a/packages/selenium-devtools-py/tests/test_assertions.py b/packages/selenium-devtools-py/tests/test_assertions.py index 0d9ac8ab..a5f5a102 100644 --- a/packages/selenium-devtools-py/tests/test_assertions.py +++ b/packages/selenium-devtools-py/tests/test_assertions.py @@ -167,8 +167,14 @@ def current_url(self): ) self.assertEqual(source, "'/secure' in driver.current_url") - self.assertIsNone(operands) # no values rather than a re-run - self.assertEqual(reads, []) # and provably nothing was read + self.assertEqual(reads, []) # provably nothing was read + + # The literal still reports — `in` puts the expectation on the left — + # while the side that would have re-run reaches the wire as nothing. + op, left, right = operands + result = assertions.collapsed_result(passed=True, op=op, left=left, right=right) + self.assertEqual(result["expected"], "/secure") + self.assertNotIn("actual", result) def test_a_call_operand_is_never_evaluated(self): calls = [] @@ -181,9 +187,16 @@ def value(): "assert value() == 'x'", sys._getframe() ) - self.assertIsNone(operands) + # The call is never made; the literal side still reports. self.assertEqual(calls, []) self.assertIsNotNone(source) + op, left, right = operands + self.assertEqual((op, right), ("==", "x")) + result = assertions.collapsed_result(passed=False, op=op, left=left, right=right) + # `==` puts the expectation on the right; the call's value would have + # been the actual, and it is absent rather than invented. + self.assertEqual(result["expected"], "x") + self.assertNotIn("actual", result) def test_a_non_comparison_yields_its_source_only(self): source, operands = assertions.parse_assert_statement("assert items", None) @@ -199,9 +212,18 @@ def test_a_chained_comparison_is_left_alone(self): self.assertEqual(source, "1 < x < 9") self.assertIsNone(operands) - def test_an_unknown_name_resolves_to_nothing(self): + def test_an_unknown_name_reports_only_the_side_it_knows(self): _, operands = assertions.parse_assert_statement("assert missing == 1", None) + op, left, right = operands + result = assertions.collapsed_result(passed=False, op=op, left=left, right=right) + # `==` puts the expectation on the right, which is the readable side. + self.assertEqual(result["expected"], 1) + self.assertNotIn("actual", result) + + def test_neither_side_readable_yields_the_source_only(self): + _, operands = assertions.parse_assert_statement("assert missing == absent", None) + self.assertIsNone(operands) def test_an_unparseable_line_still_labels_the_row(self): @@ -279,3 +301,32 @@ def test_a_non_string_actual_is_compared_by_its_text(self): if __name__ == "__main__": unittest.main() + + +class AssertCommandNameTest(unittest.TestCase): + """The name has to resolve in shared's ACTION_MAP, which matches + `^(?:assert|verify|expect)\\.(\\w+)$`. A bare "assert" is silently dropped by + the trace exporter, so the row showed in live mode and in no trace.""" + + def test_every_name_is_dotted(self): + for op in ("==", "!=", "is", "is not", "<", ">=", "in", "not in", None, ""): + with self.subTest(op=op): + name = assertions.assert_command(op) + self.assertRegex(name, r"^assert\.\w+$") + + def test_comparisons_are_named_as_node_assert_methods(self): + self.assertEqual(assertions.assert_command("=="), "assert.equal") + self.assertEqual(assertions.assert_command("!="), "assert.notEqual") + self.assertEqual(assertions.assert_command("is"), "assert.strictEqual") + self.assertEqual(assertions.assert_command("is not"), "assert.notStrictEqual") + + def test_anything_without_an_equivalent_is_a_truthiness_check(self): + # node:assert calls a bare truthiness check `ok`; the orderings and + # containment have no direct method, and the source text carries the + # meaning on the row's args either way. + for op in ("<", "<=", ">", ">=", "in", "not in", None): + with self.subTest(op=op): + self.assertEqual(assertions.assert_command(op), "assert.ok") + + def test_the_default_constant_is_dotted_too(self): + self.assertRegex(assertions.ASSERT_COMMAND, r"^assert\.\w+$") diff --git a/packages/selenium-devtools-py/tests/test_element_locators.py b/packages/selenium-devtools-py/tests/test_element_locators.py new file mode 100644 index 00000000..ca5a8208 --- /dev/null +++ b/packages/selenium-devtools-py/tests/test_element_locators.py @@ -0,0 +1,180 @@ +"""The element-handle → selector registry that feeds the player's overlay.""" + +from __future__ import annotations + +import unittest + +from selenium_devtools import element_locators +from selenium_devtools.constants import ELEMENT_LOCATOR_CACHE_SIZE + + +class _Handle: + """A WebElement as far as this module is concerned: something with an id.""" + + def __init__(self, element_id: str) -> None: + self.id = element_id + + +class LocatorToSelectorTest(unittest.TestCase): + def test_it_maps_the_strategies_that_have_a_selector_form(self): + for using, value, expected in ( + ("css selector", "#username", "#username"), + ("css selector", ".row > a", ".row > a"), + ("xpath", '//button[contains(., "Login")]', '//button[contains(., "Login")]'), + ("tag name", "button", "button"), + ): + with self.subTest(using=using, value=value): + self.assertEqual(element_locators.locator_to_selector(using, value), expected) + + def test_it_canonicalizes_the_css_form_By_ID_compiles_to(self): + # selenium's LocatorConverter turns By.ID into `[id="x"]`, while the + # captured element records carry `#x` and are compared by string. + self.assertEqual( + element_locators.locator_to_selector("css selector", '[id="username"]'), + "#username", + ) + self.assertEqual( + element_locators.locator_to_selector("css selector", '*[id="username"]'), + "#username", + ) + + def test_it_leaves_an_attribute_selector_that_is_not_an_id_alone(self): + self.assertEqual( + element_locators.locator_to_selector("css selector", '[name="q"]'), + '[name="q"]', + ) + + def test_it_declines_strategies_with_no_selector_equivalent(self): + for using, value in ( + ("link text", "Logout"), + ("partial link text", "Log"), + ("css selector", ""), + (None, "#x"), + ("css selector", None), + ): + with self.subTest(using=using, value=value): + self.assertIsNone(element_locators.locator_to_selector(using, value)) + + +class SelectorForCommandTest(unittest.TestCase): + def setUp(self): + element_locators.reset() + self.addCleanup(element_locators.reset) + + def test_a_find_remembers_its_locator_for_the_handle_it_produced(self): + found = _Handle("f.93A.e.3") + self.assertEqual( + element_locators.selector_for_command( + "findElement", {"using": "css selector", "value": '[id="username"]'}, found + ), + "#username", + ) + # The click that follows sees only the handle. + self.assertEqual( + element_locators.selector_for_command("clickElement", {"id": "f.93A.e.3"}), + "#username", + ) + self.assertEqual( + element_locators.selector_for_command( + "sendKeysToElement", {"id": "f.93A.e.3", "text": "tomsmith"} + ), + "#username", + ) + + def test_every_handle_a_plural_find_returned_is_remembered(self): + element_locators.selector_for_command( + "findElements", + {"using": "css selector", "value": ".row"}, + [_Handle("e.1"), _Handle("e.2")], + ) + self.assertEqual( + element_locators.selector_for_command("clickElement", {"id": "e.2"}), ".row" + ) + + def test_a_child_find_is_scoped_to_its_parent(self): + element_locators.selector_for_command( + "findElement", {"using": "css selector", "value": "#form"}, _Handle("e.1") + ) + self.assertEqual( + element_locators.selector_for_command( + "findChildElement", + {"id": "e.1", "using": "css selector", "value": ".row"}, + _Handle("e.2"), + ), + "#form .row", + ) + + def test_an_xpath_child_is_not_concatenated(self): + element_locators.selector_for_command( + "findElement", {"using": "css selector", "value": "#form"}, _Handle("e.1") + ) + self.assertEqual( + element_locators.selector_for_command( + "findChildElement", + {"id": "e.1", "using": "xpath", "value": "//a"}, + _Handle("e.2"), + ), + "//a", + ) + + def test_a_child_of_an_unknown_parent_keeps_its_own_selector(self): + self.assertEqual( + element_locators.selector_for_command( + "findChildElement", + {"id": "never-seen", "using": "css selector", "value": ".row"}, + _Handle("e.2"), + ), + ".row", + ) + + def test_an_unmapped_strategy_remembers_nothing(self): + found = _Handle("e.9") + self.assertIsNone( + element_locators.selector_for_command( + "findElement", {"using": "link text", "value": "Logout"}, found + ) + ) + self.assertIsNone( + element_locators.selector_for_command("clickElement", {"id": "e.9"}) + ) + + def test_an_unseen_handle_and_a_non_element_command_yield_nothing(self): + self.assertIsNone( + element_locators.selector_for_command("clickElement", {"id": "unknown"}) + ) + self.assertIsNone(element_locators.selector_for_command("get", {"url": "http://x"})) + self.assertIsNone(element_locators.selector_for_command("getTitle", None)) + + def test_a_frame_switch_by_index_is_not_read_as_a_handle(self): + # `switchToFrame` also takes `id`, as an index or a serialized element. + self.assertIsNone(element_locators.selector_for_command("switchToFrame", {"id": 0})) + self.assertIsNone( + element_locators.selector_for_command( + "switchToFrame", {"id": {"element-6066-11e4-a52e-4f735466cecf": "e.1"}} + ) + ) + + def test_the_registry_is_bounded_and_evicts_the_oldest(self): + for i in range(ELEMENT_LOCATOR_CACHE_SIZE + 10): + element_locators.selector_for_command( + "findElement", + {"using": "css selector", "value": f"#e{i}"}, + _Handle(f"e.{i}"), + ) + self.assertIsNone(element_locators.selector_for_command("clickElement", {"id": "e.0"})) + newest = f"e.{ELEMENT_LOCATOR_CACHE_SIZE + 9}" + self.assertEqual( + element_locators.selector_for_command("clickElement", {"id": newest}), + f"#e{ELEMENT_LOCATOR_CACHE_SIZE + 9}", + ) + + def test_reset_drops_handles_so_a_re_enable_cannot_serve_a_stale_selector(self): + element_locators.selector_for_command( + "findElement", {"using": "css selector", "value": "#a"}, _Handle("e.1") + ) + element_locators.reset() + self.assertIsNone(element_locators.selector_for_command("clickElement", {"id": "e.1"})) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/selenium-devtools-py/tests/test_frames.py b/packages/selenium-devtools-py/tests/test_frames.py index 57637719..5a3ffc4e 100644 --- a/packages/selenium-devtools-py/tests/test_frames.py +++ b/packages/selenium-devtools-py/tests/test_frames.py @@ -77,5 +77,28 @@ def __repr__(self): self.assertEqual(to_jsonable(Weird()), "") +class MetadataViewportTest(unittest.TestCase): + """Without a viewport the trace reader frames the replay at a hard-coded + 1280x720, which is whatever the run's window was not.""" + + def test_a_viewport_is_carried(self): + entry = frames.metadata("s1", viewport={"width": 1280, "height": 1024}) + + self.assertEqual(entry["viewport"], {"width": 1280, "height": 1024}) + + def test_it_is_copied_rather_than_aliased(self): + source = {"width": 800, "height": 600} + entry = frames.metadata("s1", viewport=source) + source["width"] = 1 + + self.assertEqual(entry["viewport"]["width"], 800) + + def test_an_unknown_viewport_is_omitted_not_zeroed(self): + # The reader's own default beats a zero-sized frame, and the app reads + # absent as unknown. + self.assertNotIn("viewport", frames.metadata("s1")) + self.assertNotIn("viewport", frames.metadata("s1", viewport=None)) + + if __name__ == "__main__": unittest.main() diff --git a/packages/selenium-devtools-py/tests/test_instrumentation.py b/packages/selenium-devtools-py/tests/test_instrumentation.py index 94e3c451..79ea2dbb 100644 --- a/packages/selenium-devtools-py/tests/test_instrumentation.py +++ b/packages/selenium-devtools-py/tests/test_instrumentation.py @@ -127,6 +127,53 @@ def test_no_screenshot_attached_on_error(self): self.assertEqual([d for s, d in self.tx.sent if s == "mutations"], []) +class FindingDriver(FakeDriver): + """Returns element handles from finds, as selenium's `execute` does once + `_unwrap_value` has turned the wire dict into a WebElement.""" + + class Element: + def __init__(self, element_id): + self.id = element_id + + def execute(self, command, params=None): + if command in ("findElement", "findChildElement"): + return {"value": self.Element("e.1")} + return super().execute(command, params) + + +class TestCommandSelector(unittest.TestCase): + """A row's `selector` is what the player's element overlay resolves in the + replayed document; without it a click row carries only an opaque handle.""" + + def setUp(self): + instrumentation.uninstall() + self.tx = FakeTransport() + instrumentation.install(SessionCapturer(self.tx), FindingDriver) + self.driver = FindingDriver() + self.addCleanup(instrumentation.uninstall) + + def _rows(self): + return [d[0] for s, d in self.tx.sent if s == "commands"] + + def test_a_command_on_a_found_handle_carries_that_find_s_selector(self): + self.driver.execute("findElement", {"using": "css selector", "value": '[id="go"]'}) + self.driver.execute("clickElement", {"id": "e.1"}) + click = [r for r in self._rows() if r["command"] == "clickElement"][0] + self.assertEqual(click["selector"], "#go") + + def test_a_failing_command_carries_it_too(self): + # The row a failure lands on is the one most worth boxing. + self.driver.execute("findElement", {"using": "css selector", "value": "#go"}) + with self.assertRaises(ValueError): + self.driver.execute("boom", {"id": "e.1"}) + boom = [r for r in self._rows() if r["command"] == "boom"][0] + self.assertEqual(boom["selector"], "#go") + + def test_a_row_with_no_known_locator_omits_the_field(self): + self.driver.execute("get", {"url": "https://x/"}) + self.assertNotIn("selector", self._rows()[0]) + + class FakeDriverWithScript(FakeDriver): """Driver whose execute_script drives the injected DOM collector.""" @@ -1060,3 +1107,68 @@ def execute(self, command, params=None): # ...but only `get` reached the timeline. captured = [d[0]["command"] for s, d in self.tx.sent if s == "commands"] self.assertEqual(captured, ["get"]) + + +class ViewportDriver(FakeDriver): + """Answers the viewport probe; every other script read returns None.""" + + def __init__(self, size=None): + super().__init__() + self.session_id = "sess-9" # already-initialized session + self.scripts = [] + self._size = size if size is not None else [1280, 1024] + + def execute_script(self, script, *args): + self.scripts.append(script) + return self._size if "innerWidth" in script else None + + +class TestViewportMetadata(unittest.TestCase): + """The player frames the replay from this; absent, it uses 1280x720.""" + + def setUp(self): + instrumentation.uninstall() + self.tx = FakeTransport() + instrumentation.install(SessionCapturer(self.tx), ViewportDriver) + self.addCleanup(instrumentation.uninstall) + + def _metadata(self): + return [d for s, d in self.tx.sent if s == "metadata"] + + def test_the_session_metadata_carries_the_real_viewport(self): + driver = ViewportDriver() + driver.execute("get", {"url": "https://x/"}) + + [meta] = self._metadata() + self.assertEqual(meta["viewport"], {"width": 1280, "height": 1024}) + + def test_the_probe_does_not_become_a_command_row(self): + # Unguarded it re-enters the same hook and every run opens with an + # executeScript row. + driver = ViewportDriver() + driver.execute("get", {"url": "https://x/"}) + + rows = [d[0]["command"] for s, d in self.tx.sent if s == "commands"] + self.assertEqual(rows, ["get"]) + + def test_a_driver_that_cannot_answer_omits_it(self): + instrumentation.uninstall() + tx = FakeTransport() + instrumentation.install(SessionCapturer(tx), FakeDriver) # no execute_script + driver = FakeDriver() + driver.execute("newSession") # FakeDriver gets its session id here + driver.execute("get", {"url": "https://x/"}) + + [meta] = [d for s, d in tx.sent if s == "metadata"] + self.assertNotIn("viewport", meta) + + def test_a_nonsense_size_is_refused(self): + for bad in ([0, 800], [1280, -1], ["1280", 800], [1280], "1280x800"): + with self.subTest(size=bad): + instrumentation.uninstall() + tx = FakeTransport() + instrumentation.install(SessionCapturer(tx), ViewportDriver) + ViewportDriver(bad).execute("get", {"url": "https://x/"}) + + [meta] = [d for s, d in tx.sent if s == "metadata"] + self.assertNotIn("viewport", meta) diff --git a/packages/selenium-devtools-py/tests/test_lifecycle.py b/packages/selenium-devtools-py/tests/test_lifecycle.py index 36b5f9d9..eeabb992 100644 --- a/packages/selenium-devtools-py/tests/test_lifecycle.py +++ b/packages/selenium-devtools-py/tests/test_lifecycle.py @@ -331,5 +331,81 @@ def test_unregister_closes_handle_and_restores(self): self.assertFalse(lifecycle._handlers_registered) +class TestProcessExitTeardown(unittest.TestCase): + """A script that just ends is a teardown path like any other. It used to + close the dashboard window and nothing else, which lost the trace archive: + that is written by disable(), and in trace mode there is no window whose + closing would have run it.""" + + def setUp(self): + lifecycle._reset_for_tests() + + def tearDown(self): + lifecycle._reset_for_tests() + + def _register(self, disable, handle): + with mock.patch.object(lifecycle.atexit, "register"), \ + mock.patch.object(lifecycle.signal, "signal"), \ + mock.patch.object(lifecycle.signal, "getsignal"), \ + mock.patch.object( + lifecycle.threading, "main_thread", + return_value=threading.current_thread()): + lifecycle.register_exit_handlers(disable, handle) + + def test_the_hook_actually_registered_is_the_one_that_tears_down(self): + # Without this the rest of the class passes with the bug still in + # place: it calls _on_process_exit directly, while atexit holds a + # window-close that never reaches disable(). + with mock.patch.object(lifecycle.atexit, "register") as reg, \ + mock.patch.object(lifecycle.signal, "signal"), \ + mock.patch.object(lifecycle.signal, "getsignal"), \ + mock.patch.object( + lifecycle.threading, "main_thread", + return_value=threading.current_thread()): + lifecycle.register_exit_handlers(mock.Mock(), None) + + reg.assert_called_once_with(lifecycle._on_process_exit) + + def test_process_exit_runs_disable(self): + disable = mock.Mock() + self._register(disable, None) # trace mode opens no window + + lifecycle._on_process_exit() + + self.assertTrue(disable.called) + + def test_it_closes_the_window_too(self): + disable = mock.Mock() + handle = BrowserHandle(proc=FakeProc()) + self._register(disable, handle) + + lifecycle._on_process_exit() + + self.assertTrue(disable.called) + self.assertTrue(handle._closed) + + def test_a_run_already_torn_down_is_not_torn_down_twice(self): + disable = mock.Mock() + self._register(disable, None) + lifecycle._trigger_shutdown(exit_after=False) + self.assertEqual(disable.call_count, 1) + + lifecycle._on_process_exit() + + self.assertEqual(disable.call_count, 1) + + def test_a_returned_waiter_does_not_block_teardown(self): + # `_trigger_shutdown` hands teardown to whoever is parked in + # wait_for_shutdown(); by atexit that caller has necessarily returned, + # so the same early-return here would skip disable() altogether. + disable = mock.Mock() + self._register(disable, None) + lifecycle.wait_for_shutdown(timeout=0) # sets _has_waiter, then returns + + lifecycle._on_process_exit() + + self.assertTrue(disable.called) + + if __name__ == "__main__": unittest.main() diff --git a/packages/selenium-devtools-py/tests/test_pytest_plugin.py b/packages/selenium-devtools-py/tests/test_pytest_plugin.py index 4f7a351c..d54e726b 100644 --- a/packages/selenium-devtools-py/tests/test_pytest_plugin.py +++ b/packages/selenium-devtools-py/tests/test_pytest_plugin.py @@ -379,7 +379,9 @@ def test_a_passing_assertion_carries_the_operands(self): ) [row] = self.capturer.commands - self.assertEqual(row["command"], "assert") + # Named for the operator, so shared's ACTION_MAP resolves it — a bare + # "assert" is dropped by the trace exporter. + self.assertEqual(row["command"], "assert.equal") self.assertEqual(row["args"], ['title == "Example Domain"']) self.assertEqual( row["result"], diff --git a/packages/shared/package.json b/packages/shared/package.json index 4a2cb132..aa8bd782 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -10,6 +10,7 @@ "directory": "packages/shared" }, "type": "module", + "sideEffects": false, "exports": { ".": { "types": "./src/index.ts", diff --git a/packages/shared/src/element-scripts.ts b/packages/shared/src/element-scripts.ts new file mode 100644 index 00000000..835bf5f6 --- /dev/null +++ b/packages/shared/src/element-scripts.ts @@ -0,0 +1,486 @@ +/** + * Browser-injectable script strings for element extraction. + * + * Each function returns a self-contained JavaScript string designed to run + * inside a browser page via `browser.execute(script)`. The scripts have no + * external dependencies and must be ES5-compatible. + * + * WDIO-dependent wrappers that call `browser.execute(script)` live in + * `@wdio/elements` — these are just the script bodies. + */ + +import { locatorDialect } from './locator-dialect.js' +import type { LocatorDialect, TextLocatorDialect } from './locator-dialect.js' +import type { TestRunnerId } from './types.js' + +/** + * HTTP contract for the page-side element scripts. + * + * Same reasoning as `COLLECTOR_API`: these are browser-injectable source + * strings, and an adapter that cannot import this package — the Python one — + * has no other way to reach them. Serving them keeps the version matched by + * construction instead of ported per language, which is what left a Python + * trace with no A11y tree at all. + * + * Generated rather than static, because the scripts bake in the runner's + * locator dialect: a WDIO run wants `a*=Logout`, a protocol-level one wants + * XPath, and the caller cannot patch that into a served string afterwards. + */ +export const ELEMENT_SCRIPTS_API = { + get: '/api/element-scripts' +} as const + +/** `Content-Type` for {@link ELEMENT_SCRIPTS_API}. A JSON envelope rather than + * raw source, because there are two scripts and a caller needs both. */ +export const ELEMENT_SCRIPTS_CONTENT_TYPE = 'application/json; charset=utf-8' + +/** Body of {@link ELEMENT_SCRIPTS_API} — one self-contained expression per + * script, both built by {@link buildElementScripts}. */ +export interface ElementScriptsResponse { + accessibilityTree: string + elements: string +} + +/** Fields a captured element record is read for: `trace-action-events` matches + * on `selector` and draws `after.point` from `boundingBox`, and the rest is + * context for a reader. `value` and `href` are deliberately absent — nothing + * in the repo reads either off a captured record, and a trace zip is a + * portable artifact, so shipping an unread OTP or signed url in every + * `*-elements.json` is cost with no consumer. `@wdio/elements` still returns + * the full `BrowserElementInfo` from its own live call. */ +const CAPTURED_ELEMENT_FIELDS = [ + 'tagName', + 'name', + 'type', + 'selector', + 'isInViewport', + 'boundingBox' +] as const + +/** The pair every caller wants: both scripts in the form an action snapshot + * reads them, so the route and the in-process adapters cannot drift apart on + * which arguments that is. */ +export function buildElementScripts( + runner?: TestRunnerId +): ElementScriptsResponse { + const projection = CAPTURED_ELEMENT_FIELDS.map( + (field) => `${field}: e.${field}` + ).join(', ') + return { + accessibilityTree: accessibilityTreeScript(true, runner), + // `.map` rather than a flag on elementsScript: the projection is a property + // of what a TRACE keeps, not of how the page is walked. + elements: `(${elementsScript(true, true, runner)}).map(function (e) { return { ${projection} } })` + } +} + +/** Shared by both injected scripts below — the same visibility gate decides + * which elements each one reports. */ +const IS_VISIBLE_SCRIPT = ` + function isVisible(el) { + if (typeof el.checkVisibility === 'function') { + return el.checkVisibility({ opacityProperty: true, visibilityProperty: true, contentVisibilityAuto: true }) + } + var style = window.getComputedStyle(el) + return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' && el.offsetWidth > 0 && el.offsetHeight > 0 + } +` + +/** XPath 1.0 has no string escape, so a value carrying both quote kinds is + * stitched from single-kind literals; a literal double quote can only enter the + * expression as its own single-quoted token. */ +const XPATH_TEXT_LITERAL_SCRIPT = ` + function xpathTextLiteral(value) { + if (value.indexOf('"') === -1) { return '"' + value + '"' } + if (value.indexOf("'") === -1) { return "'" + value + "'" } + var quoteToken = "'" + '"' + "'" + var parts = value.split('"') + var pieces = [] + for (var p = 0; p < parts.length; p++) { + if (parts[p]) { pieces.push('"' + parts[p] + '"') } + if (p < parts.length - 1) { pieces.push(quoteToken) } + } + // concat() takes at least two arguments — a value that is nothing but + // double quotes yields one piece and needs no concat. + return pieces.length > 1 ? 'concat(' + pieces.join(', ') + ')' : pieces[0] + } +` + +/** The meaning-bearing CSS branches: portable across all runners, so they are + * dialect-independent. Null when none of them identifies the element uniquely, + * which is what lets a caller order them against the text branch. */ +const SEMANTIC_CSS_SELECTOR_SCRIPT = ` + function semanticCssSelector(element, tag) { + var ariaLabel = element.getAttribute('aria-label') + if (ariaLabel && ariaLabel.length <= 200) { + var sel = '[aria-label="' + CSS.escape(ariaLabel) + '"]' + if (document.querySelectorAll(sel).length === 1) { return sel } + } + var testId = element.getAttribute('data-testid') + if (testId) { + var testSel = '[data-testid="' + CSS.escape(testId) + '"]' + if (document.querySelectorAll(testSel).length === 1) { return testSel } + } + if (element.id) { + var idSel = '#' + CSS.escape(element.id) + if (document.querySelectorAll(idSel).length === 1) { return idSel } + } + var nameAttr = element.getAttribute('name') + if (nameAttr) { + var nameSel = tag + '[name="' + CSS.escape(nameAttr) + '"]' + if (document.querySelectorAll(nameSel).length === 1) { return nameSel } + } + var typeAttr = element.getAttribute('type') + if (typeAttr) { + var typeSel = tag + '[type="' + CSS.escape(typeAttr) + '"]' + if (document.querySelectorAll(typeSel).length === 1) { return typeSel } + } + if (element.className && typeof element.className === 'string') { + var classes = element.className.trim().split(/\\s+/).filter(Boolean) + for (var i = 0; i < classes.length; i++) { + var clsSel = tag + '.' + CSS.escape(classes[i]) + if (document.querySelectorAll(clsSel).length === 1) { return clsSel } + } + if (classes.length >= 2) { + var twoClsSel = tag + classes.slice(0, 2).map(function(c) { return '.' + CSS.escape(c) }).join('') + if (document.querySelectorAll(twoClsSel).length === 1) { return twoClsSel } + } + } + return null + } +` + +/** Last resort: a positional `:nth-of-type` path, which always resolves but + * carries no meaning — hence every other branch getting first refusal. */ +const POSITIONAL_SELECTOR_SCRIPT = ` + function positionalSelector(element) { + var current = element + var path = [] + while (current && current !== document.documentElement) { + var seg = current.tagName.toLowerCase() + if (current.id) { path.unshift('#' + CSS.escape(current.id)); break } + var parent = current.parentElement + if (parent) { + var siblings = Array.from(parent.children).filter(function(c) { return c.tagName === current.tagName }) + if (siblings.length > 1) { seg += ':nth-of-type(' + (siblings.indexOf(current) + 1) + ')' } + } + path.unshift(seg) + current = current.parentElement + if (path.length >= 4) { break } + } + return path.join(' > ') + } +` + +/** The text branch's return expression. WebdriverIO's `tag*=text` compiles + * internally to XPath with `"` quoting, so a text carrying a double quote would + * yield a broken expression — those keep the XPath form, which it also resolves. */ +function textLocatorExpression(dialect: TextLocatorDialect): string { + const xpath = "'//' + tag + '[contains(., ' + xpathTextLiteral(text) + ')]'" + return dialect === 'webdriverio' + ? `text.indexOf('"') === -1 ? tag + '*=' + text : ${xpath}` + : xpath +} + +/** Identify the element by its own text, in `dialect`'s grammar. Null when the + * text neither exists nor singles it out, so it composes with the CSS branches + * in either order. */ +function textSelectorScript(dialect: TextLocatorDialect): string { + return ` + function textSelector(element, tag) { + var text = (element.textContent || '').trim().replace(/\\s+/g, ' ') + if (!text || text.length > 120) { return null } + var sameTagElements = document.querySelectorAll(tag) + var matchCount = 0 + sameTagElements.forEach(function(el) { if (el.textContent.includes(text)) { matchCount++ } }) + // The DOM predicate this counts is exactly XPath's + // \`//tag[contains(., text)]\`, so a single match here is a unique match + // there — the emitted expression carries the uniqueness just checked. + if (matchCount !== 1) { return null } + return ${textLocatorExpression(dialect)} + } +` +} + +/** Shared by both injected scripts below, so one grammar produces the locator in + * `-snapshot.txt` and `-elements.json`. The dialect decides both what the text + * branch emits and where it sits; the positional path stays last either way. */ +function getSelectorScript(dialect: LocatorDialect): string { + const preferred = + dialect.textBranch === 'first' + ? ['textSelector(element, tag)', 'semanticCssSelector(element, tag)'] + : ['semanticCssSelector(element, tag)', 'textSelector(element, tag)'] + return ` + ${XPATH_TEXT_LITERAL_SCRIPT} + ${SEMANTIC_CSS_SELECTOR_SCRIPT} + ${POSITIONAL_SELECTOR_SCRIPT} + ${textSelectorScript(dialect.text)} + + function getSelector(element) { + var tag = element.tagName.toLowerCase() + return ${preferred[0]} || ${preferred[1]} || positionalSelector(element) + } +` +} + +/** + * Accessibility tree walk — returns a flat array of AccessibilityNode. + * + * Walks the DOM from `document.body`, assigning semantic roles (button, link, + * textbox, heading, img, statictext, …) based on tag name, ARIA attributes, + * and visibility. Each node carries a unique locator, in `runner`'s own text + * dialect — omit it for the portable XPath form every runner resolves. + */ +export function accessibilityTreeScript( + inViewportOnly: boolean, + runner?: TestRunnerId +): string { + return `(function () { + var INPUT_TYPE_ROLES = { + text: 'textbox', search: 'searchbox', email: 'textbox', url: 'textbox', + tel: 'textbox', password: 'textbox', number: 'spinbutton', + checkbox: 'checkbox', radio: 'radio', range: 'slider', + submit: 'button', reset: 'button', image: 'button', file: 'button', color: 'button' + } + + var CONTAINER_ROLES = new Set([ + 'navigation', 'banner', 'contentinfo', 'complementary', 'main', + 'form', 'region', 'group', 'list', 'listitem', 'table', 'row', 'rowgroup', 'generic' + ]) + + function getRole(el) { + var explicit = el.getAttribute('role') + if (explicit) { return explicit.split(' ')[0] } + var tag = el.tagName.toLowerCase() + switch (tag) { + case 'button': return 'button' + case 'a': return el.hasAttribute('href') ? 'link' : null + case 'input': { + var type = (el.getAttribute('type') || 'text').toLowerCase() + if (type === 'hidden') { return null } + return INPUT_TYPE_ROLES[type] || 'textbox' + } + case 'select': return 'combobox' + case 'textarea': return 'textbox' + case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': return 'heading' + case 'img': return 'img' + case 'nav': return 'navigation' + case 'main': return 'main' + case 'header': return !el.closest('article,aside,main,nav,section') ? 'banner' : null + case 'footer': return !el.closest('article,aside,main,nav,section') ? 'contentinfo' : null + case 'aside': return 'complementary' + case 'dialog': return 'dialog' + case 'form': return 'form' + case 'section': return el.hasAttribute('aria-label') || el.hasAttribute('aria-labelledby') ? 'region' : null + case 'summary': return 'button' + case 'details': return 'group' + case 'progress': return 'progressbar' + case 'meter': return 'meter' + case 'ul': case 'ol': return 'list' + case 'li': return 'listitem' + case 'table': return 'table' + } + if (el.contentEditable === 'true') { return 'textbox' } + if (el.hasAttribute('tabindex') && parseInt(el.getAttribute('tabindex') || '-1', 10) >= 0) { return 'generic' } + if (getDirectText(el)) { return 'statictext' } + return null + } + + function getAccessibleName(el, role) { + var ariaLabel = el.getAttribute('aria-label') + if (ariaLabel) { return ariaLabel.trim() } + var labelledBy = el.getAttribute('aria-labelledby') + if (labelledBy) { + var texts = labelledBy.split(/\\s+/).map(function(id) { return (document.getElementById(id)?.textContent || '').trim() }).filter(Boolean) + if (texts.length > 0) { return texts.join(' ').slice(0, 200) } + } + var tag = el.tagName.toLowerCase() + if (tag === 'img' || (tag === 'input' && el.getAttribute('type') === 'image')) { + var alt = el.getAttribute('alt') + if (alt !== null) { return alt.trim() } + } + if (['input', 'select', 'textarea'].indexOf(tag) !== -1) { + var id = el.getAttribute('id') + if (id) { + var label = document.querySelector('label[for="' + CSS.escape(id) + '"]') + if (label) { return (label.textContent || '').trim() } + } + var parentLabel = el.closest('label') + if (parentLabel) { + var clone = parentLabel.cloneNode(true) + clone.querySelectorAll('input,select,textarea').forEach(function(n) { n.remove() }) + var lt = (clone.textContent || '').trim() + if (lt) { return lt } + } + } + var ph = el.getAttribute('placeholder') + if (ph) { return ph.trim() } + var title = el.getAttribute('title') + if (title) { return title.trim() } + var childImg = el.querySelector('img') + if (childImg) { + var imgAlt = childImg.getAttribute('alt') + if (imgAlt) { return imgAlt.trim() } + } + if (role && CONTAINER_ROLES.has(role)) { return '' } + return ((el.textContent || '').trim().replace(/\\s+/g, ' ') || '').slice(0, 200) + } + + ${getSelectorScript(locatorDialect(runner))} + + function getDirectText(el) { + var text = '' + for (var i = 0; i < el.childNodes.length; i++) { + if (el.childNodes[i].nodeType === 3) { text += el.childNodes[i].textContent } + } + return text.trim().replace(/\\s+/g, ' ') + } + + ${IS_VISIBLE_SCRIPT} + + function isInViewport(el) { + var rect = el.getBoundingClientRect() + return rect.top >= 0 && rect.left >= 0 && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && rect.right <= (window.innerWidth || document.documentElement.clientWidth) + } + + function getLevel(el) { + var m = el.tagName.toLowerCase().match(/^h([1-6])$/) + if (m) { return parseInt(m[1], 10) } + var ariaLevel = el.getAttribute('aria-level') + if (ariaLevel) { return parseInt(ariaLevel, 10) } + return undefined + } + + function getState(el) { + var inputEl = el + var isCheckable = ['input', 'menuitemcheckbox', 'menuitemradio'].indexOf(el.tagName.toLowerCase()) !== -1 || ['checkbox', 'radio', 'switch'].indexOf(el.getAttribute('role') || '') !== -1 + return { + disabled: el.getAttribute('aria-disabled') === 'true' || inputEl.disabled ? 'true' : '', + checked: isCheckable && inputEl.checked ? 'true' : el.getAttribute('aria-checked') || '', + expanded: el.getAttribute('aria-expanded') || '', + selected: el.getAttribute('aria-selected') || '', + pressed: el.getAttribute('aria-pressed') || '', + required: inputEl.required || el.getAttribute('aria-required') === 'true' ? 'true' : '', + readonly: inputEl.readOnly || el.getAttribute('aria-readonly') === 'true' ? 'true' : '' + } + } + + var result = [] + + function walk(el, depth) { + if (depth > 200) { return } + if (!isVisible(el)) { return } + var role = getRole(el) + var inViewport = isInViewport(el) + if (!role) { + for (var i = 0; i < el.children.length; i++) { walk(el.children[i], depth + 1) } + return + } + if (${inViewportOnly} && !inViewport) { + for (var i = 0; i < el.children.length; i++) { walk(el.children[i], depth + 1) } + return + } + var name = getAccessibleName(el, role) + var selector = getSelector(el) + var node = { role: role, name: name, selector: selector, depth: depth, level: getLevel(el) ?? '', isInViewport: inViewport } + var state = getState(el) + for (var k in state) { node[k] = state[k] } + result.push(node) + for (var i = 0; i < el.children.length; i++) { walk(el.children[i], depth + 1) } + } + + for (var i = 0; i < document.body.children.length; i++) { walk(document.body.children[i], 0) } + return result + })()` +} + +/** + * Interactable element query — returns a flat array of BrowserElementInfo. + * + * Uses `querySelectorAll` with a broad interactable-selector list, then + * filters by visibility and (optionally) viewport containment. Each element + * gets a computed accessible name and a unique locator, in `runner`'s own text + * dialect — omit it for the portable XPath form every runner resolves. + */ +export function elementsScript( + includeBounds: boolean, + inViewportOnly: boolean, + runner?: TestRunnerId +): string { + return `(function () { + var interactableSelectors = [ + 'a[href]', 'button', 'input:not([type="hidden"])', 'select', 'textarea', + '[role="button"]', '[role="link"]', '[role="checkbox"]', '[role="radio"]', + '[role="tab"]', '[role="menuitem"]', '[role="combobox"]', '[role="option"]', + '[role="switch"]', '[role="slider"]', '[role="textbox"]', '[role="searchbox"]', + '[role="spinbutton"]', '[contenteditable="true"]', '[tabindex]:not([tabindex="-1"])' + ].join(',') + + ${IS_VISIBLE_SCRIPT} + + function getAccessibleName(el) { + var ariaLabel = el.getAttribute('aria-label') + if (ariaLabel) { return ariaLabel.trim() } + var labelledBy = el.getAttribute('aria-labelledby') + if (labelledBy) { + var texts = labelledBy.split(/\\s+/).map(function(id) { return (document.getElementById(id)?.textContent || '').trim() }).filter(Boolean) + if (texts.length > 0) { return texts.join(' ').slice(0, 200) } + } + var tag = el.tagName.toLowerCase() + if (tag === 'img' || (tag === 'input' && el.getAttribute('type') === 'image')) { + var alt = el.getAttribute('alt') + if (alt !== null) { return alt.trim() } + } + if (['input', 'select', 'textarea'].indexOf(tag) !== -1) { + var id = el.getAttribute('id') + if (id) { + var label = document.querySelector('label[for="' + CSS.escape(id) + '"]') + if (label) { return (label.textContent || '').trim() } + } + var parentLabel = el.closest('label') + if (parentLabel) { + var clone = parentLabel.cloneNode(true) + clone.querySelectorAll('input,select,textarea').forEach(function(n) { n.remove() }) + var lt = (clone.textContent || '').trim() + if (lt) { return lt } + } + } + var ph = el.getAttribute('placeholder') + if (ph) { return ph.trim() } + var title = el.getAttribute('title') + if (title) { return title.trim() } + return ((el.textContent || '').trim().replace(/\\s+/g, ' ') || '').slice(0, 200) + } + + ${getSelectorScript(locatorDialect(runner))} + + var elements = [] + var seen = new Set() + + document.querySelectorAll(interactableSelectors).forEach(function(el) { + if (seen.has(el)) { return } + seen.add(el) + var htmlEl = el + if (!isVisible(htmlEl)) { return } + var inputEl = htmlEl + var rect = htmlEl.getBoundingClientRect() + var isInVp = rect.top >= 0 && rect.left >= 0 && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && rect.right <= (window.innerWidth || document.documentElement.clientWidth) + if (${inViewportOnly} && !isInVp) { return } + var elType = htmlEl.getAttribute('type') || '' + var entry = { + tagName: htmlEl.tagName.toLowerCase(), + name: getAccessibleName(htmlEl), + type: elType, + // A trace zip is a portable artifact, and nothing downstream reads a + // password's value — the a11y name comes from the label, not the field. + value: elType.toLowerCase() === 'password' ? '' : inputEl.value || '', + href: htmlEl.getAttribute('href') || '', + selector: getSelector(htmlEl), + isInViewport: isInVp + } + ${includeBounds ? 'entry.boundingBox = { x: rect.x + window.scrollX, y: rect.y + window.scrollY, width: rect.width, height: rect.height }' : ''} + elements.push(entry) + }) + return elements + })()` +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 176fa159..2d121ad7 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -4,6 +4,7 @@ export * from './action-mapping.js' export * from './baseline.js' export * from './console.js' +export * from './element-scripts.js' export * from './collector.js' export * from './files.js' export * from './locator-dialect.js' diff --git a/packages/shared/src/snapshot-format.ts b/packages/shared/src/snapshot-format.ts index b2c342c0..feeb8651 100644 --- a/packages/shared/src/snapshot-format.ts +++ b/packages/shared/src/snapshot-format.ts @@ -40,3 +40,61 @@ export const SNAPSHOT_LOCATOR_DELIM = '→' /** Marks an inferred purpose before the locator (` ∈ ""`). */ export const SNAPSHOT_PURPOSE_TOKEN = '∈' + +/** + * Roles that can be interacted with — rendered with `→ selector`. + * Structural roles (heading, img, form, nav, …) are intentionally excluded. + */ +export const INTERACTIVE_ROLES = new Set([ + 'button', + 'link', + 'textbox', + 'checkbox', + 'radio', + 'combobox', + 'slider', + 'searchbox', + 'spinbutton', + 'switch', + 'tab', + 'menuitem', + 'option' +]) + +/** The fields of a node this module's helpers read. Deliberately narrower than + * `AccessibilityNode` so core's mobile `SnapshotNode` satisfies it too — both + * snapshot pipelines share these helpers. */ +export interface SnapshotFormatNode { + role: string + name: string + depth: number +} + +/** + * Returns true when `nodes[index]` is a statictext whose accessible name + * is already echoed by its immediate interactive parent — such a node + * adds no information and should be suppressed from the output. + */ +export function isStatictextEchoedByParent( + nodes: SnapshotFormatNode[], + index: number +): boolean { + const node = nodes[index]! + if (node.role !== 'statictext' || !node.name) { + return false + } + for (let j = index - 1; j >= 0; j--) { + if (nodes[j]!.depth < node.depth) { + const parent = nodes[j]! + if ( + INTERACTIVE_ROLES.has(parent.role) && + parent.name && + parent.name.includes(node.name) + ) { + return true + } + break + } + } + return false +} diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 9df53232..64ccc423 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -507,6 +507,47 @@ export interface ActionSnapshot { screenshot?: string elements?: unknown[] snapshotText?: string + /** Raw accessibility nodes, for an adapter that captured the tree but cannot + * serialize it — the serializer is TypeScript. The exporter turns this into + * `snapshotText`, which is what the A11y tab parses; a sender that already + * has `snapshotText` never sets it. */ + accessibilityTree?: AccessibilityNode[] +} + +/** One node of what `accessibilityTreeScript` evaluates to in the page. */ +export interface AccessibilityNode { + role: string + name: string + selector: string + depth: number + level: number | string + disabled: string + checked: string + expanded: string + selected: string + pressed: string + required: string + readonly: string + /** Whether the element's bounding rect intersects the viewport. */ + isInViewport?: boolean +} + +/** One element of what `elementsScript` evaluates to in the page. */ +export interface BrowserElementInfo { + tagName: string + name: string // computed accessible name (ARIA spec) + type: string + value: string + href: string + selector: string + isInViewport: boolean + boundingBox?: { x: number; y: number; width: number; height: number } +} + +export interface GetBrowserElementsOptions { + includeBounds?: boolean + /** Only return elements whose bounding rect intersects the viewport (default true). */ + inViewportOnly?: boolean } export interface TraceLog { diff --git a/packages/shared/tests/action-mapping.test.ts b/packages/shared/tests/action-mapping.test.ts index edcb7de2..6ed96007 100644 --- a/packages/shared/tests/action-mapping.test.ts +++ b/packages/shared/tests/action-mapping.test.ts @@ -218,3 +218,26 @@ describe('raw W3C protocol names', () => { } }) }) + +describe('assertion command names', () => { + it('resolves the dotted forms every adapter emits', () => { + for (const command of [ + 'assert.equal', + 'assert.notEqual', + 'assert.strictEqual', + 'assert.notStrictEqual', + 'assert.ok', + 'verify.ok', + 'expect.toBe' + ]) { + expect(mapCommandToAction(command)?.class).toBe('Assert') + } + }) + + it('does not resolve a namespace with no method', () => { + // The Python adapter emitted a bare `assert` and the trace exporter drops + // what it cannot map, so those rows showed live and in no trace. + expect(mapCommandToAction('assert')).toBeNull() + expect(mapCommandToAction('expect')).toBeNull() + }) +}) diff --git a/packages/core/tests/element-scripts.test.ts b/packages/shared/tests/element-scripts.test.ts similarity index 83% rename from packages/core/tests/element-scripts.test.ts rename to packages/shared/tests/element-scripts.test.ts index d4a30847..c6dd9e42 100644 --- a/packages/core/tests/element-scripts.test.ts +++ b/packages/shared/tests/element-scripts.test.ts @@ -3,15 +3,15 @@ import { beforeEach, describe, expect, it } from 'vitest' import { accessibilityTreeScript, + buildElementScripts, elementsScript } from '../src/element-scripts.js' import type { AccessibilityNode, - BrowserElementInfo -} from '../src/element-types.js' -import { locatorsMatch } from '@wdio/devtools-shared' -import type { TestRunnerId } from '@wdio/devtools-shared' -import { accessibilityNodesToSnapshotNodes } from '../src/element-snapshot.js' + BrowserElementInfo, + TestRunnerId +} from '../src/types.js' +import { locatorsMatch } from '../src/locator-dialect.js' /** The scripts are injectable source, so they are run the way the adapters run * them — `@wdio/elements` builds the same `new Function` wrapper. */ @@ -346,17 +346,72 @@ describe('generated locators the exporter has to parse back', () => { expect(locatorsMatch(captured, 'button*=Logout')).toBe(false) } }) +}) - it('yields its tag to the snapshot serializer in either dialect', () => { - // The serializer reads the tag back out of the locator it was handed, so a - // dialect it can't parse would report the ARIA role as the tag instead. - for (const runner of ['mocha', 'nightwatch'] as const) { - const nodes = accessibilityNodesToSnapshotNodes(a11yNodes(html, runner), { - inViewportOnly: false - }) - const link = nodes.find((n) => n.name === 'Logout') +describe('captured input values', () => { + it('keeps a plain field value', () => { + document.body.innerHTML = '' + expect( + interactables('')[0].value + ).toBe('tomsmith') + }) + + it('never captures a password, which would travel with the trace zip', () => { + const els = interactables( + '' + ) + + expect(els).toHaveLength(1) + expect(els[0].value).toBe('') + expect(JSON.stringify(els)).not.toContain('SuperSecretPassword') + }) + + it('matches the type case-insensitively, as the HTML parser does', () => { + expect( + interactables('')[0].value + ).toBe('') + }) +}) + +describe('what a capture keeps', () => { + const html = + 'Go' + + '' - expect(link?.tagName).toBe('a') + function captured(): Record[] { + document.body.innerHTML = html + return run>(buildElementScripts('mocha').elements) + } + + it('keeps what the trace reads', () => { + // `trace-action-events` matches on selector and draws after.point from the + // bounding box; the rest is context for a reader. + const link = captured().find((e) => e.name === 'Go')! + + expect(link.selector).toBeTruthy() + expect(link).toHaveProperty('boundingBox') + expect(link).toMatchObject({ tagName: 'a', name: 'Go', isInViewport: true }) + }) + + it('drops the fields nothing reads, so a trace zip carries neither', () => { + const json = JSON.stringify(captured()) + + expect(json).not.toContain('SECRET') + expect(json).not.toContain('123456') + for (const e of captured()) { + expect(e).not.toHaveProperty('href') + expect(e).not.toHaveProperty('value') } }) + + it('leaves the @wdio/elements call returning the full record', () => { + // Public API — BrowserElementInfo documents both fields. + document.body.innerHTML = html + const live = run>( + elementsScript(true, true, 'mocha') + ) + + expect(live.find((e) => e.tagName === 'a')!.href).toContain('token=SECRET') + expect(live.find((e) => e.tagName === 'input')!.value).toBe('123456') + }) }) diff --git a/packages/trace/src/a11y-snapshot.ts b/packages/trace/src/a11y-snapshot.ts new file mode 100644 index 00000000..a958d1ba --- /dev/null +++ b/packages/trace/src/a11y-snapshot.ts @@ -0,0 +1,135 @@ +/** + * Accessibility tree → the indented text the trace's A11y tab parses. + * + * A pure transform over nodes a page-side script produced: no driver, no + * framework, no DOM. It lives here rather than in `core` because the backend + * builds the trace for an adapter that cannot run the transforms itself, and + * §2.2 bars the backend from importing core — the same reason the rest of this + * package moved. The JS adapters reach it through core's re-export unchanged. + * + * Only the WEB serializer is here. The mobile one shares helpers with the + * locator generation that stays in `core`, and no adapter exporting through the + * backend drives native mobile. + */ + +import { + INTERACTIVE_ROLES, + isStatictextEchoedByParent, + SNAPSHOT_INDENT_UNIT, + SNAPSHOT_LOCATOR_DELIM, + SNAPSHOT_PAGE_HEADER, + SNAPSHOT_PURPOSE_TOKEN +} from '@wdio/devtools-shared' +import type { AccessibilityNode } from '@wdio/devtools-shared' + +export interface WebSnapshotOptions { + inViewportOnly?: boolean +} + +/** + * Walk backwards from `index` to find the nearest ancestor or preceding + * structural sibling with a non-empty name. Same-depth nodes are only + * used when they are structural (img, heading, statictext, …) — never + * another interactive element. + */ +function inferPurpose( + nodes: AccessibilityNode[], + index: number +): string | undefined { + const myDepth = nodes[index].depth + for (let i = index - 1; i >= 0; i--) { + if (nodes[i].depth <= myDepth && nodes[i].name) { + // Same-depth sibling: only structural elements count + if (nodes[i].depth === myDepth && INTERACTIVE_ROLES.has(nodes[i].role)) { + continue + } + return nodes[i].name + } + } + return undefined +} + +/** + * Serialize a web accessibility tree into a depth-indented text snapshot. + * + * @param nodes Flat ordered node list from getBrowserAccessibilityTree() + * @param context Optional page context for the header line + * @param options {@link WebSnapshotOptions} + */ +export function serializeWebSnapshot( + nodes: AccessibilityNode[], + context?: { url?: string; title?: string }, + options: WebSnapshotOptions = {} +): string { + const { inViewportOnly = true } = options + + let header = SNAPSHOT_PAGE_HEADER + if (context?.title) { + header += `: ${context.title}` + } + if (context?.url) { + header += ` — ${context.url}` + } + header += ']' + + const lines: string[] = [header] + + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i] + + // When viewport filtering is on, skip nodes that are known to be off-screen. + // Nodes from a tree captured with inViewportOnly=false will have + // isInViewport populated; nodes from a pre-filtered tree all have + // isInViewport=true (or undefined for pre-existing data). + if (inViewportOnly && node.isInViewport === false) { + continue + } + + const indent = SNAPSHOT_INDENT_UNIT.repeat(node.depth + 1) // +1 indents everything under the header + const isInteractive = INTERACTIVE_ROLES.has(node.role) + + if (isStatictextEchoedByParent(nodes, i)) { + continue + } + + // Heading gets level suffix: heading[2] + const roleLabel = + node.role === 'heading' && node.level + ? `heading[${node.level}]` + : node.role + + if (isInteractive) { + // No selector → agent can't act on this node; skip entirely + if (!node.selector) { + continue + } + const purpose = inferPurpose(nodes, i) + if (node.name) { + // Show parent context when available — disambiguates + // duplicate selectors like six "Add to Wishlist" buttons. + lines.push( + purpose + ? `${indent}${roleLabel} "${node.name}" ${SNAPSHOT_PURPOSE_TOKEN} "${purpose}" ${SNAPSHOT_LOCATOR_DELIM} ${node.selector}` + : `${indent}${roleLabel} "${node.name}" ${SNAPSHOT_LOCATOR_DELIM} ${node.selector}` + ) + } else if (purpose) { + lines.push( + `${indent}${roleLabel} ${SNAPSHOT_PURPOSE_TOKEN} "${purpose}" ${SNAPSHOT_LOCATOR_DELIM} ${node.selector}` + ) + } else { + lines.push( + `${indent}${roleLabel} ${SNAPSHOT_LOCATOR_DELIM} ${node.selector}` + ) + } + } else { + // Container / structural: show role + name when present, no selector + lines.push( + node.name + ? `${indent}${roleLabel} "${node.name}"` + : `${indent}${roleLabel}` + ) + } + } + + return lines.join('\n') +} diff --git a/packages/trace/src/index.ts b/packages/trace/src/index.ts index 22b9c385..b3361747 100644 --- a/packages/trace/src/index.ts +++ b/packages/trace/src/index.ts @@ -9,6 +9,7 @@ // capture. Adapter-side policy and orchestration stay in `core` // (`trace-finalizer`, `spec-trace-helpers`, `trace-retention`). +export * from './a11y-snapshot.js' export * from './sha1.js' export * from './screencast-trace.js' export * from './trace-action-events.js' diff --git a/packages/trace/tests/a11y-snapshot.test.ts b/packages/trace/tests/a11y-snapshot.test.ts new file mode 100644 index 00000000..2cfcb413 --- /dev/null +++ b/packages/trace/tests/a11y-snapshot.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' + +import { serializeWebSnapshot } from '../src/a11y-snapshot.js' +import type { AccessibilityNode } from '@wdio/devtools-shared' + +function node(overrides: Partial): AccessibilityNode { + return { + role: 'statictext', + name: '', + selector: '', + depth: 0, + level: '', + disabled: '', + checked: '', + expanded: '', + selected: '', + pressed: '', + required: '', + readonly: '', + ...overrides + } +} + +describe('serializeWebSnapshot', () => { + it('renders the page header from the context it is given', () => { + const text = serializeWebSnapshot([], { + url: 'https://x/login', + title: 'The Internet' + }) + + expect(text).toBe('[Page: The Internet — https://x/login]') + }) + + it('indents by depth and appends a locator to interactive nodes only', () => { + const text = serializeWebSnapshot([ + node({ role: 'heading', name: 'Login Page', level: 2, depth: 0 }), + node({ + role: 'textbox', + name: 'Username', + selector: '#username', + depth: 1 + }) + ]) + + expect(text.split('\n')).toEqual([ + '[Page]', + ' heading[2] "Login Page"', + ' textbox "Username" ∈ "Login Page" → #username' + ]) + }) + + it('drops an interactive node with no selector — nothing can act on it', () => { + const text = serializeWebSnapshot([node({ role: 'button', name: 'Go' })]) + + expect(text).toBe('[Page]') + }) + + it('honours the viewport filter, and can be told not to', () => { + const nodes = [ + node({ role: 'button', name: 'Go', selector: '#go', isInViewport: false }) + ] + + expect(serializeWebSnapshot(nodes)).toBe('[Page]') + expect( + serializeWebSnapshot(nodes, undefined, { inViewportOnly: false }) + ).toContain('#go') + }) + + it('suppresses a statictext its interactive parent already announces', () => { + const text = serializeWebSnapshot([ + node({ role: 'button', name: 'Login', selector: '#go', depth: 0 }), + node({ role: 'statictext', name: 'Login', depth: 1 }) + ]) + + expect(text.split('\n')).toHaveLength(2) + }) +})