Skip to content

fix(selenium-devtools): give a failed driver.wait() a row, and fix the call site and first-test rows - #453

Merged
vishnuv688 merged 8 commits into
mainfrom
fix/selenium-rows-after-wait
Oct 27, 2026
Merged

vishnuv688 merged 8 commits into
mainfrom
fix/selenium-rows-after-wait

Conversation

@vishnuv688

Copy link
Copy Markdown
Member

What & why

A Selenium mocha trace showed tests with no rows after a driver.wait(): no getText, no assert.equal, not even for the failing one. The trace's own stdout events had the real story, Wait timed out after 10054ms: example.com no longer serves an <h1>, so driver.wait(until.elementLocated(By.css('h1'))) timed out and nothing after it ran. Capture had not regressed, but the failure was invisible, because a failed wait() produced no row at all. Looking into it turned up three adapter bugs, fixed here alongside the example.

  • Example: the mocha example and its README snippet read the h2 of the-internet's login page, which the other examples already drive.
  • A failed driver.wait() gets a red row. The patched wait() returns a promise derived from selenium's that records the failure and rethrows it, re-wrapped in selenium's own WebElementPromise for an element wait, so an awaited failure rejects with the same error, an unawaited one still surfaces as unhandledRejection, and driver.wait(until.elementLocated(...)).getText() still chains. The failure turns the wait's own poll row into a wait row named after the condition (Waiting for element to be located By(css selector, h1)), mapped to the same trace method as WDIO's waitUntil, so it shows in the Actions list, the Errors tab and the trace.
  • A wait's polls fold only into that wait's rows. Each wait() call carries its own scope, and a poll folds only into a row of the same scope. Before, the fold matched any earlier row by command and arguments, so a helper's wait could overwrite the user's own findElements row. Timer polls also never folded at all, because the patcher reported core's 'unknown:0' placeholder instead of no call source.
  • The call site is right under source maps. Under tsx or vitest the adapters' own frames are named by their src/ paths, so every row's callSource pointed at core/src/stack.ts. Core now locates this monorepo's packages/ directory from the nearest pnpm-workspace.yaml and treats its src/ files as library code; an adapter installed from npm or bundled into a user's build finds no such workspace, so a user's own src/ stays user code. A malformed frame can no longer throw, and no user frame now means no call source rather than 'unknown:0', which fixes the same fold for Nightwatch.
  • The first test keeps its rows under --require. The plugin loads before mocha publishes its globals, and the hooks were registered by a 100 ms poll, so a first test that started sooner lost every command. The package now exports mochaHooks, mocha's root hook plugin entry, which mocha reads before any spec; importing the plugin from a spec still registers through the globals.

Type of change

  • Bugfix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Polish (an improvement to an existing feature)
  • Breaking change (existing behavior changes for users)
  • Documentation
  • Internal (build, CI, dependencies, tooling)

Packages touched

  • shared (types and contracts)
  • core (framework-agnostic capture/reporting)
  • elements (published element/snapshot API — @wdio/elements)
  • service (WebdriverIO adapter)
  • nightwatch-devtools (Nightwatch adapter)
  • selenium-devtools (Selenium adapter)
  • selenium-devtools-py (Selenium Python adapter)
  • backend (server)
  • app (UI)
  • script (page-injected runtime)
  • trace (Trace mode)

Notes for reviewers

  • Why the call-site change is in core: getCallSourceFromStack is core's, and the service, Selenium and Nightwatch all inline it, so all three get a patch changeset. Nightwatch's change is test fixtures only. CommandLog.callSource was already optional, so the wire contract and the app are unchanged. The 'unknown:0' guards in trace-sources.ts and the backend's baseline attribution stay, because stored rows and zips from older adapters still carry the placeholder.
  • RetryTracker: with folding scoped to the wait, its bare-signature fold (isRetryIgnoringSource) had no user left and is removed.
  • Tests: each fix has regression tests that fail without it: a helper's wait cannot take over the user's row; a failed wait rejects with the same error, still raises unhandledRejection when unawaited, and keeps element chaining; source-mapped stacks, a host-ful file:// frame, the monorepo, an npm install and a user bundle; the first test starting before the globals poll. pnpm test 2840 passed, pnpm lint 0 errors, pnpm typecheck clean, and the dist leak grep is empty for selenium-devtools, nightwatch-devtools and service.
  • Real browser: not run yet. To check: pnpm build && pnpm --filter @wdio/selenium-devtools example:mocha, then open the trace; the heading tests show Element.getText() and the assert.equal rows, and every row's call site points into example.js. Pointing a selector at something missing shows the red wait row.
  • Docs: the website's Selenium page carries the same example.com / h1 snippet; a webdriverio docs PR updates it.

Screenshots / recordings

n/a (no UI change)

🤖 Generated with Claude Code

example.com dropped its <h1>, so the mocha example's
wait(until.elementLocated(By.css('h1'))) timed out and the getText()
and assert.equal after it never ran. Read the h2 of the-internet's
login page instead, which the other examples already drive, and update
the README snippet that carried the same selector.
…ne when absent

With source maps on (tsx, vitest) the adapters' own frames are named by
their source paths rather than dist/, so isUserCodeFrame accepted
getCallSourceFromStack's own frame and every row pointed at core's
stack.ts. Locate this monorepo's packages/ dir from the nearest
pnpm-workspace.yaml and treat its src/ files as library code; an
installed or user-bundled adapter finds no such workspace. Return no
call source instead of the 'unknown:0' placeholder, which read as a
call site, and keep a malformed frame from throwing.
…der --require

Under --require the plugin loads before mocha publishes its globals,
and the hooks were registered by a 100 ms poll, so a first test that
started sooner got no startTest and every command it ran was dropped.
Export mochaHooks, mocha's root hook plugin entry, which mocha reads
before loading any spec, and stop the globals poll once it is claimed.
A wait is not a captured command, so a TimeoutError had no row to
attach to: nothing red in the Actions list or the Errors tab. The
patched wait() now returns a promise derived from selenium's that
records the failure and rethrows it (re-wrapped in selenium's
WebElementPromise for an element wait), so rejection and
unhandledRejection behave exactly as before, and the failure turns the
wait's own poll row into a wait row named after its condition. A
wait's polls now fold only into rows of that same wait, never into an
earlier row with the same command and arguments.
@greptile-apps

greptile-apps Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; no new blocking issue was found.

Summary

The PR makes failed Selenium waits visible, keeps separate calls on separate rows, fixes source locations, and registers Mocha hooks before the first test.

  • A failed Selenium wait gets a named row in the trace.
  • Command call sites point to user code under source maps.
  • Mocha captures the first test when the plugin loads with --require.
  • The Selenium example reads the login page’s heading.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[driver.wait] --> B[Choose wait scope]
  B --> C[Run condition]
  C --> D[Match each call to its row]
  D --> E[Update only that wait's rows]
  C --> F{Wait fails?}
  F -->|Yes| G[Replace latest poll row with failed wait]
  G --> H[Rethrow original error]
Loading

Reviews (4) · Last reviewed commit: "fix(selenium-devtools): tell nested wait..." · Reviewed by Greptile

…ut of the retry tracker

Polls were keyed on command and args alone, so a condition reading
a.getText() and b.getText() folded the second read over the first.
Name each call at invocation as the nth call with that command, args
and target within one evaluation of the condition, which every poll
repeats in order; a timer poll has no call site to tell them apart.

Replacing an older poll row moved only the retry tracker's lastId,
leaving lastSig naming another command, so a later retry of that
command overwrote the wrong row. A wait's rows now never enter the
retry tracker and reset it, so no retry lands on one or folds across a
wait.
…er polls

A wait issued inside another wait's condition got a fresh scope on every
evaluation of the outer condition, so its polls never folded and each
outer poll added a row (and a trace snapshot). The nested wait now keeps
its scope on the outer wait, keyed by its order within one evaluation.

Core's toError no longer throws on a null-prototype value, so the wait
tracker uses it instead of a local copy; a condition description that
cannot be stringified still leaves the failed wait its row. The trace's
'unknown:0' guard goes, since nothing produces that placeholder now.
@vishnuv688

Copy link
Copy Markdown
Member Author

The new conformance rules in #466 guard this fix. With every source file this PR changes reverted, Selenium fails in all three BiDi modes: failed-test-has-error-row (the timed-out wait left no failed action), call-site-names-the-scenario (a poll row named unknown:0), and tests-end-as-designed / one-row-per-browser-action (the first test lost its rows). The other adapters stay green. With this PR restored, the suite passes 15/15.

A wait inside another wait's condition kept its scope by position alone,
so two different waits issued from different branches of the outer
condition on successive polls shared one row, and the second one's
failure replaced the first one's row. A nested wait is now keyed by its
call site, its condition's description and its condition's source, plus
its order within the evaluation: the same wait still folds across outer
polls, and two different waits keep their own rows.
@vishnuv688
vishnuv688 merged commit 1000160 into main Oct 27, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant