Skip to content

Waits render differently across adapters (no row, a condition row, or the reads) #464

Closed

Description

@vishnuv688

What happens

The same wait renders differently depending on the adapter, so the Actions list and traces from two frameworks cannot be read the same way. Found while extending the conformance scenarios (#455).

Expected

One vocabulary for waits across adapters: a wait (element or condition) is one row, named after what it waited for, carrying the error when it times out, whether it succeeded or not. Whether the reads a condition issues are shown inside it should be the same decision in every adapter.

Where to look

Selenium: packages/selenium-devtools/src/wait-tracking.ts (it already observes every wait(); a successful one could emit its row too). Python: WebDriverWait.until / until_not (see #461). WDIO: how the service folds a waitUntil condition's reads. The conformance suite records the current differences as Known debt, so a fix makes those gaps go stale.

Activity

  1. self-assigned this
    on Oct 27, 2026
  2. vishnuv688 commented on Oct 28, 2026

    @vishnuv688
    MemberAuthor

    Decision record: one wait row, its commands nested under it

    Phase 1 (the shared foundation) is in #486. It changes nothing a user sees yet: no adapter sends the new field, and a stream or trace without it renders and exports exactly as before (pinned by a golden test).

    The design, as implemented

    Wire. CommandLog.parentId?: number (packages/shared/src/types.ts) is the public id of the row whose execution issued this one. It is generic on purpose; a wait is the only parent today. A parent always carries a numeric id (core's per-capturer random-base counter keeps ids unique across processes). Generated into wire-schema.json and the Python _wire_types.py.

    Lifecycle. A wait row is sent when the wait starts (a startTime, no result, no error) and replaced by id when it ends, stamped at completion like every other row and carrying the timeout as error. The app has no explicit "running" state to reuse: Nightwatch's pending assert rows are simply rows with no result and no error, rendered neutral, and a running wait is the same. So no new field was added for it. Children are sent after their parent exists, so a live list can nest them on arrival.

    Children. Every command the wait's condition issued carries the wait's id as parentId, folded per distinct call as adapters already fold polls. A built-in element wait's polls are children too (one rule). Unmapped children (findElement(s)) stay out of the trace as before, but still count as children live.

    Naming. An element-locator condition is Element.waitForExist / Element.waitForDisplayed on its selector; any other condition is Browser.waitForFunction named after the condition. Checked against ACTION_MAP: waitForExist, waitForDisplayed, waitUntil, Nightwatch's waitForElementPresent/waitForElementVisible and Selenium/Python's wait (now any non-locator condition) were already mapped. Added one command name, waitForElementLocated -> Element.waitForExist, for a Selenium/Python wait on until.elementLocated / presence_of_element_located. A visibility wait can use the existing waitForElementVisible -> Element.waitForDisplayed.

    Shared helper. nestCommands(rows) (packages/shared/src/command-tree.ts) turns a flat list into a parent -> children tree in list order. A row whose parent is absent (sent later, never sent) stays at the top level, so a list rebuilt on every update nests it once the parent arrives; a parent replaced by id keeps its children; a cycle is rooted rather than lost. commandDescendants(node) lists everything under a node.

    Trace. buildActionEvents (packages/trace/src/trace-action-events.ts) writes the parent's before, then each child's action with parentId = the parent's callId, then the parent's after, closing no earlier than its last child, so the player draws the children inside the parent's span. A row the vocabulary drops passes its children up a level. The player model gains TraceActionCommandNode { commandIndex, children? } (packages/shared/src/trace-player.ts). The reader (packages/backend/src/trace-reader-groups.ts) keeps an action in our vocabulary that other actions name as parentId as a command row with children instead of turning it into a structural step, so foreign runner steps are unaffected; a zip without parented actions or groups still returns no tree. A child's error does not roll up into a test group's failed (the wait decides whether it failed).

    App. Both the live list (packages/app/src/components/workbench/live-action-rows.ts) and the player tree (action-tree.ts) render a parent row with a chevron and its children indented under it, collapsed by default, open when the parent failed or holds the active row. A user toggle wins. Selecting a nested row dispatches the same show-command a top-level row does, so the snapshot and DOM replay follow it unchanged.

    Conformance. normalize.ts nests child steps under their parent (NormalizedStep.children); agreement.ts compares parents as steps and the children of each aligned pair as their own aligned sequence under it (..., <parent>, inside it after <step>: ...). A new rule wait-row-parents-its-commands (packages/conformance/src/wait-nesting.ts) checks: each scenario test shows exactly the waits it writes (SCENARIO_WAITS); each wait row has an id and a startTime and is first sent running; every row that ran inside a wait's span in the same test names that wait as parentId; every parentId names a row sent before it.

    I chose scoped KNOWN_GAPS over a switch: the rule runs on every adapter today, and each adapter has one gap per mode against it whose reason states what it lacks. A gap that stops matching is reported stale and fails the run, so each phase-2 PR is forced to delete its own gap, and a regression after that is caught at once. A switch would have left all four unchecked until someone flipped it. Today's violations, identical in all three BiDi modes:

    • WDIO service: its four wait rows carry no id (and its waitUntil hides the reads).
    • Nightwatch: the three reads inside its two waitUntil rows name no parent (and the wait row is sent after them).
    • Selenium and Python: no row for a passing wait, and the timed-out one is Browser.waitForFunction rather than Element.waitForExist #missing.

    Phase 2: what each adapter must do

    Each PR deletes its adapter's waitRowGaps entry in packages/conformance/src/allowlist.ts (staleness forces it) and keeps pnpm test green.

    WDIO service (packages/service/src/index.ts, beforeCommand/afterCommand/#commandStack):

    • On beforeCommand of a wait (waitUntil, waitForExist, waitForDisplayed, waitForEnabled, waitForClickable), send the running row with a fresh id (the service stamps no ids today; take one from the capturer's counter) and push it as the open parent.
    • Stop hiding the commands issued under a wait: every command while a wait is open (the condition's $/getText, the element wait's findElement/isExisting polls) is sent with parentId = the open wait's id, folded per distinct call (reuse core's RetryTracker/fold rather than a new one).
    • On afterCommand of the wait, replaceCommand by id with the completion stamp and error. Keep the expect-webdriverio fold (coalesceAssertionIntoLastRead) working: a matcher's read is not a wait child.

    Nightwatch (packages/nightwatch-devtools/src/helpers/browserProxy.ts, session.ts):

    • At invocation of waitUntil and the waitForElement* family, captureCommand a running row with an id and sendCommand it before the queue runs the condition; finalize with sendReplaceCommand at the callback (the pattern pendingAssertionCommand + captureAssertCommand already uses).
    • Track the open wait while its condition runs (an AsyncLocalStorage or a stack keyed to Nightwatch's queue node) and stamp parentId on every command captured inside it.

    Selenium JS (packages/selenium-devtools/src/wait-tracking.ts, driverPatcher.ts, session.ts):

    • trackWaits: emit the wait row at call time (not only in waitFailure), with an id, named waitForElementLocated + selector for until.elementLocated (read the locator off the condition) and wait + the condition label otherwise; replace it by id when the promise settles, with the error on rejection.
    • Stamp parentId = that id on every command captured with this WaitScope (CapturedCommand.waitScope already identifies them); stop replacing the latest poll row with the wait row (concludesWait / WaitScope.pollRowId), since the poll is now a child.

    Python (packages/selenium-devtools-py/src/selenium_devtools/wait_tracking.py, instrumentation.py, capturer.py):

    • In the patched WebDriverWait.until/until_not, send the running wait row with an id before the first poll, named waitForElementLocated + selector for presence_of_element_located (the locator is in the closure, as condition_label already reads it) and wait otherwise; send_replace_command by id when it returns or raises.
    • poll_of / note_poll_row: stamp parentId on every row captured inside the wait. Add a mirror-vectors.json section for the wait naming (condition -> command, selector) shared with the JS side.

    Each adapter also updates its example smoke expectation (packages/conformance/smoke/expectations/*.json) if its rows change.

    Phase 3: what goes

    • packages/conformance/src/allowlist.ts: nightwatchGaps, seleniumGaps, pythonGaps (the condition-wait hunks, the FLASH_WAIT lacks, the failed-wait hunks) and any remaining waitRowGaps; KNOWN_DEBT.conditionWait, seleniumWait, seleniumFailedWait, pythonWait, pythonFailedWait and the four *WaitRows entries in constants.ts.
    • INTERNAL_RULES[wait-row-parents-its-commands] in capabilities.ts, replaced by a "Waits render as one row" feature in features.ts backed by the rule, then pnpm docs:capabilities (README) and --website (docs site).
    • CLAUDE.md Known debt: the three Waits render differently across adapters (no row, a condition row, or the reads) #464 entries under "What the conformance scenario shows the adapters doing differently", and the wait() entry's "A wait that rejects is a row, and it is the polls' row" paragraph, rewritten to the shipped behaviour; docs/ADAPTERS.md's "No adapter emits this shape yet" sentence.
    • User docs for how a wait renders, in README and the webdriverio devtools pages.
  3. added a commit that references this issue on Oct 29, 2026
  4. vishnuv688 commented on Oct 29, 2026

    @vishnuv688
    MemberAuthor

    Done in #486, which includes the four adapter PRs #487 (Python), #488 (Selenium), #489 (WebdriverIO service) and #490 (Nightwatch). A wait is one row in every adapter, named after what it waited for, with the commands its condition issued nested under it, collapsed by default and opened when the wait failed or holds the selected row, in the live dashboard and the trace player. A poll a passing wait retried past is not a failure. The conformance suite checks it through wait-row-parents-its-commands, and the capability table lists it as a feature. One documented difference remains: Nightwatch's waitForElement* rows have no children, because Nightwatch polls for the element through its own transport.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions