Skip to content
Merged
7 changes: 7 additions & 0 deletions .changeset/call-source-under-source-maps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@wdio/selenium-devtools': patch
'@wdio/nightwatch-devtools': patch
'@wdio/devtools-service': patch
---

A command's source location no longer points into the adapter's own sources when the run has source maps enabled (for example under `tsx`) inside the devtools workspace; it names the test file again. A command with no user call site now carries no `callSource` instead of the `unknown:0` placeholder, and a malformed stack frame can no longer throw out of call-site detection.
5 changes: 5 additions & 0 deletions .changeset/selenium-example-heading.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@wdio/selenium-devtools': patch
---

The README's mocha example now reads its heading from a page that still has one: example.com no longer serves an `<h1>`, so the example's wait timed out before its `getText()` and assertion ever ran.
5 changes: 5 additions & 0 deletions .changeset/selenium-failed-wait-row.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@wdio/selenium-devtools': patch
---

A `driver.wait()` that times out or rejects now shows as a failed `wait` row named after its condition (for example `Waiting for element to be located By(css selector, h1)`), in the Actions list, the Errors tab and the trace, in place of the row its polls folded into. A passing wait renders as before, and the wait's promise rejects, or goes unhandled, exactly as selenium's own does. A wait's polls now fold only into a row of that same wait: the polls fired from its timer join its first poll's row instead of listing as a second one, they never fold into an earlier, unrelated row with the same command and arguments, and two reads inside one condition (say `a.getText()` and `b.getText()`) each keep a row of their own.
5 changes: 5 additions & 0 deletions .changeset/selenium-mocha-root-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@wdio/selenium-devtools': patch
---

Under `mocha --require @wdio/selenium-devtools` the test hooks are registered as a mocha root hook plugin (`mochaHooks`) before any spec loads, so the first test is no longer missed when it starts before the plugin's poll for mocha's globals, which dropped every command that test ran.
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,8 @@ 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.** Three bugs of that one shape, all found by rerunning a single pytest test. The third is the one that shows the rule has a limit: (c) the **launch command** — what Run-all spawns — was built from the child's own invocation, which the backend had narrowed to a single nodeid, so one targeted rerun rescoped Run-all to that test permanently and the tree kept showing only what that child collected. Unlike (a) and (b) this is not recoverable inside the child: its arguments no longer mention what it was narrowed from. The original travels down instead, as `REUSE_ENV.LAUNCH_COMMAND` beside the rest of the reuse handshake; the *rerun template* stays locally derived, since only this process can say how its own interpreter selects a test. The inherit-or-derive resolution is Selenium's `helpers/launch-command.ts` `resolveLaunchCommand`, read only while the reuse handshake is live (a leftover export must not pin Run all on a first run); it stays in the adapter until a second JS adapter needs it, since Python mirrors it in `rerun.py` `_inherited_launch` and cannot import core. `captureLaunchCommand` ignores `npm_lifecycle_event` when `npm_command` is `exec` (`npx` sets the event to `npx` and `pnpm exec` sets none, so neither names a script to rerun) and shell-quotes each argument of its argv fallback, which the backend splits with `shellParse`. Nightwatch has no launch command, since the backend relaunches it through its framework path; the run that starts the backend records its CLI selection in `RUNNER_ENV.NIGHTWATCH_INITIAL_ARGS` (shared `encodeInitialArgs` / `parseInitialArgs`), or clears the variable when it selected nothing, in its own process, which a rerun child attaching in reuse mode cannot touch. The selection is specs made absolute plus every other flag without `--config`; Nightwatch parses argv with minimist and declares no boolean flags, so a flag's following bare word is its value and is never rewritten as a path, and only an entry script in the `nightwatch` package's `bin` folder counts as the CLI. The backend's `framework-filters.ts` `runAllFilters` replays it for both Nightwatch runners, as `WDIO_INITIAL_SPECS` does for every other. The other two: (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 `wait()` poll is the user's one call, not a row per tick.** selenium-webdriver's `driver.wait(until.…)` runs its first poll inside the call, with the spec line on the stack, and every later one from a timer with no user frame; the retry fold keyed on command, args and call source, so the first poll stood alone and the rest folded beside it, and because the driver patcher does not await `onCommand`, a poll could also arrive before the previous one had its id and start a third row. `captureOrReplaceCommand` now runs one capture at a time per capturer, and `RetryTracker.isRetryIgnoringSource` folds a source-less repeat into the sourced row only when it is one of a wait's own polls: the patched `wait()` runs its condition inside an `AsyncLocalStorage` (`driverPatcher.ts` `waitPoll`), which marks `CapturedCommand.duringWait` for exactly those commands, per driver and per async chain. A source-less call outside a wait (a node_modules helper) stays its own row, and so does a later call from the user's own code. Accepted cost: a library that polls from its own timer rather than through `driver.wait()` shows a row per poll.
- **A `wait()` poll is the user's one call, not a row per tick.** selenium-webdriver's `driver.wait(until.…)` runs its first poll inside the call, with the spec line on the stack, and every later one from a timer with no user frame, so a fold keyed on the call source left the first poll standing alone. The patched `wait()` runs its condition inside an `AsyncLocalStorage` (`wait-tracking.ts` `waitPoll`), which stamps `CapturedCommand.waitScope` (one object per `wait()` call) on exactly its own polls, per driver and per async chain, and `captureOrReplaceCommand` folds a poll **only** into the row an earlier poll of the same scope made for the same call (`WaitScope.rows`). The call is named at invocation (`waitPollOf`, `CapturedCommand.pollKey`) as the nth call with that command, args and target within one evaluation of the condition, since every evaluation repeats its calls in order and a timer poll has no call site to tell them apart: keyed on command and args alone, a condition reading `a.getText()` and `b.getText()` folded the second read over the first. A wait's rows never enter the ordinary retry tracker and reset it: replacing an older poll row moved only `lastId`, leaving `lastSig` naming another command, so a later retry of that command overwrote the wrong row, and a helper's wait polling the same `findElements` as a row the user wrote just before it overwrote that row. A retry therefore never folds across a wait. Captures run one at a time per capturer, because the driver patcher does not await `onCommand` and a poll could otherwise arrive before the previous one has the row id it folds into. A call outside a wait folds only as a same-call-site retry. Accepted cost: a library that polls from its own timer rather than through `driver.wait()` shows a row per poll.
- **A wait that rejects is a row, and it is the polls' row.** A passing wait leaves only its folded poll row (a `findElements` for `until.elementLocated`, which ACTION_MAP leaves out of a trace), so a failed one used to leave nothing that carried the TimeoutError: no red row, nothing in the Errors tab, the failure readable only in the terminal output. The patched `wait()` now returns a promise derived from selenium's that records the failure and rethrows it, so an awaited failure rejects with the same error and an unawaited one still surfaces as `unhandledRejection`; an element wait is re-wrapped in selenium's own `WebElementPromise`, which `driver.wait(until.elementLocated(…)).getText()` chains on. On rejection it emits a `wait` row labelled with the condition's `description()` and the call site resolved from the stack captured at the call, carrying `concludesWait`; `captureOrReplaceCommand` replaces the wait's latest poll row (`WaitScope.pollRowId`) instead of listing a second one, or captures a fresh row when the condition issued no command. `wait` maps to `Browser.waitForFunction`, the same trace method as WDIO's `waitUntil`.
- **Safari delivers a synthesized mouse click only to its frontmost window, and a live run opens the dashboard in another browser.** safaridriver's `performInteractionSequence` answers success either way, so a click issued while the Safari window is not frontmost reaches nothing: measured on desktop Safari 26.5 with no DevTools involved at all, the page reported `document.hasFocus() === false` (once `visibilityState: hidden`), no pointer event fired, and the submit never happened in 3 of 3 attempts, while after activating Safari every click landed; the user's own manual click in that window logged in. Live mode is where it shows, because the dashboard window and the wait for it to connect are what move focus; trace mode mostly escapes it and Chrome never has it. This was earlier recorded as "the Python adapter's login flow on desktop Safari is flaky" with the per-command work as the suspect; that work only widens the window. The Selenium adapter switches to the session's own window handle before every element click, sendKeys, clear and submit on a Safari session (`DriverPatcherHooks.beforeInput`, the plugin's `focusForInput`): a WebDriver window switch brings exactly that window forward, one round trip, and holds whatever took the front in between, measured 3 of 3 live logins against 0 of 2 before. `open -a Safari` was tried first and is not enough, since it activates Safari's most recent window, which can be the user's own (`visibilityState: hidden` in the automation window afterwards). The WDIO service, Nightwatch and Python still use `core/browser-focus.ts` `restoreBrowserFocus` (`open -a Safari`) once after session bring-up in live mode, with that weakness; a per-input switch there is the next step. The Selenium dashboard also opens behind the current window on macOS for a Safari session (`dashboardLaunchCommand`, `open -n -g`). The Nightwatch BDD example's assertions are separately not Safari-proof (4 of 7 fail on Safari).
- **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 `<input type="password">` 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.
Expand Down
14 changes: 8 additions & 6 deletions examples/selenium-js/mocha/example.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ DevTools.configure({
headless: true
})

const LOGIN_URL = 'https://the-internet.herokuapp.com/login'

// Survives Mocha's in-process retry so the flaky test fails once, then passes.
let flakyAttempts = 0

Expand All @@ -43,12 +45,12 @@ describe('selenium-devtools smoke test', function () {
}
})

it('loads example.com and reads the heading', async function () {
await driver.get('https://example.com')
it('loads the login page and reads the heading', async function () {
await driver.get(LOGIN_URL)
await driver.sleep(1500)
const heading = await driver.wait(until.elementLocated(By.css('h1')), 10000)
const heading = await driver.wait(until.elementLocated(By.css('h2')), 10000)
const text = await heading.getText()
assert.equal(text, 'Example Domain')
assert.equal(text, 'Login Page')
})

it('navigates and reads the page title', async function () {
Expand All @@ -59,9 +61,9 @@ describe('selenium-devtools smoke test', function () {
})

it('fails on a wrong heading (retain-on-failure target)', async function () {
await driver.get('https://example.com')
await driver.get(LOGIN_URL)
await driver.sleep(1000)
const heading = await driver.wait(until.elementLocated(By.css('h1')), 10000)
const heading = await driver.wait(until.elementLocated(By.css('h2')), 10000)
const text = await heading.getText()
assert.equal(text, 'This Is Not The Heading')
})
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ export function toError(value: unknown): Error {
}
return e
}
return new Error(String(value))
try {
return new Error(String(value))
} catch {
// `String()` throws for a null-prototype object.
return new Error(Object.prototype.toString.call(value))
}
}

/**
Expand Down
27 changes: 0 additions & 27 deletions packages/core/src/retry-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
*/
export class RetryTracker {
#lastSig: string | null = null
#lastBareSig: string | null = null
#lastId: number | null = null

/** Build the canonical signature used for retry-equality checks. */
Expand All @@ -24,23 +23,12 @@ export class RetryTracker {
return JSON.stringify({ command, args, src: callSource ?? null })
}

/** The signature without the call site: a framework's own repeat of a call
* (a `wait()` poll from a timer) has no user frame on its stack. */
static bareSignature(command: string, args: unknown): string {
return JSON.stringify({ command, args })
}

/** True when the incoming signature matches the last captured one AND we
* have an id to replace (otherwise there's nothing to replace yet). */
isRetry(sig: string): boolean {
return sig === this.#lastSig && this.#lastId !== null
}

/** As `isRetry`, for a repeat that carries no call site of its own. */
isRetryIgnoringSource(bareSig: string): boolean {
return bareSig === this.#lastBareSig && this.#lastId !== null
}

/** The id of the last captured command, if any (for the replace-in-place
* flow). */
get lastId(): number | null {
Expand All @@ -50,22 +38,9 @@ export class RetryTracker {
/** Record a fresh capture — sets both sig and id together. */
recordCapture(sig: string, id: number | null): void {
this.#lastSig = sig
this.#lastBareSig = RetryTracker.bareOf(sig)
this.#lastId = id
}

static bareOf(sig: string): string | null {
try {
const { command, args } = JSON.parse(sig) as {
command: string
args: unknown
}
return RetryTracker.bareSignature(command, args)
} catch {
return null
}
}

/** Record only the id (used by adapters that compute the sig but defer the
* id assignment to after an async capture call). */
setLastId(id: number | null): void {
Expand All @@ -77,13 +52,11 @@ export class RetryTracker {
* commands). Pair with {@link setLastId} once the capture resolves. */
setLastSig(sig: string): void {
this.#lastSig = sig
this.#lastBareSig = RetryTracker.bareOf(sig)
}

/** Reset at test/scenario boundaries so the next capture is "fresh". */
reset(): void {
this.#lastSig = null
this.#lastBareSig = null
this.#lastId = null
}
}
Loading
Loading