Skip to content

fix(nightwatch-devtools): key late row replaces by a per-capturer id - #473

Merged
vishnuv688 merged 3 commits into
mainfrom
fix/458-nightwatch-row-ids
Oct 27, 2026
Merged

vishnuv688 merged 3 commits into
mainfrom
fix/458-nightwatch-row-ids

Conversation

@vishnuv688

Copy link
Copy Markdown
Member

What & why

Closes #458.

A Nightwatch command's screenshot arrives after its row was sent, and attachScreenshot re-sends the row with sendReplaceCommand(timestamp, row). Nightwatch driver rows carried only the internal _id, so shared replaceCommandIndex fell back to the last row with that timestamp. When a node:assert row was stamped in the same millisecond as the read before it, the read's screenshot overwrote the assertion: the stream listed the read twice and lost the assertion (about 3 of 9 conformance runs).

Changes:

  • nightwatch-devtools/src/session.ts: captureCommand stamps a public id on driver rows (Selenium's rows and Nightwatch's own captureAssertCommand already did). replaceCommand (retry) keeps the replaced row's id, so the dashboard updates the row it already shows.
  • nightwatch-devtools/src/helpers/browserProxy.ts: a late screenshot for a row no longer in commandsLog (a retry superseded it) sends nothing. With the id kept across a retry this guard is required, or the stale row would replace its successor by id.
  • core/src/session-capturer.ts: the command counter starts at a random base per capturer instead of 0 (see cross-process finding below).
  • Conformance: both Nightwatch: a late screenshot replace can overwrite an assertion row stamped in the same millisecond #458 gaps removed (the agreeOnActions hunk gap and the failedTestExplained gap feat(conformance): judge failed tests, call sites and per-test rows #466 added), with the KNOWN_DEBT.nightwatchSameMsReplace entry and the CLAUDE.md Known debt bullet. features.ts now tests Nightwatch on failedTestExplained like the service and Selenium. agreement.test.ts used the gap as a fixture and now has its own fixture gap.

Cross-process ids

A reviewer asked whether an id from a per-process counter starting at 0 can collide with rows that survive from another process. It can:

  • Rerun from the dashboard. backend/src/index.ts deliberately keeps messageBuffer and baselineStore.activeRun when a rerun child connects (consumeRerunChildFlag), so Preserve & Rerun on another failed test still finds data. The child's capturer restarts its counter at 0, and baselineStore.#replaceCommand uses the same id-first replaceCommandIndex, so the child's replaceCommand for id N landed on the parent's row N in the accumulator that baselines and the backend trace export read. The app is not affected in this case because the run start broadcasts clearExecutionData with runStart, which wipes its rows.
  • Nightwatch test_workers (and Selenium jest/vitest workers). Each worker has its own capturer, and all of them stream into one dashboard with no clear between them, so id N from one worker matched another worker's row N in the app too.
  • The same reasoning is already in CLAUDE.md for the WDIO service: it folds by timestamp because "commandCounter resets per worker/spec, so a bare id collides across specs".

This collision already existed on main for every Selenium row and every Nightwatch assertion row; stamping ids on Nightwatch driver rows would have spread it further. So the counter now starts at randomInt(2 ** 32) * 2 ** 20 in SessionCapturerBase, which covers captureCommand, captureAssertCommand and Selenium's rows in one place. Each capturer gets a 2^20-id run, ids stay safe integers (below 2^52), and two capturers collide only if they pick the same base (1 in 2^32). _id is still local dedup bookkeeping and only needs to be unique within the capturer, which it is.

Tests

New nightwatch-devtools/tests/rowIds.test.ts drives a real SessionCapturer through BrowserProxy with Date.now frozen, and replays the sent frames through shared replaceCommand the way the app does:

  • same-millisecond getText + assertion: the late screenshot lands on the read and the assertion survives (fails without the fix: the read showed twice)
  • a retry keeps the row's id (fails without the id carry-over)
  • a superseded row's late screenshot sends nothing, and only the fresh screenshot shows (fails without the commandsLog guard)
  • two capturers (parent and rerun child) folded into one list: the child's late screenshot leaves the parent's row alone (fails with the counter at 0)

core/tests/session-capturer-base.test.ts gains "numbers each capturer apart" (fails with the counter at 0). Each failure was confirmed by temporarily reverting that part of the fix.

Conformance: npx vitest run packages/conformance/tests/conformance.test.ts ran 10 times after merging main with both gaps removed, 15/15 green every time. Before the fix, with the first gap removed, the first run failed in all three BiDi modes with devtools-service shows [Assert.equal #flash], nightwatch-devtools shows [Element.getText #flash].

pnpm build, pnpm test (3022 passed), pnpm lint (0 errors), pnpm typecheck and pnpm docs:capabilities --check all pass.

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)

Capability matrix

  • packages/conformance/src/features.ts updated, and tables regenerated with pnpm docs:capabilities, if this adds or changes user-facing behaviour (the Nightwatch cell gains the failedTestExplained rule; the rendered tables are unchanged)

Notes for reviewers

  • Not verified in a real browser. All evidence is unit tests and the conformance suite, which drives the adapters against a fake WebDriver. To check by hand: pnpm demo:nightwatch, then confirm each assertion row after a read keeps its own row and the read has its screenshot.
  • Selenium's dist changes only through the core counter base (its rows already carried ids), so the changeset bumps @wdio/selenium-devtools as a patch too. Its test that asserted the first _id was 0 now asserts consecutive ids.
  • The logic is in core because the counter is shared by every adapter's capturer; the Nightwatch-specific part (stamping and keeping the id, the screenshot guard) stays in the adapter.
  • Follow-ups, not done here: the WDIO service still folds matchers by timestamp, with a CLAUDE.md entry justifying it by the counter resetting per spec; with a per-capturer base that reason no longer holds, and the service could stamp ids too. The Python adapter's _command_counter in capturer.py also starts at 0 per process and has the same rerun exposure.

🤖 Generated with Claude Code

A command's screenshot arrives after its row was sent and is re-sent with
replaceCommand. Nightwatch driver rows carried no public id, so the shared
replace fell back to the last row with that timestamp, and an assertion
stamped in the same millisecond was overwritten: the read showed twice and
the assertion vanished (3 of 9 conformance runs).

Driver rows now carry an id, a retry keeps the id of the row it replaces,
and a screenshot for a row a retry has already superseded is dropped, since
it would otherwise bring the old row back under the shared id.

The id counter now starts at a random base per capturer. A rerun child's
rows join its parent's in the backend accumulator (rerun children keep
activeRun), and Nightwatch test_workers share one dashboard, so a counter
that restarts at 0 in every process would let one process's replace land
on another's row. That covers Selenium and Nightwatch's assertion rows,
which already stamped ids from the same counter.
The heading test's failure went unexplained only when its assertion row was
overwritten by the read's late screenshot, so with rows keyed by id the
gap no longer occurs and Nightwatch is tested on that rule like the others.
@vishnuv688 vishnuv688 self-assigned this Oct 27, 2026
@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 actionable issues were found.

Summary

Nightwatch rows now carry public ids, and retries keep the id of the row they replace.

  • Late screenshots and retries update the command row they belong to.

No actionable issues were found. This review checked code paths; it did not run tests or a browser.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Capture command with public id] --> B[Send row]
  B --> C{Command retried?}
  C -->|Yes| D[Store new row with same public id]
  D --> E[Replace dashboard row by id]
  B --> F[Late screenshot arrives]
  F --> G{Original object still stored?}
  G -->|Yes| H[Update its row by id]
  G -->|No| I[Drop stale screenshot]
Loading

Reviews (1) · Last reviewed commit: "Drop the failed-test conformance gap tha..." · Reviewed by Greptile

@vishnuv688
vishnuv688 merged commit 008d7f1 into main Oct 27, 2026
12 checks passed
vishnuv688 added a commit that referenced this pull request Oct 27, 2026
#473 fixed Nightwatch's failed-test gap and this branch fixes Python's,
so every adapter is now tested on failed-test-has-error-row: the cell is
allTested(FAILED_TEST_RULES), defined once. With both gaps gone the
failureUnexplained matcher has no user and is deleted.
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.

Nightwatch: a late screenshot replace can overwrite an assertion row stamped in the same millisecond

1 participant