Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/capture-only-page-changes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@wdio/devtools-service': patch
'@wdio/selenium-devtools': patch
'@wdio/nightwatch-devtools': patch
---

Trace mode no longer takes a page capture (screenshot plus page source, and DOM on web) for read and wait commands such as `getText`, `isDisplayed` or `waitForDisplayed`. Those rows still appear in the trace and now show the page as of the last command that changed it. Each capture costs a full page-source round trip, which on iOS is slow enough that a suite making a dozen interactions spent most of its time capturing reads: one reported run went from 3:10 to 7:10 in trace mode. The first traced command of each test is still captured even when it is a read, so a test that starts by reading has a frame to show (on Nightwatch's `describe`/`it` interface, the first of each module).
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ Documented divergences from the conventions above. They exist today as debt to b
- **Locator generation from a native page source must stay linear, and iOS is where it did not.** Every native action snapshot builds element locators from the page-source XML (`core/locators`), and each uniqueness check was a `//*[@name="…"]` XPath run by the `xpath` library over the whole document, several per element. Measured on the iOS Settings page source tiled up: 38 KB 82 ms, 188 KB 1.4 s, 752 KB 23.5 s, which is what a reporter saw as trace mode adding 1.5 min to a 2 min 20 s iOS test while Android, with smaller flatter trees, added 10 s. `locators/xpath-index.ts` indexes the document once per `generateAllElementLocators` walk (`withXPathIndex`) and answers `//*`, `//Tag` and `//Tag[@a="x" and …]` from it: 15 ms, 49 ms, 268 ms for the same three, identical elements. It is scoped to the walk so a caller of the exported `evaluateXPath` that edits its document is never answered stale, and it declines a namespaced document, where XPath name tests resolve namespaces and the index does not. The page-source request itself remains: 0.75-1.2 s per action on XCUITest even for Settings, against 0.09-0.5 s on UiAutomator2.
- A filmstrip poll against a native session stacks requests: `setInterval` never waits for its async handler, a native screenshot takes ~1.2 s against the 200 ms default, and a serialised driver serves that queue ahead of the test's own commands — measured, a **15 ms command took 4.5–7.8 s**. `ScreencastRecorderBase`'s `#pollInFlight` latch bounds it to one outstanding shot, and `#pollGeneration` stops a shot orphaned by `stop()` from appending after the stop or clearing its successor's latch.
- **`start()` and `stop()` are serialised against each other, because unserialised neither owns what it arms.** A `stop()` landing mid-handshake returned at its `#isRecording` guard — still false, the handshake not having finished — so it never reached its own CDP teardown, and the `start()` it interrupted then returned on the generation mismatch without stopping what it had armed: the session and its frame listener stayed live after teardown. The frames themselves were moot — every adapter builds a fresh recorder per session (`screencast-lifecycle.ts` does so even across `reloadSession`), so the leaked session and its browser-side stream were the damage, not buffer pollution. The obvious repair — stopping the session on that mismatch path — is wrong, and it is the shape to keep out: it guards a field both CDP overrides write at handshake **start** (`#cdpSession` / `#cdp`) with a flag they set only at handshake **end** (`#cdpActive`), so a start arriving in that window is indistinguishable from no start at all and the stale path tears down the *newer* recording's session instead. Overlapping starts are the adapters' normal mode rather than a corner: Selenium's `driverPatcher` documents the second test's body racing an in-flight `screencast.start()`, and Nightwatch's session rotation is fire-and-forget from a command hook. Queued, a stop always runs against a start that has finished arming and owns what it armed, which makes the mismatch branch unreachable and deleted — the tick-phase generation checks in `#startPolling` are **not**, since a shot orphaned by a stop still lands afterwards. Two consequences: `stop()` now waits on an in-flight handshake, and every awaited driver primitive in that handshake is ceilinged at `SCREENCAST_HANDSHAKE_TIMEOUT_MS` (the service's `getPuppeteer()`/`pages()`/`createCDPSession()`/`Page.startScreencast` and the stop-side `Page.stopScreencast`, Selenium's `createCDPConnection`, the polling path's first screenshot) — a driver that wedges gives up after the ceiling, falls back to polling, or reports the screencast unavailable when polling was what wedged. Nothing is claimed until the handshake has answered, so a timed-out start leaves nothing armed — and a session or connection that completes after the ceiling is detached (or its socket closed) when it lands, so no orphan outlives the recording. And a stop landing during a polling session's first screenshot now records that shot before tearing the loop down, where it previously cancelled the start outright; the interval is still never left armed underneath the stop.
- Residual: **a polling matcher fires one capture per poll** — each poll is a top-level mapped read — so "one capture per action" undercounts a real suite. Pre-existing in `d924a02`, unmeasured on a device, and the likeliest next lever.
- **Only a command that changes the page is captured** (`shared/trace-actions.ts` `changesPage`, read off the typed `METHOD_EFFECT` table that every `ACTION_MAP` method must appear in). Reads and waits still render rows and inherit the latest earlier capture through `claimAfter`'s `latestAtOrBefore` fallback. The gate used to be `mapCommandToAction` itself, so `5c54ae4` adding the reads to the table for the timeline also made each of them a capture, and a polling matcher one capture per poll: a reported iOS suite made 187 page-source reads for 12 interactions and went from 3:10 to 7:10. The first mapped command of each test is still captured even when it reads, or a test that opens with reads has no frame to inherit.
- The next test's first pre-capture used to be stamped at the previous test's last-action timestamp — the log it scans is run-long, so a test boundary was invisible to it. That slot already holds the previous test's finalize capture, and the richer-screenshot merge could replace it: with a `reloadSession` between the tests the row replayed the post-reload page. The capture now stamps `Date.now()` when the scanned timestamp predates `#currentTestStartWallTime` (0 without per-test hooks, so the standalone path is unchanged) — the same rule that makes a session's first capture its initial frame. Per-test slices were never affected: `flushTest` runs inside `afterTest`, before the next test's commands.

### File-size (raw line counts; soft cap is 500 logic lines)
Expand Down
25 changes: 20 additions & 5 deletions packages/nightwatch-devtools/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type CapturedPerformancePayload,
type SessionCapturerOptions
} from '@wdio/devtools-core'
import { changesPage } from '@wdio/devtools-shared'
import { captureActionSnapshot } from './action-snapshot.js'
import { nightwatchRunnerId } from './helpers/utils.js'
import { NAVIGATION_COMMANDS } from './constants.js'
Expand Down Expand Up @@ -91,6 +92,13 @@ export class SessionCapturer extends SessionCapturerBase {
readonly actionSnapshots: ActionSnapshot[] = []
readonly snapshotCaptures: Promise<void>[] = []

/** True until the current test's first mapped command takes its capture. */
awaitingInitialFrame = true

resetTestCapture(): void {
this.awaitingInitialFrame = true
}

constructor(
devtoolsOptions: SessionCapturerOptions = {},
browser?: NightwatchBrowser
Expand Down Expand Up @@ -168,13 +176,18 @@ export class SessionCapturer extends SessionCapturerBase {
* after the command completes, and export claims snapshots by exact equality
* with the command timestamp (FrameSnapshotIndex.claimAfter / elementsAt). */
#queueActionSnapshot(command: string, timestamp: number): void {
if (
this.traceMode !== 'trace' ||
!this.#browser ||
!mapCommandToAction(command)
) {
if (this.traceMode !== 'trace' || !this.#browser) {
return
}
// Reads and waits inherit the earlier capture; only the test's first mapped command adds an initial frame.
const initialFrame =
this.awaitingInitialFrame && !!mapCommandToAction(command)
if (!changesPage(command) && !initialFrame) {
return
}
if (initialFrame) {
this.awaitingInitialFrame = false
}
this.snapshotCaptures.push(
captureActionSnapshot(
this.#browser,
Expand All @@ -185,6 +198,8 @@ export class SessionCapturer extends SessionCapturerBase {
).then((snap) => {
if (snap) {
upsertRichestSnapshot(this.actionSnapshots, snap)
} else if (initialFrame && !changesPage(command)) {
this.awaitingInitialFrame = true
}
})
)
Expand Down
1 change: 1 addition & 0 deletions packages/nightwatch-devtools/src/test-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ export function wrapBrowserOnce(
ctx.isScriptInjected = true
}
ctx.browserProxy.resetTestTracking()
ctx.sessionCapturer.resetTestCapture()
ctx.browserProxy.wrapBrowserCommands(browser)
}

Expand Down
54 changes: 54 additions & 0 deletions packages/nightwatch-devtools/tests/actionSnapshot.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import type * as DevtoolsCore from '@wdio/devtools-core'
import { describe, it, expect, vi } from 'vitest'
import { captureActionSnapshot as coreCapture } from '@wdio/devtools-core'
import { SessionCapturer } from '../src/session.js'
import type { CommandLog, NightwatchBrowser } from '../src/types.js'

vi.mock('@wdio/devtools-core', async (importOriginal) => {
const actual = await importOriginal<typeof DevtoolsCore>()
return {
...actual,
captureActionSnapshot: vi.fn(actual.captureActionSnapshot)
}
})

function makeMockBrowser(): NightwatchBrowser {
return {
url: vi.fn(async () => ({})),
Expand Down Expand Up @@ -59,6 +69,50 @@ describe('nightwatch action-snapshot timestamp binding', () => {
cap.cleanup()
})

it('captures a click, but not a read or wait that follows it', async () => {
const cap = makeCapturer()
const at = (command: string, timestamp: number) =>
cap.captureCommand(command, ['#el'], undefined, undefined, { timestamp })
await at('click', 1)
await at('getText', 2)
await at('waitForElementVisible', 3)
await Promise.all(cap.snapshotCaptures)

expect(cap.actionSnapshots.map((s) => s.timestamp)).toEqual([1])
expect(cap.commandsLog).toHaveLength(3)
cap.cleanup()
})

it('captures a leading read as the initial frame, once per test', async () => {
const cap = makeCapturer()
const at = (command: string, timestamp: number) =>
cap.captureCommand(command, ['#el'], undefined, undefined, { timestamp })
await at('getText', 1)
await at('getText', 2)
await Promise.all(cap.snapshotCaptures)
expect(cap.actionSnapshots.map((s) => s.timestamp)).toEqual([1])

cap.resetTestCapture()
await at('getText', 3)
await Promise.all(cap.snapshotCaptures)
expect(cap.actionSnapshots.map((s) => s.timestamp)).toEqual([1, 3])
cap.cleanup()
})

it('retries the initial frame when the first read captures nothing', async () => {
vi.mocked(coreCapture).mockResolvedValueOnce(null)
const cap = makeCapturer()
const at = (command: string, timestamp: number) =>
cap.captureCommand(command, ['#el'], undefined, undefined, { timestamp })
await at('getText', 1)
await Promise.all(cap.snapshotCaptures)
await at('getText', 2)
await at('getText', 3)
await Promise.all(cap.snapshotCaptures)
expect(cap.actionSnapshots.map((s) => s.timestamp)).toEqual([2])
cap.cleanup()
})

it('does not capture outside trace mode', async () => {
const cap = new SessionCapturer({}, makeMockBrowser())
await cap.captureCommand('click', [], undefined, undefined, {
Expand Down
19 changes: 13 additions & 6 deletions packages/selenium-devtools/src/helpers/commandPostActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type CapturedPerformancePayload,
type RetryTracker
} from '@wdio/devtools-core'
import { changesPage } from '@wdio/devtools-shared'
import { ELEMENT_LOCATOR_METHODS } from '../constants.js'
import { getDriverOriginals, getElementOriginals } from '../driverPatcher.js'
import { captureOrReplaceCommand } from './captureOrReplaceCommand.js'
Expand Down Expand Up @@ -357,14 +358,18 @@ function queueActionSnapshot(
timestamp: number,
error: unknown
): void {
if (
ctx.options.mode !== 'trace' ||
error ||
!ctx.driver ||
!mapCommandToAction(cmd.command)
) {
if (ctx.options.mode !== 'trace' || error || !ctx.driver) {
return
}
// Reads and waits inherit the earlier capture; only the test's first mapped command adds an initial frame.
const initialFrame =
capturer.awaitingInitialFrame && !!mapCommandToAction(cmd.command)
if (!changesPage(cmd.command) && !initialFrame) {
return
}
if (initialFrame) {
capturer.awaitingInitialFrame = false
}
ctx.snapshotCaptures.push(
captureActionSnapshot(
ctx.driver,
Expand All @@ -374,6 +379,8 @@ function queueActionSnapshot(
).then((snap) => {
if (snap) {
upsertRichestSnapshot(ctx.actionSnapshots, snap)
} else if (initialFrame && !changesPage(cmd.command)) {
capturer.awaitingInitialFrame = true
Comment on lines 380 to +383

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Failed screenshot stops later attempts

If the first screenshot fails, captureActionSnapshot can still return a truthy object with screenshot: undefined. This branch leaves awaitingInitialFrame false, so later reads never retry. FrameSnapshotIndex skips that capture, and Selenium has no final capture to recover it. A read-only test can therefore finish without frames for its rows.

Clear the initial-frame flag only after a usable screenshot arrives. Apply the same check in Nightwatch and the service.

}
})
)
Expand Down
7 changes: 7 additions & 0 deletions packages/selenium-devtools/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ export class SessionCapturer extends SessionCapturerBase {
* post-navigation re-injection) unreachable rather than merely redundant. */
preloadRegistered = false

/** True until the current test's first mapped command takes its capture. */
awaitingInitialFrame = true

resetTestCapture(): void {
this.awaitingInitialFrame = true
}

#clientConnected = false
#clientConnectedWaiters: Array<() => void> = []
#onClientDisconnected?: () => void
Expand Down
2 changes: 2 additions & 0 deletions packages/selenium-devtools/src/test-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ export function startTest(
}
ctx.testManager!.startMarkedTest(name, resolvedMeta)
ctx.retryTracker.reset()
ctx.sessionCapturer?.resetTestCapture()
if (file) {
ctx.sessionCapturer?.captureSource(file).catch(() => {})
}
Expand Down Expand Up @@ -200,6 +201,7 @@ export function startScenario(
const file = meta.file ?? root?.file ?? process.cwd()
ctx.suiteManager.startScenarioSuite(name, file, meta.callSource, meta.file)
ctx.retryTracker.reset()
ctx.sessionCapturer?.resetTestCapture()
if (meta.file) {
ctx.sessionCapturer?.captureSource(meta.file).catch(() => {})
}
Expand Down
51 changes: 50 additions & 1 deletion packages/selenium-devtools/tests/action-snapshot.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type * as DevtoolsCore from '@wdio/devtools-core'
import { afterEach, describe, expect, it, vi } from 'vitest'

import { resetSignatureCounters } from '../src/helpers/utils.js'
Expand All @@ -11,10 +12,21 @@ import {
} from '../src/helpers/commandPostActions.js'
import { captureActionSnapshot } from '../src/action-snapshot.js'
import { getDriverOriginals } from '../src/driverPatcher.js'
import { RetryTracker } from '@wdio/devtools-core'
import {
RetryTracker,
captureActionSnapshot as coreCapture
} from '@wdio/devtools-core'
import type { ActionSnapshot } from '@wdio/devtools-shared'
import type { CapturedCommand, SeleniumDriverLike } from '../src/types.js'

vi.mock('@wdio/devtools-core', async (importOriginal) => {
const actual = await importOriginal<typeof DevtoolsCore>()
return {
...actual,
captureActionSnapshot: vi.fn(actual.captureActionSnapshot)
}
})

const capturers: SessionCapturer[] = []

afterEach(() => {
Expand Down Expand Up @@ -115,6 +127,43 @@ describe('selenium action-snapshot timestamp binding', () => {
})
})

describe('selenium action-snapshot capture gate', () => {
async function run(commands: string[], ctxBag = makeCtx(fakeDriver())) {
let t = 1
for (const command of commands) {
await handleOnCommand(ctxBag.ctx, cmd({ command, timestamp: t++ }))
}
await Promise.all(ctxBag.snapshotCaptures)
return ctxBag
}

it('captures a click, but not a read or wait that follows it', async () => {
const { actionSnapshots } = await run([
'click',
'getText',
'waitForDisplayed'
])
expect(actionSnapshots.map((s) => s.timestamp)).toEqual([1])
})

it('captures a leading read as the initial frame, once per test', async () => {
const bag = makeCtx(fakeDriver())
await run(['getText', 'isDisplayed'], bag)
expect(bag.actionSnapshots.map((s) => s.timestamp)).toEqual([1])

bag.capturer.resetTestCapture()
await handleOnCommand(bag.ctx, cmd({ command: 'getText', timestamp: 9 }))
await Promise.all(bag.snapshotCaptures)
expect(bag.actionSnapshots.map((s) => s.timestamp)).toEqual([1, 9])
})

it('retries the initial frame when the first read captures nothing', async () => {
vi.mocked(coreCapture).mockResolvedValueOnce(null)
const bag = await run(['getText', 'isDisplayed', 'getText'])
expect(bag.actionSnapshots.map((s) => s.timestamp)).toEqual([2])
})
})

describe('selenium action-snapshot locator dialect', () => {
it('injects the XPath text form, which By.xpath resolves', async () => {
// `a*=Logout` is WebdriverIO-only syntax; a Selenium user pastes the
Expand Down
Loading
Loading