From 8b557684ead6a14799ecaac9a27a057f1ebbfe09 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Wed, 5 Aug 2026 17:17:51 +0530 Subject: [PATCH 01/45] fix(core): timeout-guard every per-action snapshot probe --- packages/core/src/action-snapshot.ts | 141 +++++++++++++------- packages/core/src/with-timeout.ts | 6 + packages/core/tests/action-snapshot.test.ts | 100 ++++++++++++++ 3 files changed, 202 insertions(+), 45 deletions(-) create mode 100644 packages/core/tests/action-snapshot.test.ts diff --git a/packages/core/src/action-snapshot.ts b/packages/core/src/action-snapshot.ts index e589f743..eea67b3a 100644 --- a/packages/core/src/action-snapshot.ts +++ b/packages/core/src/action-snapshot.ts @@ -13,13 +13,21 @@ import { generateAllElementLocators, getDefaultFilters } from './locators/index.js' -import { SNAPSHOT_PROBE_TIMEOUT_MS, withTimeout } from './with-timeout.js' +import { + SNAPSHOT_DRIVER_PROBE_TIMEOUT_MS, + SNAPSHOT_PROBE_TIMEOUT_MS, + withTimeout +} from './with-timeout.js' import type { ActionSnapshot } from '@wdio/devtools-shared' export type ScriptRunner = (scriptSrc: string) => Promise export interface CaptureActionSnapshotInput { command: string + /** The logged command's timestamp. Export claims snapshots by exact equality + * (`claimAfter`/`elementsAt`) and slices them by command time, so a snapshot + * stamped with its own capture time binds to no action at all. */ + timestamp?: number /** Browser script runner — omit on native mobile where Appium can't execute JS. */ runScript?: ScriptRunner takeScreenshot?: () => Promise @@ -41,26 +49,95 @@ async function runWith( } return withTimeout( - runScript(scriptSrc).then((r) => r as T), + // A driver can answer `null` rather than reject (no-such-session, a script + // error swallowed by the transport). Passing that through hands the + // serializers a non-array where they expect one, which throws and loses the + // WHOLE snapshot — screenshot, url and all — not just the probe. + runScript(scriptSrc).then((r) => + r === null || r === undefined ? fallback : (r as T) + ), SNAPSHOT_PROBE_TIMEOUT_MS, fallback ).catch(() => fallback) } +/** + * Run one driver-side probe under a timeout, resolving to `undefined` on a + * timeout, a rejection, or a synchronous throw. + * + * Every probe is guarded, not just the in-page scripts: a framework whose + * url/title/screenshot readers go through its own command queue blocks + * indefinitely when called from inside a command hook, and an unguarded probe + * in this `Promise.all` stranded the entire capture — observed as 10 of 14 + * Nightwatch captures never settling, so those actions reached the trace with + * no DOM, no a11y tree and no element rects at all. + */ +function probe( + read: (() => Promise) | undefined +): Promise { + if (!read) { + return Promise.resolve(undefined) + } + // `Promise.resolve().then(read)` so a probe that throws synchronously becomes + // a rejection this catch can absorb, rather than escaping the Promise.all. + return withTimeout( + Promise.resolve() + .then(read) + .then((value) => value ?? undefined), + SNAPSHOT_DRIVER_PROBE_TIMEOUT_MS, + undefined + ).catch(() => undefined) +} + +/** Native-mobile snapshot text + locators, derived from the page-source XML. */ +function fromPageSource( + pageSource: string, + platform: 'android' | 'ios' +): { snapshotText: string; elements: unknown[] } { + const jsonTree = xmlToJSON(pageSource) + let snapshotText = `[${platform}]` + if (jsonTree) { + jsonTree.attributes._sourceXML = pageSource + snapshotText = serializeMobileSnapshot(jsonTree, { + platform, + sourceXML: pageSource + }) + } + try { + const filters = getDefaultFilters(platform, false) + return { + snapshotText, + elements: generateAllElementLocators(pageSource, { + platform, + viewportSize: { width: 9999, height: 9999 }, + filters, + inViewportOnly: false + }) + } + } catch { + // Non-fatal — snapshot text is the primary deliverable. + return { snapshotText, elements: [] } + } +} + export async function captureActionSnapshot( input: CaptureActionSnapshotInput ): Promise { try { - const timestamp = Date.now() + const timestamp = input.timestamp ?? Date.now() const isNativeMobile = !input.runScript && !!input.getPageSource - const [shot, url, title, pageSource, tree, elements] = await Promise.all([ - input.takeScreenshot?.().catch(() => null), - input.getUrl?.().catch(() => undefined), - input.getTitle?.().catch(() => undefined), - isNativeMobile - ? input.getPageSource?.().catch(() => undefined) - : undefined, + // Probe order is load-bearing, not cosmetic. A driver serialises requests + // per session, so `Promise.all` starting them together still has them served + // in the order issued — and the screenshot is by far the slowest. Issued + // first, it delayed the cheap reads behind it by hundreds of ms, long enough + // for the next command to navigate: a fill on the login page reported the + // page its submit had already reached. Cheapest-and-most-order-sensitive + // first, screenshot last; the screenshot is served no later than before. + const [url, title, pageSource, tree, elements, shot] = await Promise.all([ + probe(input.getUrl), + probe(input.getTitle), + isNativeMobile ? probe(input.getPageSource) : undefined, runWith( input.runScript, accessibilityTreeScript(true), @@ -71,41 +148,14 @@ export async function captureActionSnapshot( // includeBounds: the per-action element rects drive A8 input points. elementsScript(true, true), [] - ) + ), + probe(input.takeScreenshot) ]) - let snapshotText: string - let finalElements: unknown[] = elements - - if (isNativeMobile && pageSource) { - const platform = input.platform ?? 'android' - const jsonTree = xmlToJSON(pageSource) - if (jsonTree) { - jsonTree.attributes._sourceXML = pageSource - snapshotText = serializeMobileSnapshot(jsonTree, { - platform, - sourceXML: pageSource - }) - } else { - snapshotText = `[${platform}]` - } - // Generate mobile element locators from the page source XML. - try { - const viewport = { width: 9999, height: 9999 } - const filters = getDefaultFilters(platform, false) - const locators = generateAllElementLocators(pageSource, { - platform, - viewportSize: viewport, - filters, - inViewportOnly: false - }) - finalElements = locators - } catch { - // Non-fatal — snapshot text is the primary deliverable. - } - } else { - snapshotText = serializeWebSnapshot(tree, { url, title }) - } + const mobile = + isNativeMobile && pageSource + ? fromPageSource(pageSource, input.platform ?? 'android') + : undefined return { timestamp, @@ -113,8 +163,9 @@ export async function captureActionSnapshot( url, title, screenshot: shot ?? undefined, - elements: finalElements, - snapshotText + elements: mobile?.elements ?? elements, + snapshotText: + mobile?.snapshotText ?? serializeWebSnapshot(tree, { url, title }) } } catch { return null diff --git a/packages/core/src/with-timeout.ts b/packages/core/src/with-timeout.ts index feb840e2..d89b9c05 100644 --- a/packages/core/src/with-timeout.ts +++ b/packages/core/src/with-timeout.ts @@ -24,3 +24,9 @@ export function withTimeout( /** Default ceiling for a single in-page snapshot probe. */ export const SNAPSHOT_PROBE_TIMEOUT_MS = 2500 + +/** Ceiling for a driver round-trip probe (screenshot, url, title). Larger than + * the in-page ceiling because encoding a full-page screenshot legitimately + * takes longer than reading the DOM, but still inside the adapters' 5s + * settle window so a hung probe can't strand the whole capture. */ +export const SNAPSHOT_DRIVER_PROBE_TIMEOUT_MS = 4000 diff --git a/packages/core/tests/action-snapshot.test.ts b/packages/core/tests/action-snapshot.test.ts new file mode 100644 index 00000000..e5c1e514 --- /dev/null +++ b/packages/core/tests/action-snapshot.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi } from 'vitest' +import { captureActionSnapshot } from '../src/action-snapshot.js' +import { SNAPSHOT_DRIVER_PROBE_TIMEOUT_MS } from '../src/with-timeout.js' + +const okScript = () => Promise.resolve([]) + +describe('captureActionSnapshot timestamp', () => { + it('stamps the caller-supplied command timestamp', async () => { + // Export claims snapshots by exact equality with the command timestamp, so + // the caller — not the capture — owns the stamp. + const snap = await captureActionSnapshot({ + command: 'click', + timestamp: 4242, + runScript: okScript, + takeScreenshot: () => Promise.resolve('AA') + }) + expect(snap?.timestamp).toBe(4242) + }) + + it('falls back to capture time when no timestamp is given', async () => { + const before = Date.now() + const snap = await captureActionSnapshot({ + command: 'click', + runScript: okScript + }) + const after = Date.now() + expect(snap?.timestamp).toBeGreaterThanOrEqual(before) + expect(snap?.timestamp).toBeLessThanOrEqual(after) + }) + + it('honours the supplied timestamp on the native-mobile path', async () => { + // No runScript + a page source is what selects the mobile branch; the stamp + // must survive the different snapshot-text path. + const snap = await captureActionSnapshot({ + command: 'click', + timestamp: 777, + getPageSource: () => Promise.resolve(''), + platform: 'android' + }) + expect(snap?.timestamp).toBe(777) + }) + + it('stamps a zero timestamp rather than treating it as absent', async () => { + const snap = await captureActionSnapshot({ + command: 'click', + timestamp: 0, + runScript: okScript + }) + expect(snap?.timestamp).toBe(0) + }) +}) + +describe('captureActionSnapshot probe isolation', () => { + it('still resolves when a driver probe never settles', async () => { + // Nightwatch's url/title readers are QUEUED commands: called from inside the + // plugin's own command hook they enqueue behind the running command and can + // never resolve. Unguarded, one of them stranded the whole capture and the + // action reached the trace with no DOM at all. + vi.useFakeTimers() + const capture = captureActionSnapshot({ + command: 'click', + timestamp: 10, + runScript: okScript, + getUrl: () => new Promise(() => {}), + takeScreenshot: () => Promise.resolve('AA') + }) + await vi.advanceTimersByTimeAsync(SNAPSHOT_DRIVER_PROBE_TIMEOUT_MS + 1) + const snap = await capture + vi.useRealTimers() + expect(snap?.timestamp).toBe(10) + expect(snap?.url).toBeUndefined() + expect(snap?.screenshot).toBe('AA') + }) + + it('absorbs a probe that throws synchronously', async () => { + const snap = await captureActionSnapshot({ + command: 'click', + timestamp: 11, + runScript: okScript, + getTitle: () => { + throw new Error('no such session') + }, + takeScreenshot: () => Promise.resolve('BB') + }) + expect(snap?.title).toBeUndefined() + expect(snap?.screenshot).toBe('BB') + }) + + it('absorbs a rejecting screenshot without losing the rest', async () => { + const snap = await captureActionSnapshot({ + command: 'click', + timestamp: 12, + runScript: okScript, + getUrl: () => Promise.resolve('https://example.com/x'), + takeScreenshot: () => Promise.reject(new Error('boom')) + }) + expect(snap?.screenshot).toBeUndefined() + expect(snap?.url).toBe('https://example.com/x') + }) +}) From f96e8a4892ccc024d7937be467b591514cd58407 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Wed, 5 Aug 2026 17:18:05 +0530 Subject: [PATCH 02/45] fix: stamp the DOM anchor with the document's birth time --- packages/core/src/session-capturer.ts | 41 +++++++++- packages/core/src/trace-mutations.ts | 46 +++++++++++ .../core/tests/session-capturer-base.test.ts | 63 +++++++++++++++ packages/core/tests/trace-mutations.test.ts | 79 ++++++++++++++++++- packages/script/src/collector.ts | 42 +++++++++- 5 files changed, 264 insertions(+), 7 deletions(-) diff --git a/packages/core/src/session-capturer.ts b/packages/core/src/session-capturer.ts index d20416e3..56e711ef 100644 --- a/packages/core/src/session-capturer.ts +++ b/packages/core/src/session-capturer.ts @@ -13,6 +13,7 @@ import type { import { WORKER_WS_QUERY, WS_PATHS, WS_SCOPE } from '@wdio/devtools-shared' import { mapCommandToAction } from './action-mapping.js' import { resolveRunId } from './run-id.js' +import { reattributeDomAnchors } from './trace-mutations.js' import { CONSOLE_METHODS, LOG_SOURCES, @@ -275,6 +276,36 @@ export abstract class SessionCapturerBase { // no-op — service silently swallows; subclasses can opt into a log line. } + /** + * The action a document born at `time` belongs to, or undefined when the + * anchor already sits where it should and must be left alone. + * + * Derived from the anchor rather than from the draining call, because the + * post-command hooks fire at command START in some adapters: a navigation's + * own drain then runs against the document it is leaving, and whichever later + * drain does see the destination would otherwise be credited with it. + * + * Answers only when NO logged command completed after `time`. A command that + * completed after the document was born is the one that navigated there, and + * its own row already resolves the anchor — pulling the anchor earlier then + * mis-credits it to a preceding action and steals the DOM from rows that were + * genuinely still on the previous page. That is the whole difference between + * an adapter that stamps commands at invocation and one that stamps them at + * completion, so it stays a property of the data rather than a per-adapter flag. + */ + protected anchorOwnerTimestamp(time: number): number | undefined { + let best: number | undefined + for (const cmd of this.commandsLog) { + if (cmd.timestamp > time) { + return undefined + } + if (best === undefined || cmd.timestamp > best) { + best = cmd.timestamp + } + } + return best + } + /** * Ingest the `{ mutations, traceLogs, consoleLogs, networkRequests, metadata }` * payload returned by the page-side `wdioTraceCollector.getTraceData()`. @@ -333,8 +364,14 @@ export abstract class SessionCapturerBase { } if (Array.isArray(mutations) && mutations.length > 0) { - this.mutations.push(...mutations) - this.sendUpstream('mutations', mutations) + const batch = mutations as TraceMutation[] + reattributeDomAnchors( + batch, + (anchorOwnTime) => this.anchorOwnerTimestamp(anchorOwnTime), + this.mutations[this.mutations.length - 1]?.timestamp ?? 0 + ) + this.mutations.push(...batch) + this.sendUpstream('mutations', batch) } if (Array.isArray(traceLogs) && traceLogs.length > 0) { diff --git a/packages/core/src/trace-mutations.ts b/packages/core/src/trace-mutations.ts index d63ce094..aaf94a8b 100644 --- a/packages/core/src/trace-mutations.ts +++ b/packages/core/src/trace-mutations.ts @@ -14,6 +14,52 @@ import type { * on mutation-heavy SPAs. Late mutations drop first (replay-from-start holds). */ export const MAX_MUTATIONS_NDJSON_BYTES = 50 * 1024 * 1024 +/** A full-document anchor rather than an observed diff. `url` is the reliable + * discriminator: only the collector's `captureCurrentDom` sets it, and neither + * the MutationObserver serializer nor the synthetic field-state records do. */ +function isDomAnchor(mutation: TraceMutation): boolean { + return mutation.type === 'childList' && mutation.url !== undefined +} + +/** + * Re-stamp the full-document anchors in a freshly drained batch onto the action + * that produced the document. + * + * An anchor carries the document's own birth time, which lands a few ms *after* + * the command that navigated there — so the navigating action's row, which ends + * the moment the command was logged, still resolves to the PREVIOUS page's DOM. + * + * `resolve` maps an anchor's own timestamp to the action it belongs to. Deriving + * it from the anchor rather than from whichever drain collected it is what makes + * this race-free: several drains compete for a fresh collector's buffer (the + * first `getTraceData` empties it) and the winner is routinely a later command's + * post-DOM drain, so trusting the collecting drain mis-credited the anchor. + * + * `floor` is the newest timestamp already in the accumulated stream. An anchor is + * never pulled ahead of mutations belonging to the document it replaces — replay + * would then apply those stale refs to the new tree — and never pushed later than + * the page stamped it, so an overshooting attribution is simply ignored. + */ +export function reattributeDomAnchors( + batch: TraceMutation[], + resolve: (anchorOwnTime: number) => number | undefined, + floor = 0 +): void { + let lowerBound = floor + for (const mutation of batch) { + if (isDomAnchor(mutation)) { + const attributed = resolve(mutation.timestamp) + if (attributed !== undefined) { + const target = Math.max(attributed, lowerBound) + if (target < mutation.timestamp) { + mutation.timestamp = target + } + } + } + lowerBound = Math.max(lowerBound, mutation.timestamp) + } +} + export interface MutationsNdjsonResult { /** NDJSON payload (one mutation per line, optional trailing marker). Empty * buffer when there are no mutations. */ diff --git a/packages/core/tests/session-capturer-base.test.ts b/packages/core/tests/session-capturer-base.test.ts index 668b6996..5207f1a3 100644 --- a/packages/core/tests/session-capturer-base.test.ts +++ b/packages/core/tests/session-capturer-base.test.ts @@ -107,6 +107,69 @@ describe('processTracePayload — mutations + traceLogs', () => { }) }) +describe('processTracePayload — DOM anchor attribution', () => { + const anchor = (timestamp: number, url = 'https://example.com/next') => ({ + type: 'childList', + url, + addedNodes: [{ tag: 'html' }], + removedNodes: [], + timestamp + }) + const cmd = (timestamp: number) => ({ command: 'click', args: [], timestamp }) + + it('pulls the anchor onto the navigation still in flight', () => { + // Adapter stamps commands at invocation, so the navigate's row ends before + // the destination document exists and would replay the page it left. + cap.commandsLog.push(cmd(1000)) + cap.process({ mutations: [anchor(1250)] }) + expect(cap.mutations[0]!.timestamp).toBe(1000) + }) + + it('leaves the anchor alone once a command has completed after it', () => { + // Adapter stamps commands at completion: the navigate finished after the + // document was born, so its row already resolves this anchor. Pulling it + // back would hand the new page's DOM to actions still on the old one. + cap.commandsLog.push(cmd(1000), cmd(1400)) + cap.process({ mutations: [anchor(1250)] }) + expect(cap.mutations[0]!.timestamp).toBe(1250) + }) + + it('leaves the anchor alone when every command completed after it', () => { + cap.commandsLog.push(cmd(1400)) + cap.process({ mutations: [anchor(1250)] }) + expect(cap.mutations[0]!.timestamp).toBe(1250) + }) + + it('never pulls an anchor ahead of the document it replaces', () => { + cap.mutations.push({ + type: 'attributes', + target: '7', + addedNodes: [], + removedNodes: [], + timestamp: 1100 + }) + cap.commandsLog.push(cmd(1000)) + cap.process({ mutations: [anchor(1250)] }) + expect(cap.mutations[1]!.timestamp).toBe(1100) + }) + + it('leaves observed diffs untouched', () => { + cap.commandsLog.push(cmd(1000)) + cap.process({ + mutations: [ + { + type: 'attributes', + target: '35', + addedNodes: [], + removedNodes: [], + timestamp: 1250 + } + ] + }) + expect(cap.mutations[0]!.timestamp).toBe(1250) + }) +}) + describe('captureSource', () => { it('caches by file path — second read is a no-op', async () => { const filePath = new URL(import.meta.url).pathname diff --git a/packages/core/tests/trace-mutations.test.ts b/packages/core/tests/trace-mutations.test.ts index 9dfd5bdd..fdbf6141 100644 --- a/packages/core/tests/trace-mutations.test.ts +++ b/packages/core/tests/trace-mutations.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from 'vitest' -import { buildMutationsNdjson } from '@wdio/devtools-core' +import { + buildMutationsNdjson, + reattributeDomAnchors +} from '@wdio/devtools-core' import { isMutationsTruncationMarker, type TraceMutation @@ -85,3 +88,77 @@ describe('isMutationsTruncationMarker', () => { expect(isMutationsTruncationMarker('x')).toBe(false) }) }) + +describe('reattributeDomAnchors', () => { + const anchor = (timestamp: number, url = 'https://example.com/a') => + mutation({ + type: 'childList', + url, + addedNodes: [{ tag: 'html' }], + timestamp + }) + const at = (ts: number) => () => ts + + it('pulls a drain-stamped anchor back to the command that produced it', () => { + const batch = [anchor(9625)] + reattributeDomAnchors(batch, at(8568), 8566) + expect(batch[0]!.timestamp).toBe(8568) + }) + + it("resolves the action from the anchor's own time, not the draining call", () => { + // The navigation's own drain runs against the page it is leaving, so a later + // command's drain collects the anchor — the resolver must key on the anchor. + const seen: number[] = [] + const batch = [anchor(7860)] + reattributeDomAnchors(batch, (t) => { + seen.push(t) + return 7858 + }) + expect(seen).toEqual([7860]) + expect(batch[0]!.timestamp).toBe(7858) + }) + + it('leaves observed diffs alone — only full-document anchors move', () => { + const diff = mutation({ type: 'attributes', target: '35', timestamp: 8600 }) + reattributeDomAnchors([diff], at(100), 0) + expect(diff.timestamp).toBe(8600) + }) + + it('never pulls an anchor ahead of the document it replaces', () => { + // The prior page's field edits are already at 5131; replay would apply those + // stale refs to the new tree if the anchor landed before them. + const batch = [anchor(6509)] + reattributeDomAnchors(batch, at(4871), 5131) + expect(batch[0]!.timestamp).toBe(5131) + }) + + it('never pushes an anchor later than the page stamped it', () => { + const batch = [anchor(4259)] + reattributeDomAnchors(batch, at(99999), 0) + expect(batch[0]!.timestamp).toBe(4259) + }) + + it('leaves the anchor untouched when no action resolves', () => { + const batch = [anchor(4259)] + reattributeDomAnchors(batch, () => undefined, 0) + expect(batch[0]!.timestamp).toBe(4259) + }) + + it('keeps the batch ascending across two anchors', () => { + const batch = [ + anchor(5000, 'https://example.com/one'), + mutation({ type: 'attributes', target: '35', timestamp: 5200 }), + anchor(6000, 'https://example.com/two') + ] + reattributeDomAnchors(batch, at(1000), 0) + expect(batch.map((m) => m.timestamp)).toEqual([1000, 5200, 5200]) + }) + + it('is a no-op on a batch with no anchors', () => { + const batch = [ + mutation({ type: 'characterData', target: '7', timestamp: 42 }) + ] + reattributeDomAnchors(batch, at(1), 0) + expect(batch[0]!.timestamp).toBe(42) + }) +}) diff --git a/packages/script/src/collector.ts b/packages/script/src/collector.ts index ecd9ad3d..fab4ea82 100644 --- a/packages/script/src/collector.ts +++ b/packages/script/src/collector.ts @@ -3,7 +3,27 @@ import { ConsoleLogCollector } from './collectors/consoleLogs.js' import { NetworkRequestCollector } from './collectors/networkRequests.js' import { assignRef, hasRef, parseDocument } from './utils.js' -class DataCollector { +/** + * When THIS document came into existence, as epoch ms. + * + * The anchor must not be stamped with the drain's clock. A drain is forced from + * Node whenever a collector might be fresh, which is always some time after the + * navigation that created the document — a round trip at best, a whole page load + * at worst — and several actions can run inside that gap. Stamped at drain time + * the anchor lands after them, so they all replay the PREVIOUS page's DOM. + * + * `performance.timeOrigin` is this document's navigation start, which is exactly + * the moment the anchor describes and is immune to which drain happens to + * collect it. Falls back to the drain clock where it isn't exposed. + */ +function documentAnchorTime(): number { + const origin = performance?.timeOrigin + return typeof origin === 'number' && Number.isFinite(origin) && origin > 0 + ? Math.round(origin) + : Date.now() +} + +export class DataCollector { #metadata = { url: window.location.href, // Serialize viewport values explicitly — VisualViewport properties are @@ -18,6 +38,10 @@ class DataCollector { } #errors: string[] = [] #mutations: TraceMutation[] = [] + /** Whether THIS collector has emitted its full-DOM anchor. Instance-scoped on + * purpose: the document's refs outlive a replaced collector, so keying on + * them made a re-injected collector unable to anchor at all. */ + #anchored = false #consoleLogs = new ConsoleLogCollector() #networkRequests = new NetworkRequestCollector() @@ -38,15 +62,23 @@ class DataCollector { * async anchor won the race): re-running assignRef would renumber descendants * and desync every prior mutation. */ captureCurrentDom() { - if (hasRef(document.documentElement)) { + if (this.#anchored) { return } - assignRef(document.documentElement) + this.#anchored = true + // Only number the tree when it isn't numbered yet. A re-injected collector + // lands on a document a PREVIOUS collector already ref'd; re-running + // assignRef would renumber every descendant and desync prior mutations, + // but refusing to anchor at all loses the document entirely — which is what + // made a navigation destination's DOM vanish from the trace. + if (!hasRef(document.documentElement)) { + assignRef(document.documentElement) + } this.captureMutation([ { type: 'childList', url: window.location.href, - timestamp: Date.now(), + timestamp: documentAnchorTime(), addedNodes: [parseDocument(document.documentElement)], removedNodes: [] } @@ -56,6 +88,8 @@ class DataCollector { reset() { this.#errors = [] this.#mutations = [] + // `#anchored` deliberately survives a reset so a later drain doesn't + // re-emit the whole document. this.#consoleLogs.clear() this.#networkRequests.clear() clearLogs() From cf7c25da4e1c041429bb4889b068344f73b8933c Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Wed, 5 Aug 2026 17:18:19 +0530 Subject: [PATCH 03/45] fix(core): share a per-action capture instead of consuming it --- packages/core/src/trace-frame-snapshots.ts | 27 ++++++++++++++-- .../core/tests/trace-frame-snapshots.test.ts | 31 ++++++++++++++++--- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/packages/core/src/trace-frame-snapshots.ts b/packages/core/src/trace-frame-snapshots.ts index 4f91f208..dc11e497 100644 --- a/packages/core/src/trace-frame-snapshots.ts +++ b/packages/core/src/trace-frame-snapshots.ts @@ -88,19 +88,40 @@ export class FrameSnapshotIndex { return this.#lastName } - /** Claims the screenshot captured at the command's completion, if any. */ + /** + * The capture describing the page when this command completed: the one at its + * exact timestamp, else the most recent one before it. + * + * Non-consuming, and falls back rather than returning nothing, because several + * actions legitimately share one page state. Nightwatch emits its native + * assertion rows in a batch whose execution windows collapse onto the same + * instant, so handing the capture to whichever claimed first left the rest of + * the batch with no DOM, no a11y tree and no screenshot in the action pane. + * And an assertion reads the page rather than changing it, so it takes no + * capture of its own and correctly inherits the preceding action's. + */ claimAfter(timestamp: number, callId: string): string | undefined { - const snap = this.#byTimestamp.get(timestamp) + const snap = + this.#byTimestamp.get(timestamp) ?? this.#latestAtOrBefore(timestamp) if (!snap) { return undefined } - this.#byTimestamp.delete(timestamp) const snapshotName = `after@${callId}` this.#refs.push({ callId, snapshotName, snapshot: snap }) this.#lastName = snapshotName return snapshotName } + #latestAtOrBefore(timestamp: number): ActionSnapshot | undefined { + let best: ActionSnapshot | undefined + for (const [ts, snap] of this.#byTimestamp) { + if (ts <= timestamp && (!best || ts > best.timestamp)) { + best = snap + } + } + return best + } + refs(): FrameSnapshotRef[] { return this.#refs } diff --git a/packages/core/tests/trace-frame-snapshots.test.ts b/packages/core/tests/trace-frame-snapshots.test.ts index 6d23cd05..e84719fd 100644 --- a/packages/core/tests/trace-frame-snapshots.test.ts +++ b/packages/core/tests/trace-frame-snapshots.test.ts @@ -21,16 +21,37 @@ describe('FrameSnapshotIndex', () => { ]) }) - it('returns undefined for unmatched timestamps', () => { - const index = new FrameSnapshotIndex([snap()]) + it('returns undefined when the index holds no captures at all', () => { + const index = new FrameSnapshotIndex([]) expect(index.claimAfter(1234, 'call@2')).toBeUndefined() expect(index.refs()).toEqual([]) }) - it('consumes the snapshot on claim', () => { + it('shares one capture across actions completing at the same instant', () => { + // Nightwatch emits its native assertion rows in a batch whose execution + // windows collapse onto one instant; consuming left the rest blank. const index = new FrameSnapshotIndex([snap()]) - index.claimAfter(2000, 'call@2') - expect(index.claimAfter(2000, 'call@3')).toBeUndefined() + expect(index.claimAfter(2000, 'call@2')).toBe('after@call@2') + expect(index.claimAfter(2000, 'call@3')).toBe('after@call@3') + expect(index.refs().map((r) => r.callId)).toEqual(['call@2', 'call@3']) + expect(index.refs()[1]!.snapshot).toEqual(snap()) + }) + + it('falls back to the most recent capture before an uncaptured action', () => { + // An assertion reads the page rather than changing it, so it takes no + // capture of its own and inherits the preceding action's. + const index = new FrameSnapshotIndex([ + snap(), + snap({ timestamp: 3000, command: 'setValue', screenshot: 'BB' }) + ]) + expect(index.claimAfter(3500, 'call@9')).toBe('after@call@9') + expect(index.refs()[0]!.snapshot.screenshot).toBe('BB') + }) + + it('still returns nothing for an action preceding every capture', () => { + const index = new FrameSnapshotIndex([snap()]) + expect(index.claimAfter(1000, 'call@1')).toBeUndefined() + expect(index.refs()).toEqual([]) }) it('ignores snapshots without a screenshot', () => { From eaa4f4a6f03d49c294fbe041034e922eacaf86f7 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Wed, 5 Aug 2026 17:18:32 +0530 Subject: [PATCH 04/45] fix: break row-order ties with issue order --- packages/core/src/trace-action-events.ts | 13 +++-- packages/core/tests/trace-assertions.test.ts | 54 ++++++++++++++++++++ packages/shared/src/types.ts | 6 +++ 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/packages/core/src/trace-action-events.ts b/packages/core/src/trace-action-events.ts index 1c6d6220..8286ef28 100644 --- a/packages/core/src/trace-action-events.ts +++ b/packages/core/src/trace-action-events.ts @@ -367,11 +367,16 @@ export function buildActionEvents( // late but carry their real call-time `startTime`; since pushActionPair floors // each start at the running prevEndMs, out-of-order input would clamp those // late rows to the end of the timeline (clustering them after the last real - // command). A stable sort by start time restores true positions and keeps - // equal-time rows in insertion order (each command owns its own before/after - // pair, so pairing is unaffected). + // command). A stable sort by start time restores true positions. `sequence` + // then breaks ties the millisecond clock can't: a deferred row issued + // microseconds before the command that follows it reads as the same + // `startTime`, and insertion order put it after — which is how an assert that + // ran on the secure page landed below the logout click it preceded. Each + // command owns its own before/after pair, so pairing is unaffected. const ordered = [...commands].sort( - (a, b) => (a.startTime ?? a.timestamp) - (b.startTime ?? b.timestamp) + (a, b) => + (a.startTime ?? a.timestamp) - (b.startTime ?? b.timestamp) || + (a.sequence ?? 0) - (b.sequence ?? 0) ) for (const cmd of ordered) { const action = mapCommandToAction(cmd.command) diff --git a/packages/core/tests/trace-assertions.test.ts b/packages/core/tests/trace-assertions.test.ts index d28b8d55..17bcc2a3 100644 --- a/packages/core/tests/trace-assertions.test.ts +++ b/packages/core/tests/trace-assertions.test.ts @@ -120,6 +120,60 @@ describe('buildActionEvents with assert commands', () => { (e): e is AfterEvent => e.type === 'after' && e.callId === callId ) + it('orders an issued-earlier assert before a longer command sharing its start', () => { + // Nightwatch enqueues `browser.assert.*` synchronously and then awaits the + // next command, so both read the same millisecond `startTime`. The assert is + // appended to commandsLog in the test-end batch, i.e. AFTER the click, so + // without `sequence` the tie resolved in insertion order and the assert + // landed below the logout click it actually preceded. + const commands: CommandLog[] = [ + { + command: 'click', + args: ['a*=Logout'], + startTime: WALL + 100, + timestamp: WALL + 5100, + sequence: 3 + }, + { + command: 'assert.urlContains', + args: ['/secure'], + result: 'passed', + startTime: WALL + 100, + timestamp: WALL + 100, + sequence: 1 + }, + { + command: 'assert.textContains', + args: ['#flash', 'You logged into a secure area'], + result: 'passed', + startTime: WALL + 100, + timestamp: WALL + 100, + sequence: 2 + } + ] + const order = befores(buildActionEvents(commands, 'page@1', WALL)).map( + (b) => b.apiName + ) + expect(order).toEqual([ + 'assert.urlContains', + 'assert.textContains', + 'element.click' + ]) + }) + + it('falls back to insertion order when no sequence is stamped', () => { + const commands: CommandLog[] = [ + { command: 'click', args: ['#a'], startTime: WALL, timestamp: WALL }, + { command: 'click', args: ['#b'], startTime: WALL, timestamp: WALL } + ] + const order = befores(buildActionEvents(commands, 'page@1', WALL)).map( + (b) => b.title + ) + expect(order).toEqual(order.slice().sort()) + expect(order[0]).toContain('#a') + expect(order[1]).toContain('#b') + }) + it('emits an action pair with assert params, apiName and title', () => { const commands: CommandLog[] = [ { diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index f9a8db68..b52db78f 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -166,6 +166,12 @@ export interface CommandLog { timestamp: number /** Wall-clock ms when the command was invoked (before execution). */ startTime?: number + /** Monotonic issue order, stamped when the test issued the command. Breaks + * ties the millisecond clock can't: a deferred row (a Nightwatch native + * assert, finalized in a batch at test-end) is appended long after the driver + * row it was issued before, so an equal `startTime` otherwise resolved in + * insertion order and put the assert after the command that followed it. */ + sequence?: number callSource?: string screenshot?: string testUid?: string From 15ed82f943a858c682c9b9f14a697c835f97680d Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Wed, 5 Aug 2026 17:18:46 +0530 Subject: [PATCH 05/45] fix(selenium): stamp command rows at completion, not invocation --- .../selenium-devtools/src/driverPatcher.ts | 7 +- .../src/helpers/captureOrReplaceCommand.ts | 2 + .../src/helpers/commandPostActions.ts | 67 ++++++++++++++----- packages/selenium-devtools/src/session.ts | 43 +++++++++--- packages/selenium-devtools/src/types.ts | 5 ++ 5 files changed, 97 insertions(+), 27 deletions(-) diff --git a/packages/selenium-devtools/src/driverPatcher.ts b/packages/selenium-devtools/src/driverPatcher.ts index d1aab68f..a5d3bda7 100644 --- a/packages/selenium-devtools/src/driverPatcher.ts +++ b/packages/selenium-devtools/src/driverPatcher.ts @@ -129,7 +129,12 @@ function makeWrappedMethod( rawResult: error ? undefined : result, error, callSource: callInfo.callSource, - timestamp: startedAt, + // Completion, not `startedAt`: a navigation resolves seconds after it + // is issued, and the destination document's DOM anchor carries its own + // birth time. Stamped at invocation, the row ended before the document + // existed and replayed the page it had just left. + timestamp: Date.now(), + startTime: startedAt, fromElement }) } catch (hookErr) { diff --git a/packages/selenium-devtools/src/helpers/captureOrReplaceCommand.ts b/packages/selenium-devtools/src/helpers/captureOrReplaceCommand.ts index 39b35768..69e5a9f6 100644 --- a/packages/selenium-devtools/src/helpers/captureOrReplaceCommand.ts +++ b/packages/selenium-devtools/src/helpers/captureOrReplaceCommand.ts @@ -34,6 +34,7 @@ export async function captureOrReplaceCommand(opts: { cmd.timestamp ) const entry = replaced.entry as CommandLog & { _id?: number } + entry.startTime = cmd.startTime retryTracker.setLastId(entry._id ?? null) capturer.sendReplaceCommand(replaced.oldTimestamp, entry) return entry @@ -48,6 +49,7 @@ export async function captureOrReplaceCommand(opts: { cmd.callSource, cmd.timestamp )) as CommandLog & { _id?: number } + entry.startTime = cmd.startTime capturer.sendCommand(entry) retryTracker.recordCapture(cmdSig, entry._id ?? null) return entry diff --git a/packages/selenium-devtools/src/helpers/commandPostActions.ts b/packages/selenium-devtools/src/helpers/commandPostActions.ts index 486a76f8..dd8dc496 100644 --- a/packages/selenium-devtools/src/helpers/commandPostActions.ts +++ b/packages/selenium-devtools/src/helpers/commandPostActions.ts @@ -6,6 +6,7 @@ import { isSessionGoneError, mapCommandToAction, toError, + upsertRichestSnapshot, type CapturedPerformancePayload, type RetryTracker } from '@wdio/devtools-core' @@ -109,7 +110,12 @@ export function captureNavigationTrace( if (entry && driver) { await capturePerformance(capturer, driver, entry, args) } - await capturer.captureTrace() + // Anchored: the just-injected collector anchors asynchronously, so an + // unanchored drain can miss the destination's DOM entirely. This hook runs + // at command START, so the drain sees the page being left, not the + // destination — a later drain collects that anchor, and ingest credits it + // to the right action from the anchor's own document birth time. + await capturer.captureTrace(true) if (!capturer.bidiActive) { await capturer.captureBrowserLogs() } @@ -186,7 +192,12 @@ function attachScreenshotAsync( * collector so the page's field edits land before the page is discarded, then — * if the command navigated (a submit click) — re-inject on the destination so * its DOM is captured too (the previous page's `