You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Waits render differently across adapters (no row, a condition row, or the reads) #464
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).
Element wait that succeeds: WDIO (waitForExist) and Nightwatch (waitForElementPresent) show one wait row. Selenium's driver.wait(until.elementLocated(...)) and Python's WebDriverWait show none: their polls are unmapped findElements calls.
Wait on a condition that reads elements: WDIO's waitUntil shows one Browser.waitForFunction row and hides the reads inside it; Selenium and Python show only the reads (one row per read, folded across polls); Nightwatch shows both the reads and the wait row.
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.
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.
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.
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.
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.
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.
Activity
vishnuv688 commentedon Oct 28, 2026
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 publicidof the row whose execution issued this one. It is generic on purpose; a wait is the only parent today. A parent always carries a numericid(core's per-capturer random-base counter keeps ids unique across processes). Generated intowire-schema.jsonand the Python_wire_types.py.Lifecycle. A wait row is sent when the wait starts (a
startTime, no result, no error) and replaced byidwhen it ends, stamped at completion like every other row and carrying the timeout aserror. 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
idasparentId, 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.waitForDisplayedon its selector; any other condition isBrowser.waitForFunctionnamed after the condition. Checked againstACTION_MAP:waitForExist,waitForDisplayed,waitUntil, Nightwatch'swaitForElementPresent/waitForElementVisibleand Selenium/Python'swait(now any non-locator condition) were already mapped. Added one command name,waitForElementLocated->Element.waitForExist, for a Selenium/Python wait onuntil.elementLocated/presence_of_element_located. A visibility wait can use the existingwaitForElementVisible->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 byidkeeps 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'sbefore, then each child's action withparentId= the parent's callId, then the parent'safter, 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 gainsTraceActionCommandNode { 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 asparentIdas 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'sfailed(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 sameshow-commanda top-level row does, so the snapshot and DOM replay follow it unchanged.Conformance.
normalize.tsnests child steps under their parent (NormalizedStep.children);agreement.tscompares parents as steps and the children of each aligned pair as their own aligned sequence under it (..., <parent>, inside it after <step>: ...). A new rulewait-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 anidand astartTimeand is first sent running; every row that ran inside a wait's span in the same test names that wait asparentId; everyparentIdnames a row sent before it.I chose scoped
KNOWN_GAPSover 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:id(and itswaitUntilhides the reads).waitUntilrows name no parent (and the wait row is sent after them).Browser.waitForFunctionrather thanElement.waitForExist #missing.Phase 2: what each adapter must do
Each PR deletes its adapter's
waitRowGapsentry inpackages/conformance/src/allowlist.ts(staleness forces it) and keepspnpm testgreen.WDIO service (
packages/service/src/index.ts,beforeCommand/afterCommand/#commandStack):beforeCommandof 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.$/getText, the element wait'sfindElement/isExistingpolls) is sent withparentId= the open wait's id, folded per distinct call (reuse core'sRetryTracker/fold rather than a new one).afterCommandof the wait,replaceCommandby 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):waitUntiland thewaitForElement*family,captureCommanda running row with an id andsendCommandit before the queue runs the condition; finalize withsendReplaceCommandat the callback (the patternpendingAssertionCommand+captureAssertCommandalready uses).AsyncLocalStorageor a stack keyed to Nightwatch's queue node) and stampparentIdon 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 inwaitFailure), with an id, namedwaitForElementLocated+selectorforuntil.elementLocated(read the locator off the condition) andwait+ the condition label otherwise; replace it by id when the promise settles, with the error on rejection.parentId= that id on every command captured with thisWaitScope(CapturedCommand.waitScopealready 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):WebDriverWait.until/until_not, send the running wait row with an id before the first poll, namedwaitForElementLocated+selectorforpresence_of_element_located(the locator is in the closure, ascondition_labelalready reads it) andwaitotherwise;send_replace_commandby id when it returns or raises.poll_of/note_poll_row: stampparentIdon every row captured inside the wait. Add amirror-vectors.jsonsection 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, theFLASH_WAITlacks, the failed-wait hunks) and any remainingwaitRowGaps;KNOWN_DEBT.conditionWait,seleniumWait,seleniumFailedWait,pythonWait,pythonFailedWaitand the four*WaitRowsentries inconstants.ts.INTERNAL_RULES[wait-row-parents-its-commands]incapabilities.ts, replaced by a "Waits render as one row" feature infeatures.tsbacked by the rule, thenpnpm docs:capabilities(README) and--website(docs site).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.Merge pull request #486 from webdriverio/feat/464-nested-waits
vishnuv688 commentedon Oct 29, 2026
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'swaitForElement*rows have no children, because Nightwatch polls for the element through its own transport.Give a row a parent, so a wait can hold what it issued
Send a Nightwatch wait as one row that parents what it issued
Send a Selenium wait as one row that parents its polls