Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
036fe45
build(shared): mark the package side-effect free
vishnuv688 Aug 31, 2026
f6183bd
refactor(shared): own the types the page-side scripts produce
vishnuv688 Aug 31, 2026
ecaa28e
refactor(shared): own the page-side element script source
vishnuv688 Aug 31, 2026
c973c9c
refactor(core): build both element scripts through one helper
vishnuv688 Aug 31, 2026
43d0a2d
test: move the element-script tests to shared
vishnuv688 Aug 31, 2026
e712065
refactor(trace): move the web a11y serializer out of core
vishnuv688 Aug 31, 2026
0749f42
feat(backend): serve the page-side element scripts
vishnuv688 Aug 31, 2026
cce5255
feat(backend): accumulate per-action snapshots on the active run
vishnuv688 Aug 31, 2026
c1571a9
feat(backend): serialize a sender's raw a11y trees on trace export
vishnuv688 Aug 31, 2026
9251b1b
chore(selenium-devtools-py): generate the action-snapshot scope and s…
vishnuv688 Aug 31, 2026
3ba25ad
chore(selenium-devtools-py): declare the a11y and locator wire shapes
vishnuv688 Aug 31, 2026
ca95164
feat(selenium-devtools-py): fetch the page-side element scripts from …
vishnuv688 Aug 31, 2026
cd5745a
feat(selenium-devtools-py): stream action snapshots ahead of the expo…
vishnuv688 Aug 31, 2026
5f4e8c0
feat(selenium-devtools-py): capture the a11y tree and element rects p…
vishnuv688 Aug 31, 2026
88d7c2e
fix(selenium-devtools-py): stamp a command row with the locator it ac…
vishnuv688 Aug 31, 2026
696480d
docs: record instrumentation.py in the file-size debt list
vishnuv688 Aug 31, 2026
c9fdabc
fix(shared): never capture a password field's value
vishnuv688 Aug 31, 2026
9292580
fix(selenium-devtools-py): tear capture down when the process just ends
vishnuv688 Aug 31, 2026
6d3a8c7
fix(selenium-devtools-py): make a trace show what an assertion did
vishnuv688 Aug 31, 2026
1f3e59e
fix(examples): a login example that logs out, and a trace that shows why
vishnuv688 Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ Documented divergences from the conventions above. They exist today as debt to b
- 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.
- **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 `<input type="password">` 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.
Expand Down Expand Up @@ -336,6 +336,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)

Expand Down
12 changes: 8 additions & 4 deletions examples/selenium/python-test/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
Expand Down
15 changes: 11 additions & 4 deletions examples/selenium/python-test/test_login_pytest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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


Expand All @@ -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
6 changes: 6 additions & 0 deletions packages/backend/src/baseline/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
ActionSnapshot,
CommandLog,
ConsoleLog,
Metadata,
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/backend/src/baseline/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ export function freshRun(): ActiveRun {
nodes: new Map(),
startedAt: Date.now(),
traceLogs: [],
screencastFrames: []
screencastFrames: [],
actionSnapshots: []
}
}

Expand Down
3 changes: 3 additions & 0 deletions packages/backend/src/baselineStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
37 changes: 36 additions & 1 deletion packages/backend/src/trace-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions packages/backend/tests/baselineStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand Down
Loading
Loading