From 573a8865cbeed9acef416ca2858e6b4c74397416 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Tue, 10 Nov 2026 17:09:47 +0530 Subject: [PATCH 01/10] Keep the polling filmstrip from queueing in front of the test Without a CDP screencast a driver serves the 200 ms screenshot polls and the test's own requests one at a time, so the polls sat in front of commands and per-action captures (#516: a per-action screenshot 600-665 ms on Android with the poller running, 229 ms without). The recorder now skips a tick while a command or a capture holds the driver, through one bounded gate in core that the adapters' command hooks and captureActionSnapshot open; it replaces the input-only gate, whose click race it covers. Waits and host-side commands open nothing, since the driver is idle between their polls. Each capture's screenshot is kept as a filmstrip frame and the poll that would repeat it is skipped. The recorder's own screenshots and skipped ticks are counted in the DEVTOOLS_CAPTURE_TIMING report so this cost stays visible. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/action-snapshot.ts | 20 ++- packages/core/src/capture-timing.ts | 92 +++++++++++- packages/core/src/driver-activity.ts | 64 +++++++++ packages/core/src/index.ts | 2 +- packages/core/src/input-dispatch.ts | 63 -------- packages/core/src/screencast.ts | 135 +++++++++++++----- packages/core/tests/action-snapshot.test.ts | 44 +++++- packages/core/tests/capture-timing.test.ts | 66 +++++++++ packages/core/tests/driver-activity.test.ts | 120 ++++++++++++++++ packages/core/tests/input-dispatch.test.ts | 117 --------------- packages/core/tests/screencast.test.ts | 117 +++++++++++++-- .../src/helpers/browserProxy.ts | 20 +-- .../tests/browserProxy.test.ts | 45 +++++- .../selenium-devtools/src/driverPatcher.ts | 17 +-- .../selenium-devtools/tests/index.test.ts | 23 ++- packages/service/src/index.ts | 23 ++- packages/service/tests/index.test.ts | 38 ++--- packages/shared/src/action-mapping.ts | 19 +++ packages/shared/src/trace-actions.ts | 30 ---- 19 files changed, 732 insertions(+), 323 deletions(-) create mode 100644 packages/core/src/driver-activity.ts delete mode 100644 packages/core/src/input-dispatch.ts create mode 100644 packages/core/tests/driver-activity.test.ts delete mode 100644 packages/core/tests/input-dispatch.test.ts diff --git a/packages/core/src/action-snapshot.ts b/packages/core/src/action-snapshot.ts index 1cba3161..b630c41f 100644 --- a/packages/core/src/action-snapshot.ts +++ b/packages/core/src/action-snapshot.ts @@ -20,6 +20,8 @@ import { withTimeout } from './with-timeout.js' import { startCaptureTimer, type CaptureTimer } from './capture-timing.js' +import { beginDriverCapture } from './driver-activity.js' +import { ScreencastRecorderBase } from './screencast.js' import { hasScreenshot, type ActionSnapshot, @@ -205,11 +207,19 @@ export async function captureActionSnapshot( input.trigger ?? 'action', isNativeMobile ) - const [url, title, pageSource, tree, elements, shot] = await readProbes( - input, - isNativeMobile, - timer - ) + const closeDriverWindow = beginDriverCapture() + let probes: Awaited> + try { + probes = await readProbes(input, isNativeMobile, timer) + } finally { + closeDriverWindow() + } + const [url, title, pageSource, tree, elements, shot] = probes + // The screenshot is issued last, so it is the last read to settle: stamped + // now, the moment a poll taking it would have stamped it. + if (hasScreenshot(shot)) { + ScreencastRecorderBase.offerCapturedFrame(shot, Date.now()) + } const processedFrom = timer ? performance.now() : 0 const mobile = diff --git a/packages/core/src/capture-timing.ts b/packages/core/src/capture-timing.ts index d7085ef8..43f372bc 100644 --- a/packages/core/src/capture-timing.ts +++ b/packages/core/src/capture-timing.ts @@ -286,12 +286,99 @@ export function formatCaptureTimingSummary( ].join('\n') } -/** Appends the captures since the last report to the process's NDJSON file and prints their summary. Never throws. */ +/** Why the filmstrip poller let a tick pass without a screenshot. */ +export type RecorderSkipReason = 'busy' | 'fresh' | 'inFlight' + +const RECORDER_SKIP_LABELS: Record = { + busy: 'driver busy', + fresh: 'fresh frame', + inFlight: 'shot in flight' +} + +const recorderShots: number[] = [] +const recorderSkips: Record = { + busy: 0, + fresh: 0, + inFlight: 0 +} +let recorderReused = 0 + +/** The filmstrip poller's own screenshot, `ms` from issue to settle. */ +export function recordRecorderShot(ms: number): void { + if (captureTimingEnabled()) { + recorderShots.push(ms) + } +} + +export function recordRecorderSkip(reason: RecorderSkipReason): void { + if (captureTimingEnabled()) { + recorderSkips[reason]++ + } +} + +/** A per-action capture's screenshot kept as a filmstrip frame. */ +export function recordRecorderReuse(): void { + if (captureTimingEnabled()) { + recorderReused++ + } +} + +export interface RecorderTimingSummary { + shots: number + total: number + median: number + skipped: Record + reused: number +} + +function hasRecorderActivity(): boolean { + return ( + recorderShots.length > 0 || + recorderReused > 0 || + Object.values(recorderSkips).some((n) => n > 0) + ) +} + +/** Drains the recorder counters gathered since the last report. */ +function takeRecorderTiming(): RecorderTimingSummary { + const sorted = recorderShots.splice(0).sort((a, b) => a - b) + const skipped = { ...recorderSkips } + for (const reason of Object.keys(recorderSkips) as RecorderSkipReason[]) { + recorderSkips[reason] = 0 + } + const reused = recorderReused + recorderReused = 0 + return { + shots: sorted.length, + total: round(sum(sorted)), + median: sorted.length ? round(median(sorted)) : 0, + skipped, + reused + } +} + +export function formatRecorderTiming(summary: RecorderTimingSummary): string { + const reasons = (Object.keys(summary.skipped) as RecorderSkipReason[]) + .filter((r) => summary.skipped[r] > 0) + .map((r) => `${summary.skipped[r]} ${RECORDER_SKIP_LABELS[r]}`) + const skippedTotal = sum(Object.values(summary.skipped)) + return ( + `[devtools] filmstrip recorder: ${summary.shots} screenshots, total ${seconds(summary.total)}, median ${Math.round(summary.median)} ms; ` + + `${skippedTotal} ticks skipped${reasons.length ? ` (${reasons.join(', ')})` : ''}; ` + + `${summary.reused} capture screenshots reused as frames` + ) +} + +/** Appends the captures since the last report to the process's NDJSON file and prints their summary and the filmstrip recorder's line. Never throws. */ export async function reportCaptureTiming( outputDir: string, write: (text: string) => void = (text) => process.stderr.write(text) ): Promise { + const recorder = hasRecorderActivity() ? takeRecorderTiming() : undefined if (!pendingRecords.length) { + if (recorder) { + write(formatRecorderTiming(recorder) + '\n') + } return undefined } const records = pendingRecords.splice(0) @@ -314,5 +401,8 @@ export async function reportCaptureTiming( formatCaptureTimingSummary(summarizeCaptureTiming(records, wall), written) + '\n' ) + if (recorder) { + write(formatRecorderTiming(recorder) + '\n') + } return written } diff --git a/packages/core/src/driver-activity.ts b/packages/core/src/driver-activity.ts new file mode 100644 index 00000000..510c18e6 --- /dev/null +++ b/packages/core/src/driver-activity.ts @@ -0,0 +1,64 @@ +import { occupiesDriver } from '@wdio/devtools-shared' + +// A driver serves one session's requests one at a time, so a filmstrip poll +// issued while a command or a capture holds the driver only queues in front of +// the test, and a poll landing inside chromedriver's click (after it computed +// the element's coordinates, before it dispatches at them) makes the click +// report success while activating nothing. One instance per adapter bundle, not +// per session: both halves are module-scoped and must be imported from the same +// bundle to see the same gate. + +/** Bound on one window: a failing Nightwatch test discards its command queue, + * so the callback that closes a window may never fire. */ +export const DRIVER_ACTIVITY_MAX_HOLD_MS = 3000 + +const NOOP = (): void => {} + +export class InFlightGate { + readonly #openedAt = new Map() + #nextToken = 0 + + constructor(private readonly maxHoldMs = DRIVER_ACTIVITY_MAX_HOLD_MS) {} + + /** Open a window and return its closer; closing twice is harmless. */ + open(): () => void { + const token = this.#nextToken++ + this.#openedAt.set(token, Date.now()) + return () => { + this.#openedAt.delete(token) + } + } + + /** + * Whether an unexpired window is open. Expired windows are dropped here, so a + * closer that never runs costs one bounded stall rather than a permanent one + * and cannot leak entries for the rest of the run. + */ + isOpen(): boolean { + const cutoff = Date.now() - this.maxHoldMs + for (const [token, openedAt] of this.#openedAt) { + if (openedAt <= cutoff) { + this.#openedAt.delete(token) + } + } + return this.#openedAt.size > 0 + } +} + +const sharedGate = new InFlightGate() + +/** Adapter side: open the window around a runner command. Returns its closer, + * a no-op for a command that does not hold the driver (`occupiesDriver`). */ +export function beginDriverCommand(command: string): () => void { + return occupiesDriver(command) ? sharedGate.open() : NOOP +} + +/** Capture side: open the window around a per-action capture's probes. */ +export function beginDriverCapture(): () => void { + return sharedGate.open() +} + +/** Recorder side: is a command or a capture holding the driver right now? */ +export function isDriverBusy(): boolean { + return sharedGate.isOpen() +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7095bcf0..e710bfc3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -70,7 +70,7 @@ export * from './terminal-throttle.js' export * from './error.js' export * from './final-frame.js' export * from './finalize-screencast.js' -export * from './input-dispatch.js' +export * from './driver-activity.js' export * from './capture-mask.js' export * from './output-dir.js' export * from './performance-capture.js' diff --git a/packages/core/src/input-dispatch.ts b/packages/core/src/input-dispatch.ts deleted file mode 100644 index 80d8424a..00000000 --- a/packages/core/src/input-dispatch.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { isInputDispatchingCommand } from '@wdio/devtools-shared' - -// A screenshot landing inside chromedriver's click — after it computed the -// element's coordinates, before it dispatches at them — makes the click report -// success while activating nothing (CLAUDE.md § Known debt has the measurements). -// One instance per adapter bundle, not per session: both halves are module-scoped -// and must be imported from the same bundle to see the same gate. - -/** Bound on one command's suppression: a failing Nightwatch test discards its - * command queue, so the callback that closes a window may never fire. */ -export const INPUT_DISPATCH_MAX_HOLD_MS = 3000 - -const NOOP = (): void => {} - -export class InputDispatchGate { - readonly #openedAt = new Map() - #nextToken = 0 - - constructor(private readonly maxHoldMs = INPUT_DISPATCH_MAX_HOLD_MS) {} - - /** - * Open a suppression window for `command` and return its closer. Always - * returns a callable so call sites need no branch — for a command that - * dispatches no input the closer is a no-op and nothing is tracked. - */ - open(command: string): () => void { - if (!isInputDispatchingCommand(command)) { - return NOOP - } - const token = this.#nextToken++ - this.#openedAt.set(token, Date.now()) - return () => { - this.#openedAt.delete(token) - } - } - - /** - * Whether an unexpired window is open. Expired windows are dropped here, so a - * closer that never runs costs one bounded stall rather than a permanent one - * and cannot leak entries for the rest of the run. - */ - isOpen(): boolean { - const cutoff = Date.now() - this.maxHoldMs - for (const [token, openedAt] of this.#openedAt) { - if (openedAt <= cutoff) { - this.#openedAt.delete(token) - } - } - return this.#openedAt.size > 0 - } -} - -const sharedGate = new InputDispatchGate() - -/** Adapter side: open the window around a command. Returns its closer. */ -export function beginInputDispatch(command: string): () => void { - return sharedGate.open(command) -} - -/** Recorder side: is a driver command dispatching input right now? */ -export function isInputDispatchInFlight(): boolean { - return sharedGate.isOpen() -} diff --git a/packages/core/src/screencast.ts b/packages/core/src/screencast.ts index e82b31ed..10689520 100644 --- a/packages/core/src/screencast.ts +++ b/packages/core/src/screencast.ts @@ -1,6 +1,13 @@ import type { ScreencastFrame, ScreencastOptions } from '@wdio/devtools-shared' import { hasScreenshot, SCREENCAST_DEFAULTS } from '@wdio/devtools-shared' -import { isInputDispatchInFlight } from './input-dispatch.js' +import { performance } from 'node:perf_hooks' +import { isDriverBusy } from './driver-activity.js' +import { + recordRecorderReuse, + recordRecorderShot, + recordRecorderSkip, + type RecorderSkipReason +} from './capture-timing.js' import { withTimeout } from './with-timeout.js' import { errorMessage } from './error.js' @@ -33,6 +40,20 @@ export interface ScreencastLogger { * supports without extra plumbing. */ export abstract class ScreencastRecorderBase { + static readonly #recording = new Set>() + + /** + * A per-action capture's screenshot, offered as a filmstrip frame. Kept only + * when exactly one recorder is recording in this process and it polls: with + * several, nothing says which session the screenshot came from. + */ + static offerCapturedFrame(data: string, takenAt: number): void { + if (ScreencastRecorderBase.#recording.size === 1) { + const [recorder] = ScreencastRecorderBase.#recording + recorder.#acceptCapturedFrame(data, takenAt) + } + } + protected buffer: ScreencastFrame[] = [] protected options: Required protected driver?: TDriver @@ -42,6 +63,9 @@ export abstract class ScreencastRecorderBase { * against this: its frame belongs to the old recording, and the latch it * holds is not the successor's to clear. */ #pollGeneration = 0 + /** When the last reused capture screenshot was taken; a poll before the next + * interval would only repeat it. */ + #lastReusedAt = 0 #isRecording = false #cdpActive = false #startIndex = 0 @@ -96,9 +120,11 @@ export abstract class ScreencastRecorderBase { } const generation = ++this.#pollGeneration this.driver = driver + this.#lastReusedAt = 0 if (await this.tryStartCdp()) { this.#cdpActive = true this.#isRecording = true + ScreencastRecorderBase.#recording.add(this) return } await this.#startPolling(generation) @@ -119,6 +145,7 @@ export abstract class ScreencastRecorderBase { this.#stopPolling() } this.#isRecording = false + ScreencastRecorderBase.#recording.delete(this) } /** @@ -190,6 +217,21 @@ export abstract class ScreencastRecorderBase { this.#appendFrame({ data, timestamp }) } + #acceptCapturedFrame(data: string, takenAt: number): void { + if (this.#pollTimer === undefined || !hasScreenshot(data)) { + return + } + // A poll that settled after this shot already shows a later state, and + // appending behind it would put the filmstrip out of order. + const last = this.buffer[this.buffer.length - 1] + if (last && takenAt <= last.timestamp) { + return + } + this.#appendFrame({ data, timestamp: takenAt }) + this.#lastReusedAt = takenAt + recordRecorderReuse() + } + /** * Append a frame, decimating the buffer in place once it exceeds * `maxBufferFrames` so a long session can't grow it without bound. @@ -277,7 +319,7 @@ export abstract class ScreencastRecorderBase { */ async #takeFirstShot(generation: number): Promise { const first = await withTimeout( - this.takeScreenshot(), + this.#timedScreenshot(), SCREENCAST_HANDSHAKE_TIMEOUT_MS, FIRST_SHOT_TIMEOUT ) @@ -308,46 +350,72 @@ export abstract class ScreencastRecorderBase { this.#appendFrame({ data: first, timestamp: Date.now() }) const intervalMs = this.options.pollIntervalMs - this.#pollTimer = setInterval(async () => { - // A screenshot that reaches the driver mid-click makes the click report - // success while dispatching nothing, so the page never navigates. Drop - // the tick rather than race it — the gate is bounded, so a command whose - // completion never arrives costs frames only until it expires. - if (isInputDispatchInFlight()) { - return - } - // setInterval does not wait for this handler. A screenshot slower than - // the interval stacks requests that a serialised driver then serves - // ahead of the test's own commands (a native session's screenshot runs - // ~1.2 s against a 200 ms default) — keep at most one outstanding. - if (this.#pollInFlight) { - return - } - this.#pollInFlight = true - try { - const data = await this.takeScreenshot() - if (hasScreenshot(data) && generation === this.#pollGeneration) { - this.#appendFrame({ data, timestamp: Date.now() }) - } - } catch { - // Session ended mid-interval — stop polling gracefully. - if (generation === this.#pollGeneration) { - this.#stopPolling() - } - } finally { - if (generation === this.#pollGeneration) { - this.#pollInFlight = false - } - } + this.#pollTimer = setInterval(() => { + void this.#pollTick(generation) }, intervalMs) this.#isRecording = true + ScreencastRecorderBase.#recording.add(this) this.onPollingStarted(intervalMs) } catch (err) { this.onUnavailable(err) } } + async #timedScreenshot(): Promise { + const issued = performance.now() + try { + return await this.takeScreenshot() + } finally { + recordRecorderShot(performance.now() - issued) + } + } + + #skipReason(): RecorderSkipReason | undefined { + // A command or a capture holds the driver: a poll would queue in front of + // the test, and one landing mid-click makes the click report success while + // dispatching nothing. The gate is bounded, so a command whose completion + // never arrives costs frames only until it expires. + if (isDriverBusy()) { + return 'busy' + } + // setInterval does not wait for the tick. A screenshot slower than the + // interval stacks requests that a serialised driver then serves ahead of + // the test's own commands (a native session's screenshot runs ~1.2 s + // against a 200 ms default), so keep at most one outstanding. + if (this.#pollInFlight) { + return 'inFlight' + } + if (Date.now() - this.#lastReusedAt < this.options.pollIntervalMs) { + return 'fresh' + } + return undefined + } + + async #pollTick(generation: number): Promise { + const skip = this.#skipReason() + if (skip) { + recordRecorderSkip(skip) + return + } + this.#pollInFlight = true + try { + const data = await this.#timedScreenshot() + if (hasScreenshot(data) && generation === this.#pollGeneration) { + this.#appendFrame({ data, timestamp: Date.now() }) + } + } catch { + // Session ended mid-interval: stop polling gracefully. + if (generation === this.#pollGeneration) { + this.#stopPolling() + } + } finally { + if (generation === this.#pollGeneration) { + this.#pollInFlight = false + } + } + } + #stopPolling(): void { if (this.#pollTimer !== undefined) { clearInterval(this.#pollTimer) @@ -358,6 +426,7 @@ export abstract class ScreencastRecorderBase { // without waiting on it. this.#pollGeneration++ this.#pollInFlight = false + ScreencastRecorderBase.#recording.delete(this) this.onPollingStopped(this.buffer.length) } } diff --git a/packages/core/tests/action-snapshot.test.ts b/packages/core/tests/action-snapshot.test.ts index 3d722d9b..366b2531 100644 --- a/packages/core/tests/action-snapshot.test.ts +++ b/packages/core/tests/action-snapshot.test.ts @@ -1,6 +1,8 @@ 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' +import { isDriverBusy } from '../src/driver-activity.js' +import { ScreencastRecorderBase } from '../src/screencast.js' const okScript = () => Promise.resolve([]) @@ -156,7 +158,10 @@ describe('captureActionSnapshot locator dialect', () => { } it("threads the runner into both scripts' text branch", async () => { - const scripts = await injectedScripts({ command: 'click', runner: 'mocha' }) + const scripts = await injectedScripts({ + command: 'click', + runner: 'mocha' + }) expect(scripts).toHaveLength(2) for (const src of scripts) { @@ -184,3 +189,40 @@ describe('captureActionSnapshot locator dialect', () => { } }) }) + +describe('captureActionSnapshot and the filmstrip', () => { + it('holds the driver-activity gate until its probes settle', async () => { + let release: ((shot: string) => void) | undefined + const capture = captureActionSnapshot({ + command: 'click', + runScript: okScript, + takeScreenshot: () => + new Promise((resolve) => { + release = resolve + }) + }) + await vi.waitFor(() => expect(release).toBeDefined()) + expect(isDriverBusy()).toBe(true) + release?.('AA') + await capture + expect(isDriverBusy()).toBe(false) + }) + + it('offers its screenshot to a polling recorder as a frame', async () => { + class Recorder extends ScreencastRecorderBase { + protected override async takeScreenshot(): Promise { + return 'polled' + } + } + const recorder = new Recorder({ pollIntervalMs: 60_000 }) + await recorder.start({}) + await new Promise((resolve) => setTimeout(resolve, 2)) + await captureActionSnapshot({ + command: 'click', + runScript: okScript, + takeScreenshot: () => Promise.resolve('captured') + }) + await recorder.stop() + expect(recorder.frames.map((f) => f.data)).toEqual(['polled', 'captured']) + }) +}) diff --git a/packages/core/tests/capture-timing.test.ts b/packages/core/tests/capture-timing.test.ts index 6e1f0b78..64750ce4 100644 --- a/packages/core/tests/capture-timing.test.ts +++ b/packages/core/tests/capture-timing.test.ts @@ -9,10 +9,15 @@ import { type CaptureTimingRecord } from '@wdio/devtools-shared' import { captureActionSnapshot } from '../src/action-snapshot.js' +import { ScreencastRecorderBase } from '../src/screencast.js' import { CaptureTimer, captureTimingEnabled, formatCaptureTimingSummary, + formatRecorderTiming, + recordRecorderReuse, + recordRecorderShot, + recordRecorderSkip, reportCaptureTiming, startCaptureTimer, summarizeCaptureTiming @@ -314,3 +319,64 @@ describe('reporting', () => { expect(drained[1]).toContain('1 captures') }) }) + +describe('filmstrip recorder line', () => { + it('formats shots, skipped ticks by reason and reused frames', () => { + expect( + formatRecorderTiming({ + shots: 3, + total: 1830, + median: 600.4, + skipped: { busy: 31, fresh: 6, inFlight: 0 }, + reused: 6 + }) + ).toBe( + '[devtools] filmstrip recorder: 3 screenshots, total 1.8 s, median 600 ms; 37 ticks skipped (31 driver busy, 6 fresh frame); 6 capture screenshots reused as frames' + ) + }) + + it('records nothing while timing is off', async () => { + vi.stubEnv(CAPTURE_TIMING_ENV, '') + recordRecorderShot(100) + recordRecorderSkip('busy') + recordRecorderReuse() + await reportCaptureTiming(outDir, (t) => drained.push(t)) + expect(drained).toEqual([]) + }) + + it("prints the recorder's own screenshots and skips beside the capture summary", async () => { + vi.stubEnv(CAPTURE_TIMING_ENV, '1') + class Recorder extends ScreencastRecorderBase { + protected override async takeScreenshot(): Promise { + return 'polled' + } + } + const recorder = new Recorder({ pollIntervalMs: 60_000 }) + await recorder.start({}) + await new Promise((resolve) => setTimeout(resolve, 2)) + await captureActionSnapshot({ + command: 'click', + takeScreenshot: () => Promise.resolve('captured') + }) + await recorder.stop() + recordRecorderSkip('busy') + await reportCaptureTiming(outDir, (t) => drained.push(t)) + expect(drained[0]).toContain('1 captures') + expect(drained[1]).toMatch( + /^\[devtools\] filmstrip recorder: 1 screenshots, .*; 1 ticks skipped \(1 driver busy\); 1 capture screenshots reused as frames\n$/ + ) + }) + + it('prints the recorder line alone when no capture ran', async () => { + vi.stubEnv(CAPTURE_TIMING_ENV, '1') + recordRecorderShot(250) + recordRecorderShot(150) + recordRecorderSkip('inFlight') + expect( + await reportCaptureTiming(outDir, (t) => drained.push(t)) + ).toBeUndefined() + expect(drained).toEqual([ + '[devtools] filmstrip recorder: 2 screenshots, total 0.4 s, median 200 ms; 1 ticks skipped (1 shot in flight); 0 capture screenshots reused as frames\n' + ]) + }) +}) diff --git a/packages/core/tests/driver-activity.test.ts b/packages/core/tests/driver-activity.test.ts new file mode 100644 index 00000000..d9c48ffa --- /dev/null +++ b/packages/core/tests/driver-activity.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest' +import { occupiesDriver } from '@wdio/devtools-shared' +import { + InFlightGate, + beginDriverCapture, + beginDriverCommand, + isDriverBusy +} from '../src/driver-activity.js' + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +describe('occupiesDriver', () => { + it('holds the driver for input, reads, navigation and unknown commands', () => { + for (const command of [ + 'click', + 'doubleClick', + 'moveTo', + 'dragAndDrop', + 'setValue', + 'sendKeys', + 'keys', + 'moveToElement', + 'mouseButtonClick', + 'rightClick', + 'submitForm', + 'getText', + 'isDisplayed', + 'getCurrentUrl', + 'url', + 'execute', + 'takeScreenshot', + 'notACommand' + ]) { + expect(occupiesDriver(command)).toBe(true) + } + }) + + it('leaves waits and host-side commands alone', () => { + for (const command of [ + 'waitUntil', + 'waitForDisplayed', + 'waitForExist', + 'waitForElementVisible', + 'waitForElementPresent', + 'pause', + 'debug', + 'call', + 'perform' + ]) { + expect(occupiesDriver(command)).toBe(false) + } + }) +}) + +describe('InFlightGate', () => { + it('opens on open and closes on release', () => { + const gate = new InFlightGate() + expect(gate.isOpen()).toBe(false) + const close = gate.open() + expect(gate.isOpen()).toBe(true) + close() + expect(gate.isOpen()).toBe(false) + }) + + it('stays open until the last of several overlapping windows closes', () => { + const gate = new InFlightGate() + const closeClick = gate.open() + const closeKeys = gate.open() + closeClick() + expect(gate.isOpen()).toBe(true) + closeKeys() + expect(gate.isOpen()).toBe(false) + }) + + it('recovers after the bound when a window never closes', async () => { + const gate = new InFlightGate(20) + gate.open() + expect(gate.isOpen()).toBe(true) + await sleep(40) + expect(gate.isOpen()).toBe(false) + }) + + it('closing an already-expired window is harmless', async () => { + const gate = new InFlightGate(20) + const close = gate.open() + await sleep(40) + expect(gate.isOpen()).toBe(false) + close() + expect(gate.isOpen()).toBe(false) + }) +}) + +describe('process-wide gate', () => { + it('opens for a command that holds the driver', () => { + expect(isDriverBusy()).toBe(false) + const close = beginDriverCommand('click') + try { + expect(isDriverBusy()).toBe(true) + } finally { + close() + } + expect(isDriverBusy()).toBe(false) + }) + + it('opens nothing for a wait, and its closer is a harmless no-op', () => { + const close = beginDriverCommand('waitForDisplayed') + expect(isDriverBusy()).toBe(false) + expect(() => close()).not.toThrow() + }) + + it('opens for a capture', () => { + const close = beginDriverCapture() + try { + expect(isDriverBusy()).toBe(true) + } finally { + close() + } + expect(isDriverBusy()).toBe(false) + }) +}) diff --git a/packages/core/tests/input-dispatch.test.ts b/packages/core/tests/input-dispatch.test.ts deleted file mode 100644 index d164eef4..00000000 --- a/packages/core/tests/input-dispatch.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { isInputDispatchingCommand } from '@wdio/devtools-shared' -import { - InputDispatchGate, - beginInputDispatch, - isInputDispatchInFlight -} from '../src/input-dispatch.js' - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - -describe('isInputDispatchingCommand', () => { - it('classifies pointer and keyboard commands across runners', () => { - for (const command of [ - 'click', - 'doubleClick', - 'moveTo', - 'dragAndDrop', - 'touchAction', - 'scrollIntoView', - 'selectByVisibleText', - 'setValue', - 'sendKeys', - 'addValue', - 'clearValue', - 'clear', - 'keys' - ]) { - expect(isInputDispatchingCommand(command)).toBe(true) - } - }) - - it("covers runner-native pointer commands ACTION_MAP doesn't name", () => { - for (const command of [ - 'moveToElement', - 'mouseButtonClick', - 'mouseButtonDown', - 'mouseButtonUp', - 'rightClick', - 'submitForm' - ]) { - expect(isInputDispatchingCommand(command)).toBe(true) - } - }) - - it('leaves reads, waits, navigation and unknown commands alone', () => { - for (const command of [ - 'getText', - 'isDisplayed', - 'getCurrentUrl', - 'waitForElementVisible', - 'url', - 'execute', - 'takeScreenshot', - 'notACommand' - ]) { - expect(isInputDispatchingCommand(command)).toBe(false) - } - }) -}) - -describe('InputDispatchGate', () => { - it('opens for an input command and closes on release', () => { - const gate = new InputDispatchGate() - expect(gate.isOpen()).toBe(false) - const close = gate.open('click') - expect(gate.isOpen()).toBe(true) - close() - expect(gate.isOpen()).toBe(false) - }) - - it('never opens for a command that dispatches no input', () => { - const gate = new InputDispatchGate() - const close = gate.open('getText') - expect(gate.isOpen()).toBe(false) - expect(() => close()).not.toThrow() - }) - - it('stays open until the last of several overlapping commands closes', () => { - const gate = new InputDispatchGate() - const closeClick = gate.open('click') - const closeKeys = gate.open('setValue') - closeClick() - expect(gate.isOpen()).toBe(true) - closeKeys() - expect(gate.isOpen()).toBe(false) - }) - - it('recovers after the bound when a command never closes its window', async () => { - const gate = new InputDispatchGate(20) - gate.open('click') // deliberately never closed - expect(gate.isOpen()).toBe(true) - await sleep(40) - expect(gate.isOpen()).toBe(false) - }) - - it('closing an already-expired window is harmless', async () => { - const gate = new InputDispatchGate(20) - const close = gate.open('click') - await sleep(40) - expect(gate.isOpen()).toBe(false) - close() - expect(gate.isOpen()).toBe(false) - }) -}) - -describe('process-wide gate', () => { - it('routes begin/close through the shared instance', () => { - expect(isInputDispatchInFlight()).toBe(false) - const close = beginInputDispatch('click') - try { - expect(isInputDispatchInFlight()).toBe(true) - } finally { - close() - } - expect(isInputDispatchInFlight()).toBe(false) - }) -}) diff --git a/packages/core/tests/screencast.test.ts b/packages/core/tests/screencast.test.ts index cc2a2bea..7df492ed 100644 --- a/packages/core/tests/screencast.test.ts +++ b/packages/core/tests/screencast.test.ts @@ -4,9 +4,10 @@ import { type ScreencastLogger } from '../src/screencast.js' import { - INPUT_DISPATCH_MAX_HOLD_MS, - beginInputDispatch -} from '../src/input-dispatch.js' + DRIVER_ACTIVITY_MAX_HOLD_MS, + beginDriverCapture, + beginDriverCommand +} from '../src/driver-activity.js' class TestRecorder extends ScreencastRecorderBase<{ name: string }> { shotsTaken = 0 @@ -151,13 +152,13 @@ describe('ScreencastRecorderBase — polling path', () => { }) }) -describe('ScreencastRecorderBase — input-dispatch gate', () => { - it('drops ticks while an input command is in flight, resumes once it settles', async () => { +describe('ScreencastRecorderBase: driver-activity gate', () => { + it('drops ticks while a command is in flight, resumes once it settles', async () => { vi.useFakeTimers() const r = new TestRecorder({ pollIntervalMs: 50 }) await r.start({ name: 'driver' }) const suppressed = r.bufferLength - const close = beginInputDispatch('click') + const close = beginDriverCommand('getText') try { await vi.advanceTimersByTimeAsync(150) // 3 ticks, all skipped expect(r.bufferLength).toBe(suppressed) @@ -170,12 +171,30 @@ describe('ScreencastRecorderBase — input-dispatch gate', () => { vi.useRealTimers() }) - it('keeps polling through a command that dispatches no input', async () => { + it('drops ticks while a per-action capture holds the driver', async () => { + vi.useFakeTimers() + const r = new TestRecorder({ pollIntervalMs: 50 }) + await r.start({ name: 'driver' }) + const shots = r.shotsTaken + const close = beginDriverCapture() + try { + await vi.advanceTimersByTimeAsync(150) + expect(r.shotsTaken).toBe(shots) + } finally { + close() + } + await vi.advanceTimersByTimeAsync(50) + expect(r.shotsTaken).toBe(shots + 1) + await r.stop() + vi.useRealTimers() + }) + + it('keeps polling through a wait, whose polls signal on their own', async () => { vi.useFakeTimers() const r = new TestRecorder({ pollIntervalMs: 50 }) await r.start({ name: 'driver' }) const before = r.bufferLength - const close = beginInputDispatch('getText') + const close = beginDriverCommand('waitForDisplayed') try { await vi.advanceTimersByTimeAsync(150) expect(r.bufferLength).toBeGreaterThan(before) @@ -193,11 +212,11 @@ describe('ScreencastRecorderBase — input-dispatch gate', () => { const stalled = r.bufferLength // Stands in for a command whose completion callback never fires — a failing // Nightwatch test discards the rest of its queue. - const close = beginInputDispatch('click') + const close = beginDriverCommand('click') try { await vi.advanceTimersByTimeAsync(150) expect(r.bufferLength).toBe(stalled) - await vi.advanceTimersByTimeAsync(INPUT_DISPATCH_MAX_HOLD_MS) + await vi.advanceTimersByTimeAsync(DRIVER_ACTIVITY_MAX_HOLD_MS) expect(r.bufferLength).toBeGreaterThan(stalled) } finally { close() @@ -466,6 +485,7 @@ describe('ScreencastRecorderBase — start/stop serialisation', () => { expect(r.stopped).toEqual(['s1']) expect(r.session).toBe('s2') expect(r.isRecording).toBe(true) + await r.stop() }) }) @@ -616,3 +636,80 @@ describe('ScreencastRecorderBase: lifecycle logging', () => { expect(r.isRecording).toBe(false) }) }) + +describe('ScreencastRecorderBase: reused capture screenshots', () => { + it('keeps a capture screenshot as a frame, in order, and skips the poll it replaces', async () => { + vi.useFakeTimers() + const r = new TestRecorder({ pollIntervalMs: 50 }) + await r.start({ name: 'driver' }) + await vi.advanceTimersByTimeAsync(30) + ScreencastRecorderBase.offerCapturedFrame('capture-1', Date.now()) + await vi.advanceTimersByTimeAsync(20) // tick 20 ms after the capture: skipped + expect(r.shotsTaken).toBe(1) + await vi.advanceTimersByTimeAsync(50) // the next one polls again + expect(r.shotsTaken).toBe(2) + await r.stop() + + expect(r.frames.map((f) => f.data)).toEqual([ + 'frame-1', + 'capture-1', + 'frame-2' + ]) + const stamps = r.frames.map((f) => f.timestamp) + expect([...stamps].sort((a, b) => a - b)).toEqual(stamps) + vi.useRealTimers() + }) + + it('drops a shot no newer than the newest frame, so nothing is duplicated or reordered', async () => { + vi.useFakeTimers() + const r = new TestRecorder({ pollIntervalMs: 50 }) + await r.start({ name: 'driver' }) + const newest = r.frames[r.frames.length - 1].timestamp + ScreencastRecorderBase.offerCapturedFrame('stale', newest - 1) + await vi.advanceTimersByTimeAsync(10) + const at = Date.now() + ScreencastRecorderBase.offerCapturedFrame('capture', at) + ScreencastRecorderBase.offerCapturedFrame('capture', at) + await r.stop() + expect(r.frames.map((f) => f.data)).toEqual(['frame-1', 'capture']) + vi.useRealTimers() + }) + + it('takes nothing once stopped, from CDP push mode, or while two recorders record', async () => { + class PushRecorder extends TestRecorder { + protected override async tryStartCdp(): Promise { + return true + } + } + const push = new PushRecorder() + await push.start({ name: 'chrome' }) + ScreencastRecorderBase.offerCapturedFrame('to-push', Date.now() + 1) + expect(push.bufferLength).toBe(0) + + const polling = new TestRecorder({ pollIntervalMs: 60_000 }) + await polling.start({ name: 'safari' }) + ScreencastRecorderBase.offerCapturedFrame('ambiguous', Date.now() + 1) + expect(polling.bufferLength).toBe(1) + + await push.stop() + ScreencastRecorderBase.offerCapturedFrame('mine', Date.now() + 2) + expect(polling.frames.map((f) => f.data)).toEqual(['frame-1', 'mine']) + + await polling.stop() + ScreencastRecorderBase.offerCapturedFrame('after-stop', Date.now() + 3) + expect(polling.bufferLength).toBe(2) + }) + + it('bounds reused frames by maxBufferFrames like polled ones', async () => { + const r = new TestRecorder({ pollIntervalMs: 60_000, maxBufferFrames: 4 }) + await r.start({ name: 'driver' }) + const t0 = Date.now() + for (let i = 1; i <= 10; i++) { + ScreencastRecorderBase.offerCapturedFrame(`capture-${i}`, t0 + i) + } + await r.stop() + expect(r.bufferLength).toBeLessThanOrEqual(4) + expect(r.frames[0].data).toBe('frame-1') + expect(r.frames[r.frames.length - 1].data).toBe('capture-10') + }) +}) diff --git a/packages/nightwatch-devtools/src/helpers/browserProxy.ts b/packages/nightwatch-devtools/src/helpers/browserProxy.ts index 320d052a..c2ce7d44 100644 --- a/packages/nightwatch-devtools/src/helpers/browserProxy.ts +++ b/packages/nightwatch-devtools/src/helpers/browserProxy.ts @@ -17,7 +17,7 @@ import { WaitRows } from './waitRows.js' import { wrapNavigation } from './navigationWrap.js' import { RetryTracker, - beginInputDispatch, + beginDriverCommand, clearReadValues, rememberReadValue, toError @@ -571,22 +571,22 @@ export class BrowserProxy { const cmd = this.pendingCommand(methodName, args) const captureCallback = this.makeCaptureCallback(browser, cmd) - // Suppress screencast polling while this command may be dispatching input: a - // screenshot landing mid-click makes the click activate nothing. Enqueue is - // the only hook the adapter has — Nightwatch executes off a queue it exposes - // no start event for — so an awaited call (enqueue ≈ execution) is covered, - // while one queued behind a long wait can see its window expire before it - // runs. The bound is what keeps that from stalling the recorder instead. - const closeInputWindow = beginInputDispatch(methodName) + // Holds the filmstrip poll off the driver while this command runs (see core + // driver-activity.ts). Enqueue is the only hook the adapter has, Nightwatch + // executes off a queue it exposes no start event for, so an awaited call + // (enqueue ≈ execution) is covered, while one queued behind a long wait can + // see its window expire before it runs. The bound is what keeps that from + // stalling the recorder instead. + const closeCommandWindow = beginDriverCommand(methodName) const gatedCallback = (callbackResult: unknown) => { - closeInputWindow() + closeCommandWindow() return captureCallback(callbackResult) } const modifiedArgs = [...this.waits.args(cmd), gatedCallback] try { return originalMethod(...modifiedArgs) } catch (error) { - closeInputWindow() + closeCommandWindow() this.popCommandStackIfMatches(methodName, cmd.cmdSig) if (cmd.wait) { this.closeWait(browser, cmd.wait, undefined, toError(error), Date.now()) diff --git a/packages/nightwatch-devtools/tests/browserProxy.test.ts b/packages/nightwatch-devtools/tests/browserProxy.test.ts index 911d45f3..c5dcbd18 100644 --- a/packages/nightwatch-devtools/tests/browserProxy.test.ts +++ b/packages/nightwatch-devtools/tests/browserProxy.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { selectorForReadValue } from '@wdio/devtools-core' +import { + DRIVER_ACTIVITY_MAX_HOLD_MS, + isDriverBusy, + selectorForReadValue +} from '@wdio/devtools-core' import type { CommandCaptureMeta, CommandLog, @@ -887,3 +891,42 @@ describe('BrowserProxy capture ordering after a DOM-mutating command', () => { expect(capturer.nativeNetwork.forward).not.toHaveBeenCalled() }) }) + +describe('BrowserProxy driver-activity window', () => { + beforeEach(() => { + getCallSourceFromStack.mockReset() + getCallSourceFromStack.mockReturnValue({ + filePath: '/tests/spec.js', + callSource: '/tests/spec.js:9' + }) + // The gate is module-scoped in core, so prune anything an earlier case left + // open: jump past the hold bound and read once. + vi.useFakeTimers() + vi.setSystemTime(Date.now() + DRIVER_ACTIVITY_MAX_HOLD_MS + 1) + isDriverBusy() + vi.useRealTimers() + }) + + // The filmstrip poller drops its tick while this is open, so a poll does not + // queue in front of the command on the driver. + it('holds the window from enqueue until the command calls back', () => { + let callBack: ((result: unknown) => void) | undefined + const browser = { + getText: (...args: unknown[]) => { + callBack = args[args.length - 1] as (result: unknown) => void + return undefined + } + } as unknown as NightwatchBrowser + const { capturer } = makeCapturer() + new BrowserProxy(capturer, makeTestManager(), () => ({ + uid: 'uid-a' + })).wrapBrowserCommands(browser) + + ;( + browser as unknown as Record unknown> + ).getText('#flash') + expect(isDriverBusy()).toBe(true) + callBack?.({ value: 'hi' }) + expect(isDriverBusy()).toBe(false) + }) +}) diff --git a/packages/selenium-devtools/src/driverPatcher.ts b/packages/selenium-devtools/src/driverPatcher.ts index cf89c3a1..a459d2bd 100644 --- a/packages/selenium-devtools/src/driverPatcher.ts +++ b/packages/selenium-devtools/src/driverPatcher.ts @@ -1,7 +1,7 @@ import { createRequire } from 'node:module' import logger from '@wdio/logger' import { - beginInputDispatch, + beginDriverCommand, errorMessage, rememberReadValue } from '@wdio/devtools-core' @@ -142,10 +142,10 @@ const INPUT_ELEMENT_METHODS = new Set(['click', 'sendKeys', 'clear', 'submit']) function makeSettle( hooks: DriverPatcherHooks, meta: Omit, - closeInputWindow: () => void + closeCommandWindow: () => void ): (result: unknown, error: Error | undefined) => void { return (result, error) => { - closeInputWindow() + closeCommandWindow() // An assertion names no element — it acts on the value a read resolved to — // so the value is what carries the locator forward to the assertion row. if (!error) { @@ -197,16 +197,13 @@ function makeWrappedMethod( // Before the instrumentation below: serializing args and walking the stack // both cost time that would otherwise land inside the row's own duration. const startTime = Date.now() - // Suppresses the screencast recorder's screenshot polling until this command - // settles: a poll landing inside chromedriver's click, between computing the - // element's coordinates and dispatching at them, makes the click report - // success while activating nothing. Only the non-Chrome polling path is - // affected — Chrome uses push-based CDP screencast and never polls. - const closeInputWindow = beginInputDispatch(methodName) + // Holds the filmstrip poll off the driver until this command settles (see + // core driver-activity.ts); CDP push mode never polls. + const closeCommandWindow = beginDriverCommand(methodName) const settle = makeSettle( hooks, describeCall(methodName, args, fromElement, this, startTime), - closeInputWindow + closeCommandWindow ) let result: unknown diff --git a/packages/selenium-devtools/tests/index.test.ts b/packages/selenium-devtools/tests/index.test.ts index 5f6c48f2..b633d591 100644 --- a/packages/selenium-devtools/tests/index.test.ts +++ b/packages/selenium-devtools/tests/index.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { - INPUT_DISPATCH_MAX_HOLD_MS, + DRIVER_ACTIVITY_MAX_HOLD_MS, RetryTracker, - isInputDispatchInFlight + isDriverBusy } from '@wdio/devtools-core' import { createRequire } from 'node:module' import * as fs from 'node:fs' @@ -512,16 +512,15 @@ describe('driverPatcher', () => { } }) - // A screenshot poll landing inside chromedriver's click, between computing the - // element's coordinates and dispatching at them, makes the click report success - // while activating nothing. `settle` runs on every path, so the window it closes - // must survive a resolve, a reject and a sync throw. - it('holds a screencast-poll suppression window until an input command settles', async () => { + // A filmstrip poll issued while a command holds the driver queues in front of + // it, or lands inside a click's dispatch. `settle` runs on every path, so the + // window it closes must survive a resolve, a reject and a sync throw. + it('holds the driver-activity window until a command settles', async () => { // The gate is module-scoped in core, so drop anything an earlier case left // open: jump past the hold bound and read once, which is what prunes it. vi.useFakeTimers() - vi.setSystemTime(Date.now() + INPUT_DISPATCH_MAX_HOLD_MS + 1) - isInputDispatchInFlight() + vi.setSystemTime(Date.now() + DRIVER_ACTIVITY_MAX_HOLD_MS + 1) + isDriverBusy() vi.useRealTimers() ;(driverProto as any).click = function () { return Promise.resolve('ok') @@ -536,14 +535,14 @@ describe('driverPatcher', () => { const fakeDriver = Object.create(driverProto) const pending = (fakeDriver as any).click() - expect(isInputDispatchInFlight()).toBe(true) + expect(isDriverBusy()).toBe(true) await pending await new Promise((r) => setImmediate(r)) - expect(isInputDispatchInFlight()).toBe(false) + expect(isDriverBusy()).toBe(false) // A command that throws synchronously settles too, so its window closes. expect(() => (fakeDriver as any).clickBoom()).toThrow('boom') - expect(isInputDispatchInFlight()).toBe(false) + expect(isDriverBusy()).toBe(false) for (const m of ['click', 'clickBoom']) { delete (driverProto as any)[m] diff --git a/packages/service/src/index.ts b/packages/service/src/index.ts index 784bed30..52ea613e 100644 --- a/packages/service/src/index.ts +++ b/packages/service/src/index.ts @@ -2,7 +2,7 @@ import logger from '@wdio/logger' import { attachTraceArtifact, - beginInputDispatch, + beginDriverCommand, captureAndAttachScreenshot, clearReadValues, configureInputMasking, @@ -150,7 +150,7 @@ export default class DevToolsHookService implements Services.ServiceInstance { * slot because WDIO serialises commands per session; a concurrent pair (e.g. * multiremote) closes the earlier window early, degrading to unguarded * polling rather than stalling the recorder. */ - #closeInputWindow?: () => void + #closeCommandWindow?: () => void /** Current test UID, set in beforeTest(), used by afterCommand() to tag commands. */ #currentTestUid?: string @@ -713,8 +713,8 @@ export default class DevToolsHookService implements Services.ServiceInstance { this.#commandStack.reset() // No command is in flight after a reset; without this a window stranded by a // spec boundary would keep dropping frames until its bound expired. - this.#closeInputWindow?.() - this.#closeInputWindow = undefined + this.#closeCommandWindow?.() + this.#closeCommandWindow = undefined this.#assertionTracker.reset() this.#sessionCapturer.resetLastSelector() this.#sessionCapturer.resetRetryTracker() @@ -790,13 +790,10 @@ export default class DevToolsHookService implements Services.ServiceInstance { if (!this.#browser || isServiceProbe(command)) { return } - // Suppress screenshot polling for the duration of an input-dispatching - // command: a poll landing inside chromedriver's click, between computing the - // element's coordinates and dispatching at them, makes the click report - // success while activating nothing. Opened before the pre-action probes below - // so it spans the whole command. - this.#closeInputWindow?.() - this.#closeInputWindow = beginInputDispatch(command) + // Holds the filmstrip poll off the driver for the whole command (see + // driver-activity.ts); opened before the pre-action probes so it spans them. + this.#closeCommandWindow?.() + this.#closeCommandWindow = beginDriverCommand(command) // `url` is the one start-of-recording signal every runner reaches (Mocha, // Jasmine, Cucumber, standalone), so the video's start marker hangs off it. if (command === 'url') { @@ -900,8 +897,8 @@ export default class DevToolsHookService implements Services.ServiceInstance { if (this.#injecting || isServiceProbe(command)) { return } - this.#closeInputWindow?.() - this.#closeInputWindow = undefined + this.#closeCommandWindow?.() + this.#closeCommandWindow = undefined // Record every element resolution, even those below the top-level command // boundary — an `expect($('#flash'))` resolves its element inside the diff --git a/packages/service/tests/index.test.ts b/packages/service/tests/index.test.ts index df1d7e20..1e0a1332 100644 --- a/packages/service/tests/index.test.ts +++ b/packages/service/tests/index.test.ts @@ -3,8 +3,8 @@ import type * as DevtoolsCore from '@wdio/devtools-core' import { captureMask, configureInputMasking, - INPUT_DISPATCH_MAX_HOLD_MS, - isInputDispatchInFlight, + DRIVER_ACTIVITY_MAX_HOLD_MS, + isDriverBusy, rememberReadValue, selectorForReadValue } from '@wdio/devtools-core' @@ -357,18 +357,16 @@ describe('DevtoolsService - Internal Command Filtering', () => { }) }) - // A screenshot poll landing inside chromedriver's click, between computing the - // element's coordinates and dispatching at them, makes the click report success - // while activating nothing. The recorder drops a tick while this window is open, - // so the service has to open one around every input-dispatching command. - describe('beforeCommand - input-dispatch window', () => { + // The filmstrip recorder drops a tick while this window is open, so a poll + // neither queues in front of a command nor lands inside a click's dispatch. + describe('beforeCommand - driver-activity window', () => { // The gate is module-scoped in core (one per adapter bundle), so a window // left open by an earlier case in this file is still open here. Jump past the // hold bound and read once, which is what prunes it. const flushInputWindows = () => { vi.useFakeTimers() - vi.setSystemTime(Date.now() + INPUT_DISPATCH_MAX_HOLD_MS + 1) - isInputDispatchInFlight() + vi.setSystemTime(Date.now() + DRIVER_ACTIVITY_MAX_HOLD_MS + 1) + isDriverBusy() vi.useRealTimers() } @@ -378,17 +376,25 @@ describe('DevtoolsService - Internal Command Filtering', () => { flushInputWindows() }) - it('holds the window open for the duration of an input command', async () => { + it('holds the window open for the duration of a command', async () => { await service.beforeCommand('click' as any, ['.button']) - expect(isInputDispatchInFlight()).toBe(true) + expect(isDriverBusy()).toBe(true) await service.afterCommand('click' as any, ['.button'], {}) - expect(isInputDispatchInFlight()).toBe(false) + expect(isDriverBusy()).toBe(false) }) - it('opens no window for a command that dispatches no input', async () => { + it('holds the window for a read too, since it queues on the driver', async () => { await service.beforeCommand('getText' as any, ['#flash']) - expect(isInputDispatchInFlight()).toBe(false) + expect(isDriverBusy()).toBe(true) await service.afterCommand('getText' as any, ['#flash'], 'text') + expect(isDriverBusy()).toBe(false) + }) + + it('opens no window for a wait or a host-side pause', async () => { + await service.beforeCommand('waitUntil' as any, []) + expect(isDriverBusy()).toBe(false) + await service.beforeCommand('pause' as any, ['100']) + expect(isDriverBusy()).toBe(false) }) // A window stranded by a command whose afterCommand never paired (a failed @@ -396,9 +402,9 @@ describe('DevtoolsService - Internal Command Filtering', () => { // into the next test until its bound expired. it('closes the window at the next test boundary', async () => { await service.beforeCommand('setValue' as any, ['#username', 'foo']) - expect(isInputDispatchInFlight()).toBe(true) + expect(isDriverBusy()).toBe(true) service.beforeTest({ file: '/test/specs/fake.spec.ts', title: 'next' }) - expect(isInputDispatchInFlight()).toBe(false) + expect(isDriverBusy()).toBe(false) }) }) diff --git a/packages/shared/src/action-mapping.ts b/packages/shared/src/action-mapping.ts index 54edaea4..325810ba 100644 --- a/packages/shared/src/action-mapping.ts +++ b/packages/shared/src/action-mapping.ts @@ -38,6 +38,25 @@ export function isWaitCommand(command: string): boolean { return isWaitAction(mapCommandToAction(command)) } +/** Runner commands that run on the test host and send the driver nothing. */ +const HOST_ONLY_COMMANDS: ReadonlySet = new Set([ + 'pause', + 'debug', + 'call', + 'perform', + 'useCss', + 'useXpath' +]) + +/** + * Whether a runner command holds the driver for as long as it runs. A wait does + * not: the driver is idle between its polls, and each poll that goes through + * the runner is a command of its own. + */ +export function occupiesDriver(command: string): boolean { + return !HOST_ONLY_COMMANDS.has(command) && !isWaitCommand(command) +} + const ASSERT_TITLE_VALUE_MAX = 40 function formatAssertValue(value: unknown): string { diff --git a/packages/shared/src/trace-actions.ts b/packages/shared/src/trace-actions.ts index a60ce0ed..0a5effaf 100644 --- a/packages/shared/src/trace-actions.ts +++ b/packages/shared/src/trace-actions.ts @@ -239,36 +239,6 @@ export function isKeyboardCommand(command: string): boolean { return KEYBOARD_COMMANDS.has(command) } -/** Runner-native pointer commands the trace vocabulary deliberately doesn't - * name — Nightwatch's low-level mouse API has no meaningful trace method, so - * adding it to ACTION_MAP would put noise in the timeline. Listed here because - * they still dispatch, and a command that dispatches unrecognized keeps the - * screenshot poll running straight through it. */ -const UNMAPPED_POINTER_COMMANDS: ReadonlySet = new Set([ - 'moveToElement', - 'mouseButtonClick', - 'mouseButtonDown', - 'mouseButtonUp', - 'rightClick', - 'submitForm' -]) - -/** - * Whether a runner command makes the driver dispatch trusted input into the - * page. Derived from the vocabulary above rather than declared as a second list: - * a command qualifies when it types (`isKeyboardCommand`) or when its trace - * method acts at a point (`POINTABLE_METHODS`). A command wrongly classified as - * non-dispatching is the costly direction — it leaves the screencast poll racing - * the dispatch — so the unmapped pointer commands above are named explicitly. - */ -export function isInputDispatchingCommand(command: string): boolean { - if (isKeyboardCommand(command) || UNMAPPED_POINTER_COMMANDS.has(command)) { - return true - } - const action = ACTION_MAP[command] - return action !== undefined && POINTABLE_METHODS.has(action.method) -} - /** Driver lookups and reads that never change the page and have no ACTION_MAP * entry; the ACTION_MAP reads are covered by METHOD_EFFECT in `skipsCapture`. */ export const READ_ONLY_COMMANDS: ReadonlySet = new Set([ From a942123e395dc72a368e32baf4dac980955a2166 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Tue, 10 Nov 2026 17:09:48 +0530 Subject: [PATCH 02/10] Document the filmstrip poll gate, frame reuse and recorder timing line Co-Authored-By: Claude Opus 5.5 --- .changeset/filmstrip-poll-queue.md | 7 +++++++ CLAUDE.md | 4 ++-- README.md | 6 ++++-- packages/nightwatch-devtools/README.md | 2 +- packages/selenium-devtools/README.md | 4 ++-- packages/service/README.md | 2 +- 6 files changed, 17 insertions(+), 8 deletions(-) create mode 100644 .changeset/filmstrip-poll-queue.md diff --git a/.changeset/filmstrip-poll-queue.md b/.changeset/filmstrip-poll-queue.md new file mode 100644 index 00000000..fa7b2f34 --- /dev/null +++ b/.changeset/filmstrip-poll-queue.md @@ -0,0 +1,7 @@ +--- +'@wdio/devtools-service': patch +'@wdio/selenium-devtools': patch +'@wdio/nightwatch-devtools': patch +--- + +Without a CDP screencast (Safari, Appium), the `filmstrip` recorder no longer queues its screenshots in front of the test. It skips a poll while a command or a per-action capture holds the driver, keeps each per-action capture's screenshot as a filmstrip frame, and skips the poll that would only repeat it. The interval and the filmstrip default are unchanged, and Chrome's CDP push mode is untouched. With `DEVTOOLS_CAPTURE_TIMING=1`, the report adds a `filmstrip recorder` line: the screenshots the recorder took and their time, the polls it skipped and why, and the capture screenshots it kept as frames. diff --git a/CLAUDE.md b/CLAUDE.md index 6de6eb7d..e101fb69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -396,10 +396,10 @@ Documented divergences from the conventions above. They exist today as debt to b ### What a per-action trace capture costs -- **Measure before proposing a change: `DEVTOOLS_CAPTURE_TIMING=1`.** Every adapter then times each probe of each capture (issue and settle, so a probe's own service time is separable from its wait behind the probes the driver served first) and, when the trace is written, prints a per-probe table and appends the raw records to `capture-timing-.ndjson` beside the trace. The JS recording is `core/capture-timing.ts`, wired once in `captureActionSnapshot` and reported from `finalizeTraceExport`, so no adapter carries more than the capture's trigger (action, wait poll, matcher poll, final); Python mirrors it in `capture_timing.py`, held to the same summary by `mirror-vectors.json`. Off, it costs a flag read per capture. The per-platform commands are in the README's "Measuring capture cost". +- **Measure before proposing a change: `DEVTOOLS_CAPTURE_TIMING=1`.** Every adapter then times each probe of each capture (issue and settle, so a probe's own service time is separable from its wait behind the probes the driver served first) and, when the trace is written, prints a per-probe table and appends the raw records to `capture-timing-.ndjson` beside the trace. The JS recording is `core/capture-timing.ts`, wired once in `captureActionSnapshot` and reported from `finalizeTraceExport`, so no adapter carries more than the capture's trigger (action, wait poll, matcher poll, final); Python mirrors it in `capture_timing.py`, held to the same summary by `mirror-vectors.json`. Off, it costs a flag read per capture. The per-platform commands are in the README's "Measuring capture cost". The polling filmstrip recorder's own screenshots are counted beside it (`recordRecorderShot`/`recordRecorderSkip`/`recordRecorderReuse`, one `filmstrip recorder:` line, not written to the NDJSON); Python has no polling recorder and prints none, so the mirrored summary is unchanged. - **One capture per action means two driver round trips per action, and the platform decides which half is expensive.** Measured on Appium 3.7.0 / UiAutomator2 (emulator-5554, Android API 37, 1080×2424): `GET /screenshot` 1.18–2.22 s (steady ~1.20 s) at 1.86 MB, `GET /source` (page-source XML) 0.09–0.53 s at 40 KB, `window/rect` 0.015 s — the screenshot dominates by ~10× on Android. **iOS is unmeasured**; the issue claims the split inverts there, and the native example (`examples/wdio/mocha/wdio.native.conf.ts`) is what makes it measurable. Measured per action: two captures 2.41 s against one capture 1.19 s; end-to-end on the native example 19.1 s against 12.4 s, with live mode (no per-action capture) at 5.9 s. That is the case for the `beforeCommand` capture, and the reason an eager post-action capture is not worth re-adding: it cost a second capture per action and existed only to be patched by a `readyState` poll that could not tell a document that had not navigated yet from one that was loading. - **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. +- 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. One outstanding shot still queues in front of everything after it (measured in #516: a per-action screenshot 600-665 ms on the Android emulator with the poller running, 229 ms without), so a tick is also skipped while the driver is held: `core/driver-activity.ts` keeps one bounded in-flight gate per adapter bundle, opened by each adapter's command hook (`beginDriverCommand`, the service's `beforeCommand`, Selenium's patched methods, Nightwatch's proxy at enqueue) and by `captureActionSnapshot` around its probes (`beginDriverCapture`). A wait and a host-side command (`pause`, `perform`, ...) open none (shared `occupiesDriver`): the driver is idle between a wait's polls, and a pause holding the gate would blank the filmstrip for nothing. The same gate is what keeps a poll out of chromedriver's click (a screenshot between its coordinate read and its dispatch makes the click report success and activate nothing), which a separate input-only gate used to do. Each per-action capture's screenshot is offered to the recorder as a frame stamped when it settled (`ScreencastRecorderBase.offerCapturedFrame`), and the next tick within one interval is skipped as redundant. The offer is taken only when exactly one recorder is recording in the process and it polls, since the capture names no session; a shot no newer than the newest frame is dropped rather than reordered. Residuals: the gate is process-wide, so in a multi-session process one session's commands also hold the other's poller off; a recorder never stopped stays in the registry and disables reuse for the process; a reused frame still passes through `maxBufferFrames` decimation and export thinning like a polled one. - **`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. - **Only a command that can change the page is captured** (`shared/trace-actions.ts` `skipsCapture`, a deny-list: reads, waits, lookups and assertions are skipped, anything unlisted captures; the ACTION_MAP reads come from 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. diff --git a/README.md b/README.md index 398ce0a1..f24b10b5 100644 --- a/README.md +++ b/README.md @@ -348,7 +348,7 @@ The hash names a position in the trace, for a `?trace=` URL, a picked file and ` | `traceGranularity` | `'session'` \| `'spec'` \| `'test'` | `'session'` | `'session'` writes one trace per worker; `'spec'` one per spec file; `'test'` one per test into its own `--<browser>[-retryN]/trace.zip` folder — the smallest, most navigable artifacts, and the best pairing for a retention policy. | | `tracePolicy` | `'on'` \| `'retain-on-failure'` \| `'retain-on-first-failure'` \| `'on-first-retry'` \| `'on-all-retries'` \| `'retain-on-failure-and-retries'` | `'on'` | Which traces to keep. `'on'` keeps every trace; the rest keep only failing/retried tests — pairs well with `traceGranularity: 'test'`. | | `captureAssertions` | `boolean` | `true` | Capture assertions as action rows: `node:assert` (all adapters), WebdriverIO `expect(...)` matchers, and Nightwatch `browser.assert`/`verify`. Set `false` to opt out. | -| `filmstrip` | `boolean` | `true` | Record a dense, continuous screencast *into* the trace for smooth scrubbing in the player (not just one frame per action). Dense frames sit alongside the per-action frames; thinned + content-addressed at export. Runs the screencast recorder (CDP push on Chrome, polling elsewhere). | +| `filmstrip` | `boolean` | `true` | Record a dense, continuous screencast *into* the trace for smooth scrubbing in the player (not just one frame per action). Dense frames sit alongside the per-action frames; thinned + content-addressed at export. Runs the screencast recorder (CDP push on Chrome, polling elsewhere). The polling recorder skips a tick while a command or a per-action capture holds the driver, and keeps each per-action capture's screenshot as a frame, so its polls do not queue in front of the test. | | `maskInputs` | `boolean` | `true` | Mask password fields in the replayed page, live and trace mode alike; `false` records them as typed. Command rows always keep the typed text. See [Password masking](#-password-masking). | | `maskSelectors` | `string[]` | `[]` | Also mask the text and value of every element these CSS selectors match in the replayed page. | | `traceResources` | `'styles'` \| `'none'` | `'styles'` | Which page resources the trace stores so its replay does not load them from the site. `'styles'` stores the stylesheets and fonts each archive's pages used, each once by content hash, capped at 1 MiB per resource and 8 MiB per archive (past the cap a resource replays live); `'none'` stores nothing. Images always replay from the site. The page collector reads each one back from the browser's HTTP cache, cross-origin ones only where the site allows CORS (a same-origin stylesheet it cannot read again falls back to its parsed rules), and the trace leaves those reads out of its network rows. Live mode reads nothing back. | @@ -393,13 +393,15 @@ screenshot 4 1616 254 1004 1004 1600 250 process 4 24 6 6 6 24 6 capture 4 1640 260 1010 1010 1640 260 raw records: /out/capture-timing-1.ndjson +[devtools] filmstrip recorder: 95 screenshots, total 57.0 s, median 600 ms; 40 ticks skipped (31 driver busy, 6 fresh frame, 3 shot in flight); 12 capture screenshots reused as frames ``` -(Illustrative numbers from a unit-test fixture, not a real run.) +(Illustrative numbers, not a real run.) - **Columns.** The JS adapters issue every read of a capture at once and the driver serves them in order, so a read's time from issue to settle includes waiting behind the reads before it. `svc` subtracts that wait: it is the read's own cost. `process` is the in-process step that turns the reads into a snapshot (page-source parsing and locator generation on native). `capture` is the whole capture. - **Busy and wall.** `busy` is the time at least one capture was running, overlapping captures counted once; `wall` is the time since the process started, or since the previous report in the same process. - **Triggers.** `action` is a capture for a user command, `wait-poll` one for a command a wait issued, `matcher-poll` one taken while a WebdriverIO `expect` matcher was polling, and `final` the WebdriverIO capture of the last action's result. Matcher polls are told apart on WebdriverIO only, and only with `captureAssertions` on; Selenium and Nightwatch matchers expose no hook to tell them by. +- **Filmstrip recorder.** With no CDP screencast (Safari, Appium), the `filmstrip` recorder polls a screenshot every 200 ms, and those screenshots share the driver with the captures. The last line counts them: the screenshots it took and their time from issue to settle, the ticks it let pass and why (`driver busy`: a command or a capture held the driver, so a poll would only have queued in front of it; `fresh frame`: a capture's screenshot taken less than one interval earlier is already the frame; `shot in flight`: its previous screenshot had not answered yet), and how many per-action capture screenshots it kept as frames. Captures stay counted in the table only. CDP push mode takes no screenshots and prints no line. Python has no polling recorder; it records one frame per command. - **Python.** It reads one thing at a time, so `svc` equals the read's duration; it records the screenshot and the two page reads, and has no url, title or `process` rows. To measure each platform from the repo's examples (the desktop examples other than Selenium's drive Chrome only): diff --git a/packages/nightwatch-devtools/README.md b/packages/nightwatch-devtools/README.md index c19d0dfe..ccb95164 100644 --- a/packages/nightwatch-devtools/README.md +++ b/packages/nightwatch-devtools/README.md @@ -252,7 +252,7 @@ The encoded video is sent to the DevTools dashboard via the `screencast` WS scop | `traceFormat` | `'zip' \| 'ndjson-directory'` | `'zip'` | Trace artifact layout — `zip` writes a single archive, `ndjson-directory` unpacks the same files into `trace-<sessionId>/`. Only applies when `mode: 'trace'`. | | `traceGranularity` | `'session' \| 'spec' \| 'test'` | `'session'` | One trace per session / spec file / test. `'test'` is what the per-test `screenshot` / `video` artifacts attach to. Only applies when `mode: 'trace'`. **Caveat:** the BDD `describe/it` interface collapses to a single session-scoped slice (see [Per-test slicing](#per-test-slicing--the-bdd-describeit-caveat)). For Cucumber, `'session'` spans every scenario in one zip; `'test'` gives one per scenario and is the recommendation. | | `tracePolicy` | `'on' \| 'retain-on-failure' \| 'retain-on-first-failure' \| 'on-first-retry' \| 'on-all-retries' \| 'retain-on-failure-and-retries'` | `'on'` | Which traces to keep. Pairs with `traceGranularity: 'test'`. Only applies when `mode: 'trace'`. | -| `filmstrip` | `boolean` | `true` | Record a dense, continuous screencast filmstrip into the trace for scrubbable playback in the trace player — not just one frame per action. Runs the screencast recorder (polling mode on Nightwatch) for the session. Only applies when `mode: 'trace'`. | +| `filmstrip` | `boolean` | `true` | Record a dense, continuous screencast filmstrip into the trace for scrubbable playback in the trace player, not just one frame per action. Runs the screencast recorder (polling mode on Nightwatch) for the session. The polling recorder skips a tick while a command or a per-action capture holds the driver, and keeps each per-action capture's screenshot as a frame, so its polls do not queue in front of the test. Only applies when `mode: 'trace'`. | | `traceResources` | `'styles'` \| `'none'` | `'styles'` | Trace mode only. `'styles'` stores the stylesheets and fonts each archive's pages used, once each by content hash, so the player replays them from the archive rather than the site; `'none'` stores nothing. Images always replay from the site. See the [root README](../../README.md#options) for caps and how the reads are kept out of the network rows. | | `screenshot` | `'off' \| 'on' \| 'only-on-failure'` | `'off'` | Per-test screenshot. Trace mode + `traceGranularity: 'test'` only. **Produce-only** — the PNG is written to the trace output dir (and listed in the artifacts manifest when `emitArtifactsManifest: true`); it is NOT attached inline to Allure (see note below). | | `video` | `'off' \| TraceRetentionPolicy` | `'off'` | Per-test video slice, retained per the given policy (e.g. `'retain-on-failure'`). Trace mode + `traceGranularity: 'test'` only. Setting a non-`off` policy starts the screencast recorder itself — you do **not** also need `filmstrip` or `screencast.enabled`; the recorder runs continuously for the session and each test's slice is cut from it by wall-clock window. **Produce-only** — the `.webm` is written to the trace output dir (and listed in the manifest when `emitArtifactsManifest: true`); NOT attached inline to Allure. | diff --git a/packages/selenium-devtools/README.md b/packages/selenium-devtools/README.md index 07b00615..14a3dc52 100644 --- a/packages/selenium-devtools/README.md +++ b/packages/selenium-devtools/README.md @@ -334,7 +334,7 @@ DevTools.configure({ mode: 'trace', traceGranularity: 'test' }) **Default:** `'zip'` (a single `trace-<sessionId>.zip`). `'ndjson-directory'` writes the same `trace.trace` + `trace.network` + `resources/` layout unpacked into `trace-<id>/`, skipping the unzip step for scripted / agentic consumers. Trace mode only. #### `filmstrip` — dense scrubbable screencast in the trace -**Default:** `true`. Records a continuous screencast (CDP push on Chrome, screenshot polling elsewhere) into the trace so the player timeline scrubs frame-by-frame. On by default in trace mode; set `false` to record just one frame per action. Trace mode only. +**Default:** `true`. Records a continuous screencast (CDP push on Chrome, screenshot polling elsewhere) into the trace so the player timeline scrubs frame-by-frame. On by default in trace mode; set `false` to record just one frame per action. Trace mode only. The polling recorder skips a tick while a command or a per-action capture holds the driver, and keeps each per-action capture's screenshot as a frame, so its polls do not queue in front of the test. ```javascript DevTools.configure({ mode: 'trace', filmstrip: true }) @@ -405,7 +405,7 @@ The trace uses a portable NDJSON schema, so the same `.zip` (or `ndjson-director | `traceFormat` | `'zip' \| 'ndjson-directory'` | `'zip'` | Trace artifact layout — a single `.zip` or an unpacked directory. Trace mode only. | | `traceGranularity` | `'session' \| 'spec' \| 'test'` | `'session'` | One trace per session / spec file / test. `'test'` writes each to `test-results/<spec>-<title>-<browser>[-retryN]/trace.zip`. Trace mode only. | | `tracePolicy` | `'on' \| 'retain-on-failure' \| 'retain-on-first-failure' \| 'on-first-retry' \| 'on-all-retries' \| 'retain-on-failure-and-retries'` | `'on'` | Which traces to keep. Pairs with `traceGranularity: 'test'`. Trace mode only. | -| `filmstrip` | `boolean` | `true` | Record a dense, continuous screencast into the trace for frame-by-frame scrubbing in the player. Trace mode only. | +| `filmstrip` | `boolean` | `true` | Record a dense, continuous screencast into the trace for frame-by-frame scrubbing in the player. Trace mode only. The polling recorder skips a tick while a command or a per-action capture holds the driver, and keeps each per-action capture's screenshot as a frame, so its polls do not queue in front of the test. | | `traceResources` | `'styles'` \| `'none'` | `'styles'` | Trace mode only. `'styles'` stores the stylesheets and fonts each archive's pages used, once each by content hash, so the player replays them from the archive rather than the site; `'none'` stores nothing. Images always replay from the site. See the [root README](../../README.md#options) for caps and how the reads are kept out of the network rows. | | `screenshot` | `'off' \| 'on' \| 'only-on-failure'` | `'off'` | Trace mode + `traceGranularity: 'test'`. Per-test screenshot, attached inline to Allure (`image/png`) via `allure-js-commons` when an Allure runner adapter is active. | | `video` | `'off' \| TraceRetentionPolicy` | `'off'` | Trace mode + `traceGranularity: 'test'`. Per-test screencast video, retained per the given policy, attached inline to Allure (`video/webm`) via `allure-js-commons` when an Allure runner adapter is active. | diff --git a/packages/service/README.md b/packages/service/README.md index effdcc82..57c91559 100644 --- a/packages/service/README.md +++ b/packages/service/README.md @@ -273,7 +273,7 @@ services: [['devtools', options]] | `tracePolicy` | `TraceRetentionPolicy` | `'on'` | Trace mode only. Which traces to keep: `'on'` \| `'retain-on-failure'` \| `'retain-on-first-failure'` \| `'on-first-retry'` \| `'on-all-retries'` \| `'retain-on-failure-and-retries'`. The retry-aware policies pair best with `traceGranularity: 'test'`. | | `screenshot` | `'off' \| 'on' \| 'only-on-failure'` | `'off'` | Trace mode + `traceGranularity: 'test'`. Per-test screenshot, attached inline to Allure (`image/png`). WDIO-service-specific. | | `video` | `'off' \| TraceRetentionPolicy` | `'off'` | Trace mode + `traceGranularity: 'test'`. Per-test screencast video, retained per the given policy, attached inline to Allure (`video/webm`). WDIO-service-specific. | -| `filmstrip` | `boolean` | `true` | Trace mode only. Records a dense, continuous screencast filmstrip *into* the trace so the player scrubs smooth playback — dense frames are added alongside the per-action frames (not one frame per action). Frames are thinned (≥100 ms apart, ~600 max) and content-addressed (identical frames — a static wait — collapse to one resource); windowed per slice at any `traceGranularity`. Runs the screencast recorder (CDP push on Chrome, polling elsewhere). | +| `filmstrip` | `boolean` | `true` | Trace mode only. Records a dense, continuous screencast filmstrip *into* the trace so the player scrubs smooth playback: dense frames are added alongside the per-action frames (not one frame per action). Frames are thinned (≥100 ms apart, ~600 max) and content-addressed (identical frames, such as a static wait, collapse to one resource); windowed per slice at any `traceGranularity`. Runs the screencast recorder (CDP push on Chrome, polling elsewhere). The polling recorder skips a tick while a command or a per-action capture holds the driver, and keeps each per-action capture's screenshot as a frame, so its polls do not queue in front of the test. | | `traceResources` | `'styles'` \| `'none'` | `'styles'` | Trace mode only. `'styles'` stores the stylesheets and fonts each archive's pages used, once each by content hash, so the player replays them from the archive rather than the site; `'none'` stores nothing. Images always replay from the site. See the [root README](../../README.md#options) for caps and how the reads are kept out of the network rows. | | `emitArtifactsManifest` | `boolean` | `false` | Trace mode only. Writes `devtools-artifacts-<sessionId>.json` next to the trace — a generic index of every produced artifact (trace/screenshot/video) plus each test's state, for reporters/CI to consume. Off by default; **auto-enabled when `@wdio/allure-reporter` is in the config**. | | `captureAssertions` | `boolean` | `true` | Capture assertions as command/action rows — `node:assert` plus passing *and* failing expect-webdriverio matchers, folded into single `expect.<matcher>` rows (e.g. `toHaveText`, `toExist`). Set `false` to opt out. | From 7cc5e7dee89abb89c0ba037902cd735bd018f607 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan <vishnu.p@browserstack.com> Date: Tue, 10 Nov 2026 17:31:50 +0530 Subject: [PATCH 03/10] Cap the filmstrip gap while the driver stays busy A tick skipped for a busy driver was dropped, not deferred, so a run of back-to-back commands that take no capture (reads, lookups, wait polls, or any live-mode run on Safari or Appium) could leave the filmstrip without a frame for as long as the run lasted. Once the newest frame is three intervals old the recorder polls anyway, still one shot at a time. An input-dispatching command is never overridden, since a poll inside its dispatch makes the click activate nothing, so the gate tells input windows apart again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/core/src/driver-activity.ts | 54 +++++++++++++---- packages/core/src/screencast.ts | 30 ++++++++-- packages/core/tests/driver-activity.test.ts | 49 +++++++++++++++- packages/core/tests/screencast.test.ts | 64 ++++++++++++++++++++- packages/shared/src/trace-actions.ts | 30 ++++++++++ 5 files changed, 206 insertions(+), 21 deletions(-) diff --git a/packages/core/src/driver-activity.ts b/packages/core/src/driver-activity.ts index 510c18e6..0ff0108c 100644 --- a/packages/core/src/driver-activity.ts +++ b/packages/core/src/driver-activity.ts @@ -1,4 +1,7 @@ -import { occupiesDriver } from '@wdio/devtools-shared' +import { + isInputDispatchingCommand, + occupiesDriver +} from '@wdio/devtools-shared' // A driver serves one session's requests one at a time, so a filmstrip poll // issued while a command or a capture holds the driver only queues in front of @@ -14,18 +17,24 @@ export const DRIVER_ACTIVITY_MAX_HOLD_MS = 3000 const NOOP = (): void => {} +interface OpenWindow { + openedAt: number + input: boolean +} + export class InFlightGate { - readonly #openedAt = new Map<number, number>() + readonly #open = new Map<number, OpenWindow>() #nextToken = 0 constructor(private readonly maxHoldMs = DRIVER_ACTIVITY_MAX_HOLD_MS) {} - /** Open a window and return its closer; closing twice is harmless. */ - open(): () => void { + /** Open a window and return its closer; closing twice is harmless. `input` + * marks a command dispatching trusted input, which no poll may overlap. */ + open(input = false): () => void { const token = this.#nextToken++ - this.#openedAt.set(token, Date.now()) + this.#open.set(token, { openedAt: Date.now(), input }) return () => { - this.#openedAt.delete(token) + this.#open.delete(token) } } @@ -35,13 +44,28 @@ export class InFlightGate { * and cannot leak entries for the rest of the run. */ isOpen(): boolean { + this.#prune() + return this.#open.size > 0 + } + + /** Whether an unexpired window of an input-dispatching command is open. */ + isInputOpen(): boolean { + this.#prune() + for (const window of this.#open.values()) { + if (window.input) { + return true + } + } + return false + } + + #prune(): void { const cutoff = Date.now() - this.maxHoldMs - for (const [token, openedAt] of this.#openedAt) { - if (openedAt <= cutoff) { - this.#openedAt.delete(token) + for (const [token, window] of this.#open) { + if (window.openedAt <= cutoff) { + this.#open.delete(token) } } - return this.#openedAt.size > 0 } } @@ -50,7 +74,9 @@ const sharedGate = new InFlightGate() /** Adapter side: open the window around a runner command. Returns its closer, * a no-op for a command that does not hold the driver (`occupiesDriver`). */ export function beginDriverCommand(command: string): () => void { - return occupiesDriver(command) ? sharedGate.open() : NOOP + return occupiesDriver(command) + ? sharedGate.open(isInputDispatchingCommand(command)) + : NOOP } /** Capture side: open the window around a per-action capture's probes. */ @@ -62,3 +88,9 @@ export function beginDriverCapture(): () => void { export function isDriverBusy(): boolean { return sharedGate.isOpen() } + +/** Recorder side: is a command dispatching input right now? A poll then must + * wait however stale the filmstrip is, or the click can activate nothing. */ +export function isInputDispatchInFlight(): boolean { + return sharedGate.isInputOpen() +} diff --git a/packages/core/src/screencast.ts b/packages/core/src/screencast.ts index 10689520..ba063c14 100644 --- a/packages/core/src/screencast.ts +++ b/packages/core/src/screencast.ts @@ -1,7 +1,7 @@ import type { ScreencastFrame, ScreencastOptions } from '@wdio/devtools-shared' import { hasScreenshot, SCREENCAST_DEFAULTS } from '@wdio/devtools-shared' import { performance } from 'node:perf_hooks' -import { isDriverBusy } from './driver-activity.js' +import { isDriverBusy, isInputDispatchInFlight } from './driver-activity.js' import { recordRecorderReuse, recordRecorderShot, @@ -18,6 +18,10 @@ export const SCREENCAST_HANDSHAKE_TIMEOUT_MS = 5000 const FIRST_SHOT_TIMEOUT = Symbol('first-shot-timeout') +/** Longest gap, in poll intervals, a busy driver may leave in the filmstrip: + * back-to-back commands that take no capture would otherwise starve it. */ +export const MAX_FILMSTRIP_GAP_INTERVALS = 3 + /** Where a recorder reports its lifecycle; each adapter passes its own logger. */ export interface ScreencastLogger { info(message: string): void @@ -372,11 +376,11 @@ export abstract class ScreencastRecorderBase<TDriver = unknown> { } #skipReason(): RecorderSkipReason | undefined { - // A command or a capture holds the driver: a poll would queue in front of - // the test, and one landing mid-click makes the click report success while - // dispatching nothing. The gate is bounded, so a command whose completion - // never arrives costs frames only until it expires. - if (isDriverBusy()) { + // A poll landing mid-click makes the click report success while + // dispatching nothing, so input waits however stale the filmstrip is. The + // gate is bounded, so a command whose completion never arrives costs + // frames only until it expires. + if (isInputDispatchInFlight()) { return 'busy' } // setInterval does not wait for the tick. A screenshot slower than the @@ -386,12 +390,26 @@ export abstract class ScreencastRecorderBase<TDriver = unknown> { if (this.#pollInFlight) { return 'inFlight' } + // Any other command or a capture holding the driver would only have a poll + // queue in front of it, until the newest frame is too old to wait longer. + if ( + isDriverBusy() && + this.#newestFrameAge() < + this.options.pollIntervalMs * MAX_FILMSTRIP_GAP_INTERVALS + ) { + return 'busy' + } if (Date.now() - this.#lastReusedAt < this.options.pollIntervalMs) { return 'fresh' } return undefined } + #newestFrameAge(): number { + const newest = this.buffer[this.buffer.length - 1] + return newest ? Date.now() - newest.timestamp : Number.POSITIVE_INFINITY + } + async #pollTick(generation: number): Promise<void> { const skip = this.#skipReason() if (skip) { diff --git a/packages/core/tests/driver-activity.test.ts b/packages/core/tests/driver-activity.test.ts index d9c48ffa..b5331d9e 100644 --- a/packages/core/tests/driver-activity.test.ts +++ b/packages/core/tests/driver-activity.test.ts @@ -1,10 +1,14 @@ import { describe, it, expect } from 'vitest' -import { occupiesDriver } from '@wdio/devtools-shared' +import { + isInputDispatchingCommand, + occupiesDriver +} from '@wdio/devtools-shared' import { InFlightGate, beginDriverCapture, beginDriverCommand, - isDriverBusy + isDriverBusy, + isInputDispatchInFlight } from '../src/driver-activity.js' const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -118,3 +122,44 @@ describe('process-wide gate', () => { expect(isDriverBusy()).toBe(false) }) }) + +describe('input windows', () => { + it('classifies pointer and keyboard commands across runners', () => { + for (const command of [ + 'click', + 'doubleClick', + 'setValue', + 'sendKeys', + 'keys', + 'moveToElement', + 'mouseButtonClick', + 'rightClick', + 'submitForm' + ]) { + expect(isInputDispatchingCommand(command)).toBe(true) + } + for (const command of ['getText', 'url', 'execute', 'waitUntil']) { + expect(isInputDispatchingCommand(command)).toBe(false) + } + }) + + it('reports an input command apart from any other busy window', () => { + const closeRead = beginDriverCommand('getText') + expect(isDriverBusy()).toBe(true) + expect(isInputDispatchInFlight()).toBe(false) + const closeClick = beginDriverCommand('click') + expect(isInputDispatchInFlight()).toBe(true) + closeClick() + expect(isInputDispatchInFlight()).toBe(false) + closeRead() + expect(isDriverBusy()).toBe(false) + }) + + it('lets an input window expire like any other', async () => { + const gate = new InFlightGate(20) + gate.open(true) + expect(gate.isInputOpen()).toBe(true) + await sleep(40) + expect(gate.isInputOpen()).toBe(false) + }) +}) diff --git a/packages/core/tests/screencast.test.ts b/packages/core/tests/screencast.test.ts index 7df492ed..b18aa416 100644 --- a/packages/core/tests/screencast.test.ts +++ b/packages/core/tests/screencast.test.ts @@ -3,6 +3,7 @@ import { ScreencastRecorderBase, type ScreencastLogger } from '../src/screencast.js' +import { MAX_FILMSTRIP_GAP_INTERVALS } from '../src/screencast.js' import { DRIVER_ACTIVITY_MAX_HOLD_MS, beginDriverCapture, @@ -160,7 +161,7 @@ describe('ScreencastRecorderBase: driver-activity gate', () => { const suppressed = r.bufferLength const close = beginDriverCommand('getText') try { - await vi.advanceTimersByTimeAsync(150) // 3 ticks, all skipped + await vi.advanceTimersByTimeAsync(100) // 2 ticks inside the gap cap, skipped expect(r.bufferLength).toBe(suppressed) } finally { close() @@ -178,7 +179,7 @@ describe('ScreencastRecorderBase: driver-activity gate', () => { const shots = r.shotsTaken const close = beginDriverCapture() try { - await vi.advanceTimersByTimeAsync(150) + await vi.advanceTimersByTimeAsync(100) expect(r.shotsTaken).toBe(shots) } finally { close() @@ -713,3 +714,62 @@ describe('ScreencastRecorderBase: reused capture screenshots', () => { expect(r.frames[r.frames.length - 1].data).toBe('capture-10') }) }) + +describe('ScreencastRecorderBase: gap cap while the driver stays busy', () => { + const INTERVAL = 50 + const CAP = INTERVAL * MAX_FILMSTRIP_GAP_INTERVALS + + function largestGap(r: TestRecorder): number { + const stamps = r.frames.map((f) => f.timestamp) + return Math.max(...stamps.slice(1).map((t, i) => t - stamps[i])) + } + + it('polls through back-to-back commands once the newest frame reaches the cap', async () => { + vi.useFakeTimers() + const r = new TestRecorder({ pollIntervalMs: INTERVAL }) + await r.start({ name: 'driver' }) + // Live mode on Safari or Appium: reads only, no capture to reuse, and each + // command opens before the previous one closes. + let close = beginDriverCommand('getText') + for (let i = 0; i < 40; i++) { + await vi.advanceTimersByTimeAsync(25) + const next = beginDriverCommand(i % 2 ? 'findElements' : 'isDisplayed') + close() + close = next + } + close() + await r.stop() + expect(r.frames.length).toBeGreaterThan(1000 / CAP - 1) + expect(largestGap(r)).toBeLessThanOrEqual(CAP) + vi.useRealTimers() + }) + + it('caps the gap through a long run of captures too', async () => { + vi.useFakeTimers() + const r = new TestRecorder({ pollIntervalMs: INTERVAL }) + await r.start({ name: 'driver' }) + const close = beginDriverCapture() + await vi.advanceTimersByTimeAsync(CAP * 4) + close() + await r.stop() + expect(r.frames.length).toBeGreaterThanOrEqual(5) + expect(largestGap(r)).toBeLessThanOrEqual(CAP) + vi.useRealTimers() + }) + + it('never overrides an input command, however stale the filmstrip', async () => { + vi.useFakeTimers() + const r = new TestRecorder({ pollIntervalMs: INTERVAL }) + await r.start({ name: 'driver' }) + const shots = r.shotsTaken + const close = beginDriverCommand('click') + try { + await vi.advanceTimersByTimeAsync(CAP * 4) + expect(r.shotsTaken).toBe(shots) + } finally { + close() + } + await r.stop() + vi.useRealTimers() + }) +}) diff --git a/packages/shared/src/trace-actions.ts b/packages/shared/src/trace-actions.ts index 0a5effaf..70608799 100644 --- a/packages/shared/src/trace-actions.ts +++ b/packages/shared/src/trace-actions.ts @@ -239,6 +239,36 @@ export function isKeyboardCommand(command: string): boolean { return KEYBOARD_COMMANDS.has(command) } +/** Runner-native pointer commands the trace vocabulary deliberately doesn't + * name: Nightwatch's low-level mouse API has no meaningful trace method, so + * adding it to ACTION_MAP would put noise in the timeline. Listed here because + * they still dispatch, and a command that dispatches unrecognized keeps the + * screenshot poll running straight through it. */ +const UNMAPPED_POINTER_COMMANDS: ReadonlySet<string> = new Set([ + 'moveToElement', + 'mouseButtonClick', + 'mouseButtonDown', + 'mouseButtonUp', + 'rightClick', + 'submitForm' +]) + +/** + * Whether a runner command makes the driver dispatch trusted input into the + * page. Derived from the vocabulary above rather than declared as a second list: + * a command qualifies when it types (`isKeyboardCommand`) or when its trace + * method acts at a point (`POINTABLE_METHODS`). A command wrongly classified as + * non-dispatching is the costly direction: it leaves the screencast poll racing + * the dispatch, so the unmapped pointer commands above are named explicitly. + */ +export function isInputDispatchingCommand(command: string): boolean { + if (isKeyboardCommand(command) || UNMAPPED_POINTER_COMMANDS.has(command)) { + return true + } + const action = ACTION_MAP[command] + return action !== undefined && POINTABLE_METHODS.has(action.method) +} + /** Driver lookups and reads that never change the page and have no ACTION_MAP * entry; the ACTION_MAP reads are covered by METHOD_EFFECT in `skipsCapture`. */ export const READ_ONLY_COMMANDS: ReadonlySet<string> = new Set([ From 85b5d9ce41ef1a6ff3e504cedd4134bea2624cd5 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan <vishnu.p@browserstack.com> Date: Tue, 10 Nov 2026 17:32:34 +0530 Subject: [PATCH 04/10] Open no driver window for the service's own injection calls #afterCommand returns before closing the window while #injecting, so an injection call from a re-arm (reloadSession, a context change) held the gate to its 3 s bound and replaced the user command's single-field window. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/service/src/index.ts | 8 ++++++-- packages/service/tests/index.test.ts | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/service/src/index.ts b/packages/service/src/index.ts index 52ea613e..6aa4650d 100644 --- a/packages/service/src/index.ts +++ b/packages/service/src/index.ts @@ -792,8 +792,12 @@ export default class DevToolsHookService implements Services.ServiceInstance { } // Holds the filmstrip poll off the driver for the whole command (see // driver-activity.ts); opened before the pre-action probes so it spans them. - this.#closeCommandWindow?.() - this.#closeCommandWindow = beginDriverCommand(command) + // Not for an injection call: #afterCommand skips those before closing, so + // the window would hold the gate to its bound and drop the user command's. + if (!this.#injecting) { + this.#closeCommandWindow?.() + this.#closeCommandWindow = beginDriverCommand(command) + } // `url` is the one start-of-recording signal every runner reaches (Mocha, // Jasmine, Cucumber, standalone), so the video's start marker hangs off it. if (command === 'url') { diff --git a/packages/service/tests/index.test.ts b/packages/service/tests/index.test.ts index 1e0a1332..4e90922c 100644 --- a/packages/service/tests/index.test.ts +++ b/packages/service/tests/index.test.ts @@ -376,6 +376,22 @@ describe('DevtoolsService - Internal Command Filtering', () => { flushInputWindows() }) + // #afterCommand skips an injection call before it would close the window, + // so opening one would hold the gate to its bound and drop the user's. + it('opens no window for the injection calls of a re-arm', async () => { + let busyDuringInjection: boolean | undefined + mockSessionCapturerInstance.injectScript.mockImplementationOnce( + async () => { + await service.beforeCommand('execute' as any, ['script']) + busyDuringInjection = isDriverBusy() + await service.afterCommand('execute' as any, ['script'], undefined) + } + ) + await service.onReload('old-session', 'new-session') + expect(busyDuringInjection).toBe(false) + expect(isDriverBusy()).toBe(false) + }) + it('holds the window open for the duration of a command', async () => { await service.beforeCommand('click' as any, ['.button']) expect(isDriverBusy()).toBe(true) From 8a001dd31d54fff8699f35b2ab39b7bf642c57e9 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan <vishnu.p@browserstack.com> Date: Tue, 10 Nov 2026 17:34:18 +0530 Subject: [PATCH 05/10] Open Nightwatch's driver window only for commands that call back The window closes from the callback the proxy appends, so a wrapped function that never calls it (a builder or scoped API such as actions() or element(), or a function-style custom command that never calls done) held the filmstrip's gate for its full 3 s bound on every call. The window now opens only for an input command, a command the trace vocabulary maps, or a known driver read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../src/helpers/browserProxy.ts | 14 +++++------ .../src/helpers/commandWindow.ts | 25 +++++++++++++++++++ .../tests/browserProxy.test.ts | 16 ++++++++++++ 3 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 packages/nightwatch-devtools/src/helpers/commandWindow.ts diff --git a/packages/nightwatch-devtools/src/helpers/browserProxy.ts b/packages/nightwatch-devtools/src/helpers/browserProxy.ts index c2ce7d44..58a7de79 100644 --- a/packages/nightwatch-devtools/src/helpers/browserProxy.ts +++ b/packages/nightwatch-devtools/src/helpers/browserProxy.ts @@ -14,10 +14,10 @@ import { getCallSourceFromStack } from './utils.js' import { serializeCommandResult } from './serializeCommandResult.js' import { NativeAssertRecorder } from './nativeAssertRecorder.js' import { WaitRows } from './waitRows.js' +import { beginCommandWindow } from './commandWindow.js' import { wrapNavigation } from './navigationWrap.js' import { RetryTracker, - beginDriverCommand, clearReadValues, rememberReadValue, toError @@ -572,12 +572,12 @@ export class BrowserProxy { const cmd = this.pendingCommand(methodName, args) const captureCallback = this.makeCaptureCallback(browser, cmd) // Holds the filmstrip poll off the driver while this command runs (see core - // driver-activity.ts). Enqueue is the only hook the adapter has, Nightwatch - // executes off a queue it exposes no start event for, so an awaited call - // (enqueue ≈ execution) is covered, while one queued behind a long wait can - // see its window expire before it runs. The bound is what keeps that from - // stalling the recorder instead. - const closeCommandWindow = beginDriverCommand(methodName) + // driver-activity.ts), for a command known to call back (commandWindow.ts). + // Enqueue is the only hook the adapter has, Nightwatch executes off a queue + // it exposes no start event for, so an awaited call (enqueue ≈ execution) is + // covered, while one queued behind a long wait can see its window expire + // before it runs. The bound is what keeps that from stalling the recorder. + const closeCommandWindow = beginCommandWindow(methodName) const gatedCallback = (callbackResult: unknown) => { closeCommandWindow() return captureCallback(callbackResult) diff --git a/packages/nightwatch-devtools/src/helpers/commandWindow.ts b/packages/nightwatch-devtools/src/helpers/commandWindow.ts new file mode 100644 index 00000000..8defe8ce --- /dev/null +++ b/packages/nightwatch-devtools/src/helpers/commandWindow.ts @@ -0,0 +1,25 @@ +import { beginDriverCommand } from '@wdio/devtools-core' +import { + READ_ONLY_COMMANDS, + isInputDispatchingCommand, + mapCommandToAction +} from '@wdio/devtools-shared' + +const NOOP = (): void => {} + +/** Whether a wrapped command is known to reach the driver and call back. The + * window closes from the appended callback, so a builder or scoped API + * (`actions()`, `element()`) or a custom command that never calls back would + * hold the filmstrip's gate to its bound on every call. */ +export function callsBackFromDriver(command: string): boolean { + return ( + isInputDispatchingCommand(command) || + mapCommandToAction(command) !== null || + READ_ONLY_COMMANDS.has(command) + ) +} + +/** Opens core's driver-activity window for a command that calls back from the driver. */ +export function beginCommandWindow(command: string): () => void { + return callsBackFromDriver(command) ? beginDriverCommand(command) : NOOP +} diff --git a/packages/nightwatch-devtools/tests/browserProxy.test.ts b/packages/nightwatch-devtools/tests/browserProxy.test.ts index c5dcbd18..6322b5b2 100644 --- a/packages/nightwatch-devtools/tests/browserProxy.test.ts +++ b/packages/nightwatch-devtools/tests/browserProxy.test.ts @@ -929,4 +929,20 @@ describe('BrowserProxy driver-activity window', () => { callBack?.({ value: 'hi' }) expect(isDriverBusy()).toBe(false) }) + + it('opens no window for a function that never calls back', () => { + const browser = { + actions: () => ({ move: () => undefined }), + myCustomCommand: () => undefined + } as unknown as NightwatchBrowser + const { capturer } = makeCapturer() + new BrowserProxy(capturer, makeTestManager(), () => ({ + uid: 'uid-a' + })).wrapBrowserCommands(browser) + + const b = browser as unknown as Record<string, (...a: unknown[]) => unknown> + b.actions() + b.myCustomCommand() + expect(isDriverBusy()).toBe(false) + }) }) From ab5ccaf01efbf0f8f165488085c8271c84516139 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan <vishnu.p@browserstack.com> Date: Tue, 10 Nov 2026 17:35:00 +0530 Subject: [PATCH 06/10] Bound the filmstrip recorder's timing sample Recorder counters are only drained when a trace is exported, so a live-mode run with DEVTOOLS_CAPTURE_TIMING on kept every shot's duration for the life of the process. The count and total stay exact; the median reads a ring of the newest 2000 shots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/core/src/capture-timing.ts | 20 ++++++++++++++++---- packages/core/tests/capture-timing.test.ts | 15 +++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/core/src/capture-timing.ts b/packages/core/src/capture-timing.ts index 43f372bc..436e766d 100644 --- a/packages/core/src/capture-timing.ts +++ b/packages/core/src/capture-timing.ts @@ -295,7 +295,13 @@ const RECORDER_SKIP_LABELS: Record<RecorderSkipReason, string> = { inFlight: 'shot in flight' } +/** Shot durations kept for the median; a live run is never reported, so the + * sample is a ring over the newest shots rather than every one. */ +export const RECORDER_SHOT_SAMPLE_MAX = 2000 + const recorderShots: number[] = [] +let recorderShotCount = 0 +let recorderShotTotal = 0 const recorderSkips: Record<RecorderSkipReason, number> = { busy: 0, fresh: 0, @@ -306,7 +312,9 @@ let recorderReused = 0 /** The filmstrip poller's own screenshot, `ms` from issue to settle. */ export function recordRecorderShot(ms: number): void { if (captureTimingEnabled()) { - recorderShots.push(ms) + recorderShots[recorderShotCount % RECORDER_SHOT_SAMPLE_MAX] = ms + recorderShotCount++ + recorderShotTotal += ms } } @@ -333,7 +341,7 @@ export interface RecorderTimingSummary { function hasRecorderActivity(): boolean { return ( - recorderShots.length > 0 || + recorderShotCount > 0 || recorderReused > 0 || Object.values(recorderSkips).some((n) => n > 0) ) @@ -342,6 +350,10 @@ function hasRecorderActivity(): boolean { /** Drains the recorder counters gathered since the last report. */ function takeRecorderTiming(): RecorderTimingSummary { const sorted = recorderShots.splice(0).sort((a, b) => a - b) + const shots = recorderShotCount + const total = recorderShotTotal + recorderShotCount = 0 + recorderShotTotal = 0 const skipped = { ...recorderSkips } for (const reason of Object.keys(recorderSkips) as RecorderSkipReason[]) { recorderSkips[reason] = 0 @@ -349,8 +361,8 @@ function takeRecorderTiming(): RecorderTimingSummary { const reused = recorderReused recorderReused = 0 return { - shots: sorted.length, - total: round(sum(sorted)), + shots, + total: round(total), median: sorted.length ? round(median(sorted)) : 0, skipped, reused diff --git a/packages/core/tests/capture-timing.test.ts b/packages/core/tests/capture-timing.test.ts index 64750ce4..ffa448cc 100644 --- a/packages/core/tests/capture-timing.test.ts +++ b/packages/core/tests/capture-timing.test.ts @@ -14,6 +14,7 @@ import { CaptureTimer, captureTimingEnabled, formatCaptureTimingSummary, + RECORDER_SHOT_SAMPLE_MAX, formatRecorderTiming, recordRecorderReuse, recordRecorderShot, @@ -379,4 +380,18 @@ describe('filmstrip recorder line', () => { '[devtools] filmstrip recorder: 2 screenshots, total 0.4 s, median 200 ms; 1 ticks skipped (1 shot in flight); 0 capture screenshots reused as frames\n' ]) }) + + it('keeps the shot sample bounded while still counting every shot', async () => { + vi.stubEnv(CAPTURE_TIMING_ENV, '1') + const shots = RECORDER_SHOT_SAMPLE_MAX * 3 + for (let i = 0; i < shots; i++) { + recordRecorderShot(i < shots - RECORDER_SHOT_SAMPLE_MAX ? 1000 : 10) + } + await reportCaptureTiming(outDir, (t) => drained.push(t)) + const total = + (shots - RECORDER_SHOT_SAMPLE_MAX) * 1000 + RECORDER_SHOT_SAMPLE_MAX * 10 + expect(drained[0]).toContain( + `${shots} screenshots, total ${(total / 1000).toFixed(1)} s, median 10 ms` + ) + }) }) From 030f1ff812748bf4655a10f1b40ec505cbe34333 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan <vishnu.p@browserstack.com> Date: Tue, 10 Nov 2026 17:35:49 +0530 Subject: [PATCH 07/10] Document the filmstrip gap cap and the bounded timing sample Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .changeset/filmstrip-poll-queue.md | 2 +- CLAUDE.md | 4 ++-- README.md | 2 +- packages/nightwatch-devtools/README.md | 2 +- packages/selenium-devtools/README.md | 4 ++-- packages/service/README.md | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.changeset/filmstrip-poll-queue.md b/.changeset/filmstrip-poll-queue.md index fa7b2f34..c7b0c283 100644 --- a/.changeset/filmstrip-poll-queue.md +++ b/.changeset/filmstrip-poll-queue.md @@ -4,4 +4,4 @@ '@wdio/nightwatch-devtools': patch --- -Without a CDP screencast (Safari, Appium), the `filmstrip` recorder no longer queues its screenshots in front of the test. It skips a poll while a command or a per-action capture holds the driver, keeps each per-action capture's screenshot as a filmstrip frame, and skips the poll that would only repeat it. The interval and the filmstrip default are unchanged, and Chrome's CDP push mode is untouched. With `DEVTOOLS_CAPTURE_TIMING=1`, the report adds a `filmstrip recorder` line: the screenshots the recorder took and their time, the polls it skipped and why, and the capture screenshots it kept as frames. +Without a CDP screencast (Safari, Appium), the `filmstrip` recorder no longer queues its screenshots in front of the test. It skips a poll while a command or a per-action capture holds the driver (polling anyway once the newest frame is three intervals old, except during a click or typing), keeps each per-action capture's screenshot as a filmstrip frame, and skips the poll that would only repeat it. The interval and the filmstrip default are unchanged, and Chrome's CDP push mode is untouched. With `DEVTOOLS_CAPTURE_TIMING=1`, the report adds a `filmstrip recorder` line: the screenshots the recorder took and their time, the polls it skipped and why, and the capture screenshots it kept as frames. diff --git a/CLAUDE.md b/CLAUDE.md index e101fb69..dd0d273d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -396,10 +396,10 @@ Documented divergences from the conventions above. They exist today as debt to b ### What a per-action trace capture costs -- **Measure before proposing a change: `DEVTOOLS_CAPTURE_TIMING=1`.** Every adapter then times each probe of each capture (issue and settle, so a probe's own service time is separable from its wait behind the probes the driver served first) and, when the trace is written, prints a per-probe table and appends the raw records to `capture-timing-<pid>.ndjson` beside the trace. The JS recording is `core/capture-timing.ts`, wired once in `captureActionSnapshot` and reported from `finalizeTraceExport`, so no adapter carries more than the capture's trigger (action, wait poll, matcher poll, final); Python mirrors it in `capture_timing.py`, held to the same summary by `mirror-vectors.json`. Off, it costs a flag read per capture. The per-platform commands are in the README's "Measuring capture cost". The polling filmstrip recorder's own screenshots are counted beside it (`recordRecorderShot`/`recordRecorderSkip`/`recordRecorderReuse`, one `filmstrip recorder:` line, not written to the NDJSON); Python has no polling recorder and prints none, so the mirrored summary is unchanged. +- **Measure before proposing a change: `DEVTOOLS_CAPTURE_TIMING=1`.** Every adapter then times each probe of each capture (issue and settle, so a probe's own service time is separable from its wait behind the probes the driver served first) and, when the trace is written, prints a per-probe table and appends the raw records to `capture-timing-<pid>.ndjson` beside the trace. The JS recording is `core/capture-timing.ts`, wired once in `captureActionSnapshot` and reported from `finalizeTraceExport`, so no adapter carries more than the capture's trigger (action, wait poll, matcher poll, final); Python mirrors it in `capture_timing.py`, held to the same summary by `mirror-vectors.json`. Off, it costs a flag read per capture. The per-platform commands are in the README's "Measuring capture cost". The polling filmstrip recorder's own screenshots are counted beside it (`recordRecorderShot`/`recordRecorderSkip`/`recordRecorderReuse`, one `filmstrip recorder:` line, not written to the NDJSON); Python has no polling recorder and prints none, so the mirrored summary is unchanged. The shot durations are a ring of the newest 2000 (`RECORDER_SHOT_SAMPLE_MAX`), since a live run never reports and never drains them. - **One capture per action means two driver round trips per action, and the platform decides which half is expensive.** Measured on Appium 3.7.0 / UiAutomator2 (emulator-5554, Android API 37, 1080×2424): `GET /screenshot` 1.18–2.22 s (steady ~1.20 s) at 1.86 MB, `GET /source` (page-source XML) 0.09–0.53 s at 40 KB, `window/rect` 0.015 s — the screenshot dominates by ~10× on Android. **iOS is unmeasured**; the issue claims the split inverts there, and the native example (`examples/wdio/mocha/wdio.native.conf.ts`) is what makes it measurable. Measured per action: two captures 2.41 s against one capture 1.19 s; end-to-end on the native example 19.1 s against 12.4 s, with live mode (no per-action capture) at 5.9 s. That is the case for the `beforeCommand` capture, and the reason an eager post-action capture is not worth re-adding: it cost a second capture per action and existed only to be patched by a `readyState` poll that could not tell a document that had not navigated yet from one that was loading. - **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. One outstanding shot still queues in front of everything after it (measured in #516: a per-action screenshot 600-665 ms on the Android emulator with the poller running, 229 ms without), so a tick is also skipped while the driver is held: `core/driver-activity.ts` keeps one bounded in-flight gate per adapter bundle, opened by each adapter's command hook (`beginDriverCommand`, the service's `beforeCommand`, Selenium's patched methods, Nightwatch's proxy at enqueue) and by `captureActionSnapshot` around its probes (`beginDriverCapture`). A wait and a host-side command (`pause`, `perform`, ...) open none (shared `occupiesDriver`): the driver is idle between a wait's polls, and a pause holding the gate would blank the filmstrip for nothing. The same gate is what keeps a poll out of chromedriver's click (a screenshot between its coordinate read and its dispatch makes the click report success and activate nothing), which a separate input-only gate used to do. Each per-action capture's screenshot is offered to the recorder as a frame stamped when it settled (`ScreencastRecorderBase.offerCapturedFrame`), and the next tick within one interval is skipped as redundant. The offer is taken only when exactly one recorder is recording in the process and it polls, since the capture names no session; a shot no newer than the newest frame is dropped rather than reordered. Residuals: the gate is process-wide, so in a multi-session process one session's commands also hold the other's poller off; a recorder never stopped stays in the registry and disables reuse for the process; a reused frame still passes through `maxBufferFrames` decimation and export thinning like a polled one. +- 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. One outstanding shot still queues in front of everything after it (measured in #516: a per-action screenshot 600-665 ms on the Android emulator with the poller running, 229 ms without), so a tick is also skipped while the driver is held: `core/driver-activity.ts` keeps one bounded in-flight gate per adapter bundle, opened by each adapter's command hook (`beginDriverCommand`, the service's `beforeCommand`, Selenium's patched methods, Nightwatch's proxy at enqueue) and by `captureActionSnapshot` around its probes (`beginDriverCapture`). A wait and a host-side command (`pause`, `perform`, ...) open none (shared `occupiesDriver`): the driver is idle between a wait's polls, and a pause holding the gate would blank the filmstrip for nothing. A skipped tick is dropped rather than deferred, so a run of commands that take no capture (reads, lookups, wait polls, every command of a live run) would starve the filmstrip: once the newest frame is `MAX_FILMSTRIP_GAP_INTERVALS` (3) intervals old the recorder polls through a busy driver anyway, one shot at a time. Never through an input command: the gate marks those windows (`isInputDispatchInFlight`, shared `isInputDispatchingCommand`), because a screenshot between chromedriver's coordinate read and its dispatch makes the click report success and activate nothing. The service opens no window for its own injection calls (`#afterCommand` returns before closing one while `#injecting`), and Nightwatch opens one only for a command known to call back from the driver (`helpers/commandWindow.ts`), since its window closes from the appended callback and a builder API or a custom command that never calls back would hold the gate to its bound. Each per-action capture's screenshot is offered to the recorder as a frame stamped when it settled (`ScreencastRecorderBase.offerCapturedFrame`), and the next tick within one interval is skipped as redundant. The offer is taken only when exactly one recorder is recording in the process and it polls, since the capture names no session; a shot no newer than the newest frame is dropped rather than reordered. Residuals: the gate is process-wide, so in a multi-session process one session's commands also hold the other's poller off; a recorder never stopped stays in the registry and disables reuse for the process; a reused frame still passes through `maxBufferFrames` decimation and export thinning like a polled one. - **`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. - **Only a command that can change the page is captured** (`shared/trace-actions.ts` `skipsCapture`, a deny-list: reads, waits, lookups and assertions are skipped, anything unlisted captures; the ACTION_MAP reads come from 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. diff --git a/README.md b/README.md index f24b10b5..f74444f9 100644 --- a/README.md +++ b/README.md @@ -348,7 +348,7 @@ The hash names a position in the trace, for a `?trace=` URL, a picked file and ` | `traceGranularity` | `'session'` \| `'spec'` \| `'test'` | `'session'` | `'session'` writes one trace per worker; `'spec'` one per spec file; `'test'` one per test into its own `<spec>-<title>-<browser>[-retryN]/trace.zip` folder — the smallest, most navigable artifacts, and the best pairing for a retention policy. | | `tracePolicy` | `'on'` \| `'retain-on-failure'` \| `'retain-on-first-failure'` \| `'on-first-retry'` \| `'on-all-retries'` \| `'retain-on-failure-and-retries'` | `'on'` | Which traces to keep. `'on'` keeps every trace; the rest keep only failing/retried tests — pairs well with `traceGranularity: 'test'`. | | `captureAssertions` | `boolean` | `true` | Capture assertions as action rows: `node:assert` (all adapters), WebdriverIO `expect(...)` matchers, and Nightwatch `browser.assert`/`verify`. Set `false` to opt out. | -| `filmstrip` | `boolean` | `true` | Record a dense, continuous screencast *into* the trace for smooth scrubbing in the player (not just one frame per action). Dense frames sit alongside the per-action frames; thinned + content-addressed at export. Runs the screencast recorder (CDP push on Chrome, polling elsewhere). The polling recorder skips a tick while a command or a per-action capture holds the driver, and keeps each per-action capture's screenshot as a frame, so its polls do not queue in front of the test. | +| `filmstrip` | `boolean` | `true` | Record a dense, continuous screencast *into* the trace for smooth scrubbing in the player (not just one frame per action). Dense frames sit alongside the per-action frames; thinned + content-addressed at export. Runs the screencast recorder (CDP push on Chrome, polling elsewhere). The polling recorder skips a tick while a command or a per-action capture holds the driver (up to three intervals without a frame, never during a click or typing), and keeps each per-action capture's screenshot as a frame, so its polls do not queue in front of the test. | | `maskInputs` | `boolean` | `true` | Mask password fields in the replayed page, live and trace mode alike; `false` records them as typed. Command rows always keep the typed text. See [Password masking](#-password-masking). | | `maskSelectors` | `string[]` | `[]` | Also mask the text and value of every element these CSS selectors match in the replayed page. | | `traceResources` | `'styles'` \| `'none'` | `'styles'` | Which page resources the trace stores so its replay does not load them from the site. `'styles'` stores the stylesheets and fonts each archive's pages used, each once by content hash, capped at 1 MiB per resource and 8 MiB per archive (past the cap a resource replays live); `'none'` stores nothing. Images always replay from the site. The page collector reads each one back from the browser's HTTP cache, cross-origin ones only where the site allows CORS (a same-origin stylesheet it cannot read again falls back to its parsed rules), and the trace leaves those reads out of its network rows. Live mode reads nothing back. | diff --git a/packages/nightwatch-devtools/README.md b/packages/nightwatch-devtools/README.md index ccb95164..3b6dc82e 100644 --- a/packages/nightwatch-devtools/README.md +++ b/packages/nightwatch-devtools/README.md @@ -252,7 +252,7 @@ The encoded video is sent to the DevTools dashboard via the `screencast` WS scop | `traceFormat` | `'zip' \| 'ndjson-directory'` | `'zip'` | Trace artifact layout — `zip` writes a single archive, `ndjson-directory` unpacks the same files into `trace-<sessionId>/`. Only applies when `mode: 'trace'`. | | `traceGranularity` | `'session' \| 'spec' \| 'test'` | `'session'` | One trace per session / spec file / test. `'test'` is what the per-test `screenshot` / `video` artifacts attach to. Only applies when `mode: 'trace'`. **Caveat:** the BDD `describe/it` interface collapses to a single session-scoped slice (see [Per-test slicing](#per-test-slicing--the-bdd-describeit-caveat)). For Cucumber, `'session'` spans every scenario in one zip; `'test'` gives one per scenario and is the recommendation. | | `tracePolicy` | `'on' \| 'retain-on-failure' \| 'retain-on-first-failure' \| 'on-first-retry' \| 'on-all-retries' \| 'retain-on-failure-and-retries'` | `'on'` | Which traces to keep. Pairs with `traceGranularity: 'test'`. Only applies when `mode: 'trace'`. | -| `filmstrip` | `boolean` | `true` | Record a dense, continuous screencast filmstrip into the trace for scrubbable playback in the trace player, not just one frame per action. Runs the screencast recorder (polling mode on Nightwatch) for the session. The polling recorder skips a tick while a command or a per-action capture holds the driver, and keeps each per-action capture's screenshot as a frame, so its polls do not queue in front of the test. Only applies when `mode: 'trace'`. | +| `filmstrip` | `boolean` | `true` | Record a dense, continuous screencast filmstrip into the trace for scrubbable playback in the trace player, not just one frame per action. Runs the screencast recorder (polling mode on Nightwatch) for the session. The polling recorder skips a tick while a command or a per-action capture holds the driver (up to three intervals without a frame, never during a click or typing), and keeps each per-action capture's screenshot as a frame, so its polls do not queue in front of the test. Only applies when `mode: 'trace'`. | | `traceResources` | `'styles'` \| `'none'` | `'styles'` | Trace mode only. `'styles'` stores the stylesheets and fonts each archive's pages used, once each by content hash, so the player replays them from the archive rather than the site; `'none'` stores nothing. Images always replay from the site. See the [root README](../../README.md#options) for caps and how the reads are kept out of the network rows. | | `screenshot` | `'off' \| 'on' \| 'only-on-failure'` | `'off'` | Per-test screenshot. Trace mode + `traceGranularity: 'test'` only. **Produce-only** — the PNG is written to the trace output dir (and listed in the artifacts manifest when `emitArtifactsManifest: true`); it is NOT attached inline to Allure (see note below). | | `video` | `'off' \| TraceRetentionPolicy` | `'off'` | Per-test video slice, retained per the given policy (e.g. `'retain-on-failure'`). Trace mode + `traceGranularity: 'test'` only. Setting a non-`off` policy starts the screencast recorder itself — you do **not** also need `filmstrip` or `screencast.enabled`; the recorder runs continuously for the session and each test's slice is cut from it by wall-clock window. **Produce-only** — the `.webm` is written to the trace output dir (and listed in the manifest when `emitArtifactsManifest: true`); NOT attached inline to Allure. | diff --git a/packages/selenium-devtools/README.md b/packages/selenium-devtools/README.md index 14a3dc52..b9825064 100644 --- a/packages/selenium-devtools/README.md +++ b/packages/selenium-devtools/README.md @@ -334,7 +334,7 @@ DevTools.configure({ mode: 'trace', traceGranularity: 'test' }) **Default:** `'zip'` (a single `trace-<sessionId>.zip`). `'ndjson-directory'` writes the same `trace.trace` + `trace.network` + `resources/` layout unpacked into `trace-<id>/`, skipping the unzip step for scripted / agentic consumers. Trace mode only. #### `filmstrip` — dense scrubbable screencast in the trace -**Default:** `true`. Records a continuous screencast (CDP push on Chrome, screenshot polling elsewhere) into the trace so the player timeline scrubs frame-by-frame. On by default in trace mode; set `false` to record just one frame per action. Trace mode only. The polling recorder skips a tick while a command or a per-action capture holds the driver, and keeps each per-action capture's screenshot as a frame, so its polls do not queue in front of the test. +**Default:** `true`. Records a continuous screencast (CDP push on Chrome, screenshot polling elsewhere) into the trace so the player timeline scrubs frame-by-frame. On by default in trace mode; set `false` to record just one frame per action. Trace mode only. The polling recorder skips a tick while a command or a per-action capture holds the driver (up to three intervals without a frame, never during a click or typing), and keeps each per-action capture's screenshot as a frame, so its polls do not queue in front of the test. ```javascript DevTools.configure({ mode: 'trace', filmstrip: true }) @@ -405,7 +405,7 @@ The trace uses a portable NDJSON schema, so the same `.zip` (or `ndjson-director | `traceFormat` | `'zip' \| 'ndjson-directory'` | `'zip'` | Trace artifact layout — a single `.zip` or an unpacked directory. Trace mode only. | | `traceGranularity` | `'session' \| 'spec' \| 'test'` | `'session'` | One trace per session / spec file / test. `'test'` writes each to `test-results/<spec>-<title>-<browser>[-retryN]/trace.zip`. Trace mode only. | | `tracePolicy` | `'on' \| 'retain-on-failure' \| 'retain-on-first-failure' \| 'on-first-retry' \| 'on-all-retries' \| 'retain-on-failure-and-retries'` | `'on'` | Which traces to keep. Pairs with `traceGranularity: 'test'`. Trace mode only. | -| `filmstrip` | `boolean` | `true` | Record a dense, continuous screencast into the trace for frame-by-frame scrubbing in the player. Trace mode only. The polling recorder skips a tick while a command or a per-action capture holds the driver, and keeps each per-action capture's screenshot as a frame, so its polls do not queue in front of the test. | +| `filmstrip` | `boolean` | `true` | Record a dense, continuous screencast into the trace for frame-by-frame scrubbing in the player. Trace mode only. The polling recorder skips a tick while a command or a per-action capture holds the driver (up to three intervals without a frame, never during a click or typing), and keeps each per-action capture's screenshot as a frame, so its polls do not queue in front of the test. | | `traceResources` | `'styles'` \| `'none'` | `'styles'` | Trace mode only. `'styles'` stores the stylesheets and fonts each archive's pages used, once each by content hash, so the player replays them from the archive rather than the site; `'none'` stores nothing. Images always replay from the site. See the [root README](../../README.md#options) for caps and how the reads are kept out of the network rows. | | `screenshot` | `'off' \| 'on' \| 'only-on-failure'` | `'off'` | Trace mode + `traceGranularity: 'test'`. Per-test screenshot, attached inline to Allure (`image/png`) via `allure-js-commons` when an Allure runner adapter is active. | | `video` | `'off' \| TraceRetentionPolicy` | `'off'` | Trace mode + `traceGranularity: 'test'`. Per-test screencast video, retained per the given policy, attached inline to Allure (`video/webm`) via `allure-js-commons` when an Allure runner adapter is active. | diff --git a/packages/service/README.md b/packages/service/README.md index 57c91559..b25d5db3 100644 --- a/packages/service/README.md +++ b/packages/service/README.md @@ -273,7 +273,7 @@ services: [['devtools', options]] | `tracePolicy` | `TraceRetentionPolicy` | `'on'` | Trace mode only. Which traces to keep: `'on'` \| `'retain-on-failure'` \| `'retain-on-first-failure'` \| `'on-first-retry'` \| `'on-all-retries'` \| `'retain-on-failure-and-retries'`. The retry-aware policies pair best with `traceGranularity: 'test'`. | | `screenshot` | `'off' \| 'on' \| 'only-on-failure'` | `'off'` | Trace mode + `traceGranularity: 'test'`. Per-test screenshot, attached inline to Allure (`image/png`). WDIO-service-specific. | | `video` | `'off' \| TraceRetentionPolicy` | `'off'` | Trace mode + `traceGranularity: 'test'`. Per-test screencast video, retained per the given policy, attached inline to Allure (`video/webm`). WDIO-service-specific. | -| `filmstrip` | `boolean` | `true` | Trace mode only. Records a dense, continuous screencast filmstrip *into* the trace so the player scrubs smooth playback: dense frames are added alongside the per-action frames (not one frame per action). Frames are thinned (≥100 ms apart, ~600 max) and content-addressed (identical frames, such as a static wait, collapse to one resource); windowed per slice at any `traceGranularity`. Runs the screencast recorder (CDP push on Chrome, polling elsewhere). The polling recorder skips a tick while a command or a per-action capture holds the driver, and keeps each per-action capture's screenshot as a frame, so its polls do not queue in front of the test. | +| `filmstrip` | `boolean` | `true` | Trace mode only. Records a dense, continuous screencast filmstrip *into* the trace so the player scrubs smooth playback: dense frames are added alongside the per-action frames (not one frame per action). Frames are thinned (≥100 ms apart, ~600 max) and content-addressed (identical frames, such as a static wait, collapse to one resource); windowed per slice at any `traceGranularity`. Runs the screencast recorder (CDP push on Chrome, polling elsewhere). The polling recorder skips a tick while a command or a per-action capture holds the driver (up to three intervals without a frame, never during a click or typing), and keeps each per-action capture's screenshot as a frame, so its polls do not queue in front of the test. | | `traceResources` | `'styles'` \| `'none'` | `'styles'` | Trace mode only. `'styles'` stores the stylesheets and fonts each archive's pages used, once each by content hash, so the player replays them from the archive rather than the site; `'none'` stores nothing. Images always replay from the site. See the [root README](../../README.md#options) for caps and how the reads are kept out of the network rows. | | `emitArtifactsManifest` | `boolean` | `false` | Trace mode only. Writes `devtools-artifacts-<sessionId>.json` next to the trace — a generic index of every produced artifact (trace/screenshot/video) plus each test's state, for reporters/CI to consume. Off by default; **auto-enabled when `@wdio/allure-reporter` is in the config**. | | `captureAssertions` | `boolean` | `true` | Capture assertions as command/action rows — `node:assert` plus passing *and* failing expect-webdriverio matchers, folded into single `expect.<matcher>` rows (e.g. `toHaveText`, `toExist`). Set `false` to opt out. | From 870901b65ee7e04a72b3732f074e3499195146f2 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan <vishnu.p@browserstack.com> Date: Tue, 10 Nov 2026 17:59:24 +0530 Subject: [PATCH 08/10] Keep the gap cap out of captures and every input dispatch The gap cap counted from the newest frame, usually the previous capture's, so a 1.4 to 2.4 s native capture crossed it partway and the capped poll queued behind the capture's own screenshot, adding nothing and holding the driver in front of the next command. A capture window is now never overridden; its own screenshot is the frame. WDIO runs its hooks for the protocol commands nested in click and setValue, whose single window then dropped the input mark, so elementClick, elementSendKeys, elementClear, performActions and releaseActions count as input, as do Nightwatch's updateValue, setPassword, clickAndHold and releaseMouseButton. Selenium's actions().perform() goes through execute(), so it opens its input window itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/core/src/driver-activity.ts | 68 ++++++++----------- packages/core/src/screencast.ts | 21 +++--- packages/core/tests/driver-activity.test.ts | 29 ++++++-- packages/core/tests/screencast.test.ts | 28 +++++++- .../src/helpers/actions-patcher.ts | 5 +- .../tests/driverPatcher.test.ts | 15 ++++ packages/service/tests/index.test.ts | 11 +++ packages/shared/src/trace-actions.ts | 26 ++++--- 8 files changed, 135 insertions(+), 68 deletions(-) diff --git a/packages/core/src/driver-activity.ts b/packages/core/src/driver-activity.ts index 0ff0108c..6a99ef5f 100644 --- a/packages/core/src/driver-activity.ts +++ b/packages/core/src/driver-activity.ts @@ -3,23 +3,21 @@ import { occupiesDriver } from '@wdio/devtools-shared' -// A driver serves one session's requests one at a time, so a filmstrip poll -// issued while a command or a capture holds the driver only queues in front of -// the test, and a poll landing inside chromedriver's click (after it computed -// the element's coordinates, before it dispatches at them) makes the click -// report success while activating nothing. One instance per adapter bundle, not -// per session: both halves are module-scoped and must be imported from the same -// bundle to see the same gate. +// One gate per adapter bundle: a driver serves one request at a time, so a poll issued while it is held only queues. /** Bound on one window: a failing Nightwatch test discards its command queue, * so the callback that closes a window may never fire. */ export const DRIVER_ACTIVITY_MAX_HOLD_MS = 3000 +/** What holds the driver: an input command no poll may overlap, a capture that + * delivers its own frame, or any other command. */ +export type DriverWindowKind = 'command' | 'input' | 'capture' + const NOOP = (): void => {} interface OpenWindow { openedAt: number - input: boolean + kind: DriverWindowKind } export class InFlightGate { @@ -28,44 +26,31 @@ export class InFlightGate { constructor(private readonly maxHoldMs = DRIVER_ACTIVITY_MAX_HOLD_MS) {} - /** Open a window and return its closer; closing twice is harmless. `input` - * marks a command dispatching trusted input, which no poll may overlap. */ - open(input = false): () => void { + /** Open a window and return its closer; closing twice is harmless. */ + open(kind: DriverWindowKind = 'command'): () => void { const token = this.#nextToken++ - this.#open.set(token, { openedAt: Date.now(), input }) + this.#open.set(token, { openedAt: Date.now(), kind }) return () => { this.#open.delete(token) } } /** - * Whether an unexpired window is open. Expired windows are dropped here, so a - * closer that never runs costs one bounded stall rather than a permanent one - * and cannot leak entries for the rest of the run. + * Whether an unexpired window (of `kind`, when given) is open. Expired + * windows are dropped here, so a closer that never runs costs one bounded + * stall rather than a permanent one and cannot leak entries for the run. */ - isOpen(): boolean { - this.#prune() - return this.#open.size > 0 - } - - /** Whether an unexpired window of an input-dispatching command is open. */ - isInputOpen(): boolean { - this.#prune() - for (const window of this.#open.values()) { - if (window.input) { - return true - } - } - return false - } - - #prune(): void { + isOpen(kind?: DriverWindowKind): boolean { const cutoff = Date.now() - this.maxHoldMs + let found = false for (const [token, window] of this.#open) { if (window.openedAt <= cutoff) { this.#open.delete(token) + } else if (kind === undefined || window.kind === kind) { + found = true } } + return found } } @@ -74,14 +59,15 @@ const sharedGate = new InFlightGate() /** Adapter side: open the window around a runner command. Returns its closer, * a no-op for a command that does not hold the driver (`occupiesDriver`). */ export function beginDriverCommand(command: string): () => void { - return occupiesDriver(command) - ? sharedGate.open(isInputDispatchingCommand(command)) - : NOOP + if (isInputDispatchingCommand(command)) { + return sharedGate.open('input') + } + return occupiesDriver(command) ? sharedGate.open('command') : NOOP } /** Capture side: open the window around a per-action capture's probes. */ export function beginDriverCapture(): () => void { - return sharedGate.open() + return sharedGate.open('capture') } /** Recorder side: is a command or a capture holding the driver right now? */ @@ -89,8 +75,12 @@ export function isDriverBusy(): boolean { return sharedGate.isOpen() } -/** Recorder side: is a command dispatching input right now? A poll then must - * wait however stale the filmstrip is, or the click can activate nothing. */ +/** Recorder side: is a command dispatching input right now? A poll inside it makes the click activate nothing. */ export function isInputDispatchInFlight(): boolean { - return sharedGate.isInputOpen() + return sharedGate.isOpen('input') +} + +/** Recorder side: is a per-action capture running? Its own screenshot is the frame a poll would add. */ +export function isCaptureInFlight(): boolean { + return sharedGate.isOpen('capture') } diff --git a/packages/core/src/screencast.ts b/packages/core/src/screencast.ts index ba063c14..8020bd72 100644 --- a/packages/core/src/screencast.ts +++ b/packages/core/src/screencast.ts @@ -1,7 +1,11 @@ import type { ScreencastFrame, ScreencastOptions } from '@wdio/devtools-shared' import { hasScreenshot, SCREENCAST_DEFAULTS } from '@wdio/devtools-shared' import { performance } from 'node:perf_hooks' -import { isDriverBusy, isInputDispatchInFlight } from './driver-activity.js' +import { + isCaptureInFlight, + isDriverBusy, + isInputDispatchInFlight +} from './driver-activity.js' import { recordRecorderReuse, recordRecorderShot, @@ -376,22 +380,15 @@ export abstract class ScreencastRecorderBase<TDriver = unknown> { } #skipReason(): RecorderSkipReason | undefined { - // A poll landing mid-click makes the click report success while - // dispatching nothing, so input waits however stale the filmstrip is. The - // gate is bounded, so a command whose completion never arrives costs - // frames only until it expires. - if (isInputDispatchInFlight()) { + // Never past an input command (a poll mid-click activates nothing) or a capture (it brings its own frame). + if (isInputDispatchInFlight() || isCaptureInFlight()) { return 'busy' } - // setInterval does not wait for the tick. A screenshot slower than the - // interval stacks requests that a serialised driver then serves ahead of - // the test's own commands (a native session's screenshot runs ~1.2 s - // against a 200 ms default), so keep at most one outstanding. + // One outstanding shot: a slow native screenshot would stack requests in front of the test. if (this.#pollInFlight) { return 'inFlight' } - // Any other command or a capture holding the driver would only have a poll - // queue in front of it, until the newest frame is too old to wait longer. + // Any other command: a poll only queues behind it, until the filmstrip has waited the cap. if ( isDriverBusy() && this.#newestFrameAge() < diff --git a/packages/core/tests/driver-activity.test.ts b/packages/core/tests/driver-activity.test.ts index b5331d9e..f5582c49 100644 --- a/packages/core/tests/driver-activity.test.ts +++ b/packages/core/tests/driver-activity.test.ts @@ -7,6 +7,7 @@ import { InFlightGate, beginDriverCapture, beginDriverCommand, + isCaptureInFlight, isDriverBusy, isInputDispatchInFlight } from '../src/driver-activity.js' @@ -134,7 +135,16 @@ describe('input windows', () => { 'moveToElement', 'mouseButtonClick', 'rightClick', - 'submitForm' + 'submitForm', + 'updateValue', + 'setPassword', + 'clickAndHold', + 'releaseMouseButton', + 'elementClick', + 'elementSendKeys', + 'elementClear', + 'performActions', + 'releaseActions' ]) { expect(isInputDispatchingCommand(command)).toBe(true) } @@ -157,9 +167,20 @@ describe('input windows', () => { it('lets an input window expire like any other', async () => { const gate = new InFlightGate(20) - gate.open(true) - expect(gate.isInputOpen()).toBe(true) + gate.open('input') + expect(gate.isOpen('input')).toBe(true) await sleep(40) - expect(gate.isInputOpen()).toBe(false) + expect(gate.isOpen('input')).toBe(false) + }) + + it('reports a capture apart from a command', () => { + const closeRead = beginDriverCommand('getText') + expect(isCaptureInFlight()).toBe(false) + const closeCapture = beginDriverCapture() + expect(isCaptureInFlight()).toBe(true) + expect(isInputDispatchInFlight()).toBe(false) + closeCapture() + closeRead() + expect(isDriverBusy()).toBe(false) }) }) diff --git a/packages/core/tests/screencast.test.ts b/packages/core/tests/screencast.test.ts index b18aa416..4c6d6e57 100644 --- a/packages/core/tests/screencast.test.ts +++ b/packages/core/tests/screencast.test.ts @@ -744,13 +744,35 @@ describe('ScreencastRecorderBase: gap cap while the driver stays busy', () => { vi.useRealTimers() }) - it('caps the gap through a long run of captures too', async () => { + it('issues no poll while a capture window is open, however stale the filmstrip', async () => { vi.useFakeTimers() const r = new TestRecorder({ pollIntervalMs: INTERVAL }) await r.start({ name: 'driver' }) + const shots = r.shotsTaken + // A native capture runs 1.4 to 2.4 s; a capped poll would only queue behind its own screenshot. const close = beginDriverCapture() - await vi.advanceTimersByTimeAsync(CAP * 4) - close() + try { + await vi.advanceTimersByTimeAsync(CAP * 4) + expect(r.shotsTaken).toBe(shots) + } finally { + close() + } + await vi.advanceTimersByTimeAsync(INTERVAL) + expect(r.shotsTaken).toBe(shots + 1) + await r.stop() + vi.useRealTimers() + }) + + it('still applies the cap to a non-input command held as long', async () => { + vi.useFakeTimers() + const r = new TestRecorder({ pollIntervalMs: INTERVAL }) + await r.start({ name: 'driver' }) + const close = beginDriverCommand('execute') + try { + await vi.advanceTimersByTimeAsync(CAP * 4) + } finally { + close() + } await r.stop() expect(r.frames.length).toBeGreaterThanOrEqual(5) expect(largestGap(r)).toBeLessThanOrEqual(CAP) diff --git a/packages/selenium-devtools/src/helpers/actions-patcher.ts b/packages/selenium-devtools/src/helpers/actions-patcher.ts index d9e93562..d4107fd5 100644 --- a/packages/selenium-devtools/src/helpers/actions-patcher.ts +++ b/packages/selenium-devtools/src/helpers/actions-patcher.ts @@ -1,5 +1,5 @@ import logger from '@wdio/logger' -import { errorMessage } from '@wdio/devtools-core' +import { beginDriverCommand, errorMessage } from '@wdio/devtools-core' import { PATCHED_SYMBOL } from '../constants.js' import type { DriverPatcherHooks, @@ -25,9 +25,12 @@ function patchActionsPerform( this: unknown ) => Promise<void> actionsProto.perform = async function patchedPerform(this: unknown) { + // perform() goes through execute(), which no wrapped method opens a window for. + const closeInputWindow = beginDriverCommand('performActions') try { await originalPerform.call(this) } finally { + closeInputWindow() const driver = actionsOwners.get(this as object) if (driver && hooks.afterActions) { try { diff --git a/packages/selenium-devtools/tests/driverPatcher.test.ts b/packages/selenium-devtools/tests/driverPatcher.test.ts index 7fa59c3c..1f60a604 100644 --- a/packages/selenium-devtools/tests/driverPatcher.test.ts +++ b/packages/selenium-devtools/tests/driverPatcher.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi } from 'vitest' +import { isInputDispatchInFlight } from '@wdio/devtools-core' import { isWebElementLike, safeSerialize, @@ -196,4 +197,18 @@ describe('patchActions', () => { expect(afterActions).toHaveBeenCalledTimes(1) }) + + it('holds an input window while a sequence performs, and closes it after a failure too', async () => { + const FakeDriver = fakeDriverClass() + patchActions(FakeDriver, hooksWith()) + const driver = new FakeDriver() + + const pending = driver.actions().perform() + expect(isInputDispatchInFlight()).toBe(true) + await pending + expect(isInputDispatchInFlight()).toBe(false) + + await expect(driver.actions({ fails: true }).perform()).rejects.toThrow() + expect(isInputDispatchInFlight()).toBe(false) + }) }) diff --git a/packages/service/tests/index.test.ts b/packages/service/tests/index.test.ts index 4e90922c..396924ac 100644 --- a/packages/service/tests/index.test.ts +++ b/packages/service/tests/index.test.ts @@ -5,6 +5,7 @@ import { configureInputMasking, DRIVER_ACTIVITY_MAX_HOLD_MS, isDriverBusy, + isInputDispatchInFlight, rememberReadValue, selectorForReadValue } from '@wdio/devtools-core' @@ -392,6 +393,16 @@ describe('DevtoolsService - Internal Command Filtering', () => { expect(isDriverBusy()).toBe(false) }) + // WDIO re-runs the hooks for the protocol command inside `click`, which replaces the single window. + it('keeps an input window through the nested protocol command', async () => { + await service.beforeCommand('click' as any, ['.button']) + await service.beforeCommand('elementClick' as any, ['element-id']) + expect(isInputDispatchInFlight()).toBe(true) + await service.afterCommand('elementClick' as any, ['element-id'], null) + await service.afterCommand('click' as any, ['.button'], {}) + expect(isDriverBusy()).toBe(false) + }) + it('holds the window open for the duration of a command', async () => { await service.beforeCommand('click' as any, ['.button']) expect(isDriverBusy()).toBe(true) diff --git a/packages/shared/src/trace-actions.ts b/packages/shared/src/trace-actions.ts index 70608799..3e6bf3db 100644 --- a/packages/shared/src/trace-actions.ts +++ b/packages/shared/src/trace-actions.ts @@ -239,18 +239,26 @@ export function isKeyboardCommand(command: string): boolean { return KEYBOARD_COMMANDS.has(command) } -/** Runner-native pointer commands the trace vocabulary deliberately doesn't - * name: Nightwatch's low-level mouse API has no meaningful trace method, so - * adding it to ACTION_MAP would put noise in the timeline. Listed here because - * they still dispatch, and a command that dispatches unrecognized keeps the - * screenshot poll running straight through it. */ -const UNMAPPED_POINTER_COMMANDS: ReadonlySet<string> = new Set([ +/** Input commands the trace vocabulary deliberately doesn't name: Nightwatch's + * low-level mouse and value API, and the WebDriver protocol commands WDIO runs + * its own hooks for inside `click`/`setValue`, so a nested one must stay input. + * Listed because a command that dispatches unrecognized lets a poll race it. */ +const UNMAPPED_INPUT_COMMANDS: ReadonlySet<string> = new Set([ 'moveToElement', 'mouseButtonClick', 'mouseButtonDown', 'mouseButtonUp', 'rightClick', - 'submitForm' + 'submitForm', + 'updateValue', + 'setPassword', + 'clickAndHold', + 'releaseMouseButton', + 'elementClick', + 'elementSendKeys', + 'elementClear', + 'performActions', + 'releaseActions' ]) /** @@ -259,10 +267,10 @@ const UNMAPPED_POINTER_COMMANDS: ReadonlySet<string> = new Set([ * a command qualifies when it types (`isKeyboardCommand`) or when its trace * method acts at a point (`POINTABLE_METHODS`). A command wrongly classified as * non-dispatching is the costly direction: it leaves the screencast poll racing - * the dispatch, so the unmapped pointer commands above are named explicitly. + * the dispatch, so the unmapped input commands above are named explicitly. */ export function isInputDispatchingCommand(command: string): boolean { - if (isKeyboardCommand(command) || UNMAPPED_POINTER_COMMANDS.has(command)) { + if (isKeyboardCommand(command) || UNMAPPED_INPUT_COMMANDS.has(command)) { return true } const action = ACTION_MAP[command] From ada9fd58a5f390fbdb7a54d832baa7c796651ad2 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan <vishnu.p@browserstack.com> Date: Tue, 10 Nov 2026 17:59:24 +0530 Subject: [PATCH 09/10] Open no Nightwatch driver window for browser.element() In Nightwatch v3 browser.element() builds a scoped element and never calls the appended callback, yet it is listed among the read-only commands, so it held the gate to its bound on every call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../nightwatch-devtools/src/helpers/browserProxy.ts | 7 +------ .../nightwatch-devtools/src/helpers/commandWindow.ts | 12 +++++++++--- .../nightwatch-devtools/tests/browserProxy.test.ts | 2 ++ 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/nightwatch-devtools/src/helpers/browserProxy.ts b/packages/nightwatch-devtools/src/helpers/browserProxy.ts index 58a7de79..e3d811d4 100644 --- a/packages/nightwatch-devtools/src/helpers/browserProxy.ts +++ b/packages/nightwatch-devtools/src/helpers/browserProxy.ts @@ -571,12 +571,7 @@ export class BrowserProxy { const cmd = this.pendingCommand(methodName, args) const captureCallback = this.makeCaptureCallback(browser, cmd) - // Holds the filmstrip poll off the driver while this command runs (see core - // driver-activity.ts), for a command known to call back (commandWindow.ts). - // Enqueue is the only hook the adapter has, Nightwatch executes off a queue - // it exposes no start event for, so an awaited call (enqueue ≈ execution) is - // covered, while one queued behind a long wait can see its window expire - // before it runs. The bound is what keeps that from stalling the recorder. + // Opened at enqueue, Nightwatch's only hook; a command queued behind a long wait can outlive its bounded window. const closeCommandWindow = beginCommandWindow(methodName) const gatedCallback = (callbackResult: unknown) => { closeCommandWindow() diff --git a/packages/nightwatch-devtools/src/helpers/commandWindow.ts b/packages/nightwatch-devtools/src/helpers/commandWindow.ts index 8defe8ce..5070566b 100644 --- a/packages/nightwatch-devtools/src/helpers/commandWindow.ts +++ b/packages/nightwatch-devtools/src/helpers/commandWindow.ts @@ -7,11 +7,17 @@ import { const NOOP = (): void => {} +/** Nightwatch v3's `browser.element()` builds a scoped element and never calls back, outside backwards-compatibility mode. */ +const BUILDER_COMMANDS: ReadonlySet<string> = new Set(['element']) + /** Whether a wrapped command is known to reach the driver and call back. The - * window closes from the appended callback, so a builder or scoped API - * (`actions()`, `element()`) or a custom command that never calls back would - * hold the filmstrip's gate to its bound on every call. */ + * window closes from the appended callback, so a builder (`actions()`, + * `element()`) or a custom command that never calls back would hold the + * filmstrip's gate to its bound on every call. */ export function callsBackFromDriver(command: string): boolean { + if (BUILDER_COMMANDS.has(command)) { + return false + } return ( isInputDispatchingCommand(command) || mapCommandToAction(command) !== null || diff --git a/packages/nightwatch-devtools/tests/browserProxy.test.ts b/packages/nightwatch-devtools/tests/browserProxy.test.ts index 6322b5b2..0be0af13 100644 --- a/packages/nightwatch-devtools/tests/browserProxy.test.ts +++ b/packages/nightwatch-devtools/tests/browserProxy.test.ts @@ -933,6 +933,7 @@ describe('BrowserProxy driver-activity window', () => { it('opens no window for a function that never calls back', () => { const browser = { actions: () => ({ move: () => undefined }), + element: () => ({ click: () => undefined }), myCustomCommand: () => undefined } as unknown as NightwatchBrowser const { capturer } = makeCapturer() @@ -942,6 +943,7 @@ describe('BrowserProxy driver-activity window', () => { const b = browser as unknown as Record<string, (...a: unknown[]) => unknown> b.actions() + b.element('#a') b.myCustomCommand() expect(isDriverBusy()).toBe(false) }) From 2ecbf400d76ee5615d48981139c0bf6f8c86e3d1 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan <vishnu.p@browserstack.com> Date: Tue, 10 Nov 2026 18:00:10 +0530 Subject: [PATCH 10/10] State the filmstrip gap honestly and list the stranded-window residual A capped poll still waits behind the busy command and takes its own screenshot time, so the longest gap is about three intervals plus one screenshot, and captures as well as input commands are never overridden. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .changeset/filmstrip-poll-queue.md | 2 +- CLAUDE.md | 2 +- README.md | 4 ++-- packages/nightwatch-devtools/README.md | 2 +- packages/selenium-devtools/README.md | 4 ++-- packages/service/README.md | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.changeset/filmstrip-poll-queue.md b/.changeset/filmstrip-poll-queue.md index c7b0c283..48d77a32 100644 --- a/.changeset/filmstrip-poll-queue.md +++ b/.changeset/filmstrip-poll-queue.md @@ -4,4 +4,4 @@ '@wdio/nightwatch-devtools': patch --- -Without a CDP screencast (Safari, Appium), the `filmstrip` recorder no longer queues its screenshots in front of the test. It skips a poll while a command or a per-action capture holds the driver (polling anyway once the newest frame is three intervals old, except during a click or typing), keeps each per-action capture's screenshot as a filmstrip frame, and skips the poll that would only repeat it. The interval and the filmstrip default are unchanged, and Chrome's CDP push mode is untouched. With `DEVTOOLS_CAPTURE_TIMING=1`, the report adds a `filmstrip recorder` line: the screenshots the recorder took and their time, the polls it skipped and why, and the capture screenshots it kept as frames. +Without a CDP screencast (Safari, Appium), the `filmstrip` recorder no longer queues its screenshots in front of the test. It skips a poll while a command or a per-action capture holds the driver (polling anyway once the newest frame is three intervals old, so the longest gap is about three intervals plus one screenshot, but never during a click, typing or a capture), keeps each per-action capture's screenshot as a filmstrip frame, and skips the poll that would only repeat it. The interval and the filmstrip default are unchanged, and Chrome's CDP push mode is untouched. With `DEVTOOLS_CAPTURE_TIMING=1`, the report adds a `filmstrip recorder` line: the screenshots the recorder took and their time, the polls it skipped and why, and the capture screenshots it kept as frames. diff --git a/CLAUDE.md b/CLAUDE.md index dd0d273d..b8f7036d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -399,7 +399,7 @@ Documented divergences from the conventions above. They exist today as debt to b - **Measure before proposing a change: `DEVTOOLS_CAPTURE_TIMING=1`.** Every adapter then times each probe of each capture (issue and settle, so a probe's own service time is separable from its wait behind the probes the driver served first) and, when the trace is written, prints a per-probe table and appends the raw records to `capture-timing-<pid>.ndjson` beside the trace. The JS recording is `core/capture-timing.ts`, wired once in `captureActionSnapshot` and reported from `finalizeTraceExport`, so no adapter carries more than the capture's trigger (action, wait poll, matcher poll, final); Python mirrors it in `capture_timing.py`, held to the same summary by `mirror-vectors.json`. Off, it costs a flag read per capture. The per-platform commands are in the README's "Measuring capture cost". The polling filmstrip recorder's own screenshots are counted beside it (`recordRecorderShot`/`recordRecorderSkip`/`recordRecorderReuse`, one `filmstrip recorder:` line, not written to the NDJSON); Python has no polling recorder and prints none, so the mirrored summary is unchanged. The shot durations are a ring of the newest 2000 (`RECORDER_SHOT_SAMPLE_MAX`), since a live run never reports and never drains them. - **One capture per action means two driver round trips per action, and the platform decides which half is expensive.** Measured on Appium 3.7.0 / UiAutomator2 (emulator-5554, Android API 37, 1080×2424): `GET /screenshot` 1.18–2.22 s (steady ~1.20 s) at 1.86 MB, `GET /source` (page-source XML) 0.09–0.53 s at 40 KB, `window/rect` 0.015 s — the screenshot dominates by ~10× on Android. **iOS is unmeasured**; the issue claims the split inverts there, and the native example (`examples/wdio/mocha/wdio.native.conf.ts`) is what makes it measurable. Measured per action: two captures 2.41 s against one capture 1.19 s; end-to-end on the native example 19.1 s against 12.4 s, with live mode (no per-action capture) at 5.9 s. That is the case for the `beforeCommand` capture, and the reason an eager post-action capture is not worth re-adding: it cost a second capture per action and existed only to be patched by a `readyState` poll that could not tell a document that had not navigated yet from one that was loading. - **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. One outstanding shot still queues in front of everything after it (measured in #516: a per-action screenshot 600-665 ms on the Android emulator with the poller running, 229 ms without), so a tick is also skipped while the driver is held: `core/driver-activity.ts` keeps one bounded in-flight gate per adapter bundle, opened by each adapter's command hook (`beginDriverCommand`, the service's `beforeCommand`, Selenium's patched methods, Nightwatch's proxy at enqueue) and by `captureActionSnapshot` around its probes (`beginDriverCapture`). A wait and a host-side command (`pause`, `perform`, ...) open none (shared `occupiesDriver`): the driver is idle between a wait's polls, and a pause holding the gate would blank the filmstrip for nothing. A skipped tick is dropped rather than deferred, so a run of commands that take no capture (reads, lookups, wait polls, every command of a live run) would starve the filmstrip: once the newest frame is `MAX_FILMSTRIP_GAP_INTERVALS` (3) intervals old the recorder polls through a busy driver anyway, one shot at a time. Never through an input command: the gate marks those windows (`isInputDispatchInFlight`, shared `isInputDispatchingCommand`), because a screenshot between chromedriver's coordinate read and its dispatch makes the click report success and activate nothing. The service opens no window for its own injection calls (`#afterCommand` returns before closing one while `#injecting`), and Nightwatch opens one only for a command known to call back from the driver (`helpers/commandWindow.ts`), since its window closes from the appended callback and a builder API or a custom command that never calls back would hold the gate to its bound. Each per-action capture's screenshot is offered to the recorder as a frame stamped when it settled (`ScreencastRecorderBase.offerCapturedFrame`), and the next tick within one interval is skipped as redundant. The offer is taken only when exactly one recorder is recording in the process and it polls, since the capture names no session; a shot no newer than the newest frame is dropped rather than reordered. Residuals: the gate is process-wide, so in a multi-session process one session's commands also hold the other's poller off; a recorder never stopped stays in the registry and disables reuse for the process; a reused frame still passes through `maxBufferFrames` decimation and export thinning like a polled one. +- 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. One outstanding shot still queues in front of everything after it (measured in #516: a per-action screenshot 600-665 ms on the Android emulator with the poller running, 229 ms without), so a tick is also skipped while the driver is held: `core/driver-activity.ts` keeps one bounded in-flight gate per adapter bundle, opened by each adapter's command hook (`beginDriverCommand`, the service's `beforeCommand`, Selenium's patched methods, Nightwatch's proxy at enqueue) and by `captureActionSnapshot` around its probes (`beginDriverCapture`). A wait and a host-side command (`pause`, `perform`, ...) open none (shared `occupiesDriver`): the driver is idle between a wait's polls, and a pause holding the gate would blank the filmstrip for nothing. A skipped tick is dropped rather than deferred, so a run of commands that take no capture (reads, lookups, wait polls, every command of a live run) would starve the filmstrip: once the newest frame is `MAX_FILMSTRIP_GAP_INTERVALS` (3) intervals old the recorder polls through a busy driver anyway, one shot at a time; that poll still waits behind the command and takes its own screenshot time, so the longest gap is about three intervals plus one screenshot. The gate tags each window (`DriverWindowKind`) and two kinds are never overridden: input (`isInputDispatchInFlight`, shared `isInputDispatchingCommand`, which names the WebDriver protocol commands WDIO re-runs its hooks for inside `click`/`setValue`, since the service keeps one window and the nested one replaces it), because a screenshot between chromedriver's coordinate read and its dispatch makes the click report success and activate nothing; and a capture (`isCaptureInFlight`), whose own screenshot is the frame, where a capped poll would only queue behind it on a 1.4 to 2.4 s native capture and hold the driver in front of the next command. Selenium's `actions().perform()` reaches the driver through `execute()`, so `actions-patcher.ts` opens its input window itself. The service opens no window for its own injection calls (`#afterCommand` returns before closing one while `#injecting`), and Nightwatch opens one only for a command known to call back from the driver (`helpers/commandWindow.ts`), since its window closes from the appended callback and a builder API (`actions()`, v3's scoped `browser.element()`) or a custom command that never calls back would hold the gate to its bound. Each per-action capture's screenshot is offered to the recorder as a frame stamped when it settled (`ScreencastRecorderBase.offerCapturedFrame`), and the next tick within one interval is skipped as redundant. The offer is taken only when exactly one recorder is recording in the process and it polls, since the capture names no session; a shot no newer than the newest frame is dropped rather than reordered. Residuals: the gate is process-wide, so in a multi-session process one session's commands also hold the other's poller off; a recorder never stopped stays in the registry and disables reuse for the process; a reused frame still passes through `maxBufferFrames` decimation and export thinning like a polled one; after a failed assert, Nightwatch's aborted queue strands the input windows of commands enqueued after it, so for up to 3 s the recorder takes no polled frame (trace mode still gets reused capture frames). - **`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. - **Only a command that can change the page is captured** (`shared/trace-actions.ts` `skipsCapture`, a deny-list: reads, waits, lookups and assertions are skipped, anything unlisted captures; the ACTION_MAP reads come from 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. diff --git a/README.md b/README.md index f74444f9..da8dfe64 100644 --- a/README.md +++ b/README.md @@ -348,7 +348,7 @@ The hash names a position in the trace, for a `?trace=` URL, a picked file and ` | `traceGranularity` | `'session'` \| `'spec'` \| `'test'` | `'session'` | `'session'` writes one trace per worker; `'spec'` one per spec file; `'test'` one per test into its own `<spec>-<title>-<browser>[-retryN]/trace.zip` folder — the smallest, most navigable artifacts, and the best pairing for a retention policy. | | `tracePolicy` | `'on'` \| `'retain-on-failure'` \| `'retain-on-first-failure'` \| `'on-first-retry'` \| `'on-all-retries'` \| `'retain-on-failure-and-retries'` | `'on'` | Which traces to keep. `'on'` keeps every trace; the rest keep only failing/retried tests — pairs well with `traceGranularity: 'test'`. | | `captureAssertions` | `boolean` | `true` | Capture assertions as action rows: `node:assert` (all adapters), WebdriverIO `expect(...)` matchers, and Nightwatch `browser.assert`/`verify`. Set `false` to opt out. | -| `filmstrip` | `boolean` | `true` | Record a dense, continuous screencast *into* the trace for smooth scrubbing in the player (not just one frame per action). Dense frames sit alongside the per-action frames; thinned + content-addressed at export. Runs the screencast recorder (CDP push on Chrome, polling elsewhere). The polling recorder skips a tick while a command or a per-action capture holds the driver (up to three intervals without a frame, never during a click or typing), and keeps each per-action capture's screenshot as a frame, so its polls do not queue in front of the test. | +| `filmstrip` | `boolean` | `true` | Record a dense, continuous screencast *into* the trace for smooth scrubbing in the player (not just one frame per action). Dense frames sit alongside the per-action frames; thinned + content-addressed at export. Runs the screencast recorder (CDP push on Chrome, polling elsewhere). The polling recorder skips a tick while a command or a per-action capture holds the driver (once the newest frame is three intervals old it polls anyway, so the longest gap is about three intervals plus one screenshot; never during a click, typing or a capture), and keeps each per-action capture's screenshot as a frame, so its polls do not queue in front of the test. | | `maskInputs` | `boolean` | `true` | Mask password fields in the replayed page, live and trace mode alike; `false` records them as typed. Command rows always keep the typed text. See [Password masking](#-password-masking). | | `maskSelectors` | `string[]` | `[]` | Also mask the text and value of every element these CSS selectors match in the replayed page. | | `traceResources` | `'styles'` \| `'none'` | `'styles'` | Which page resources the trace stores so its replay does not load them from the site. `'styles'` stores the stylesheets and fonts each archive's pages used, each once by content hash, capped at 1 MiB per resource and 8 MiB per archive (past the cap a resource replays live); `'none'` stores nothing. Images always replay from the site. The page collector reads each one back from the browser's HTTP cache, cross-origin ones only where the site allows CORS (a same-origin stylesheet it cannot read again falls back to its parsed rules), and the trace leaves those reads out of its network rows. Live mode reads nothing back. | @@ -401,7 +401,7 @@ raw records: /out/capture-timing-1.ndjson - **Columns.** The JS adapters issue every read of a capture at once and the driver serves them in order, so a read's time from issue to settle includes waiting behind the reads before it. `svc` subtracts that wait: it is the read's own cost. `process` is the in-process step that turns the reads into a snapshot (page-source parsing and locator generation on native). `capture` is the whole capture. - **Busy and wall.** `busy` is the time at least one capture was running, overlapping captures counted once; `wall` is the time since the process started, or since the previous report in the same process. - **Triggers.** `action` is a capture for a user command, `wait-poll` one for a command a wait issued, `matcher-poll` one taken while a WebdriverIO `expect` matcher was polling, and `final` the WebdriverIO capture of the last action's result. Matcher polls are told apart on WebdriverIO only, and only with `captureAssertions` on; Selenium and Nightwatch matchers expose no hook to tell them by. -- **Filmstrip recorder.** With no CDP screencast (Safari, Appium), the `filmstrip` recorder polls a screenshot every 200 ms, and those screenshots share the driver with the captures. The last line counts them: the screenshots it took and their time from issue to settle, the ticks it let pass and why (`driver busy`: a command or a capture held the driver, so a poll would only have queued in front of it; `fresh frame`: a capture's screenshot taken less than one interval earlier is already the frame; `shot in flight`: its previous screenshot had not answered yet), and how many per-action capture screenshots it kept as frames. Captures stay counted in the table only. CDP push mode takes no screenshots and prints no line. Python has no polling recorder; it records one frame per command. +- **Filmstrip recorder.** With no CDP screencast (Safari, Appium), the `filmstrip` recorder polls a screenshot every 200 ms, and those screenshots share the driver with the captures. The last line counts them: the screenshots it took and their time from issue to settle, the ticks it let pass and why (`driver busy`: a command or a capture held the driver, so a poll would only have queued in front of it; once the newest frame is three intervals old it polls anyway, still waiting behind the command, but never during a click, typing or a capture; `fresh frame`: a capture's screenshot taken less than one interval earlier is already the frame; `shot in flight`: its previous screenshot had not answered yet), and how many per-action capture screenshots it kept as frames. Captures stay counted in the table only. CDP push mode takes no screenshots and prints no line. Python has no polling recorder; it records one frame per command. - **Python.** It reads one thing at a time, so `svc` equals the read's duration; it records the screenshot and the two page reads, and has no url, title or `process` rows. To measure each platform from the repo's examples (the desktop examples other than Selenium's drive Chrome only): diff --git a/packages/nightwatch-devtools/README.md b/packages/nightwatch-devtools/README.md index 3b6dc82e..b1d168f5 100644 --- a/packages/nightwatch-devtools/README.md +++ b/packages/nightwatch-devtools/README.md @@ -252,7 +252,7 @@ The encoded video is sent to the DevTools dashboard via the `screencast` WS scop | `traceFormat` | `'zip' \| 'ndjson-directory'` | `'zip'` | Trace artifact layout — `zip` writes a single archive, `ndjson-directory` unpacks the same files into `trace-<sessionId>/`. Only applies when `mode: 'trace'`. | | `traceGranularity` | `'session' \| 'spec' \| 'test'` | `'session'` | One trace per session / spec file / test. `'test'` is what the per-test `screenshot` / `video` artifacts attach to. Only applies when `mode: 'trace'`. **Caveat:** the BDD `describe/it` interface collapses to a single session-scoped slice (see [Per-test slicing](#per-test-slicing--the-bdd-describeit-caveat)). For Cucumber, `'session'` spans every scenario in one zip; `'test'` gives one per scenario and is the recommendation. | | `tracePolicy` | `'on' \| 'retain-on-failure' \| 'retain-on-first-failure' \| 'on-first-retry' \| 'on-all-retries' \| 'retain-on-failure-and-retries'` | `'on'` | Which traces to keep. Pairs with `traceGranularity: 'test'`. Only applies when `mode: 'trace'`. | -| `filmstrip` | `boolean` | `true` | Record a dense, continuous screencast filmstrip into the trace for scrubbable playback in the trace player, not just one frame per action. Runs the screencast recorder (polling mode on Nightwatch) for the session. The polling recorder skips a tick while a command or a per-action capture holds the driver (up to three intervals without a frame, never during a click or typing), and keeps each per-action capture's screenshot as a frame, so its polls do not queue in front of the test. Only applies when `mode: 'trace'`. | +| `filmstrip` | `boolean` | `true` | Record a dense, continuous screencast filmstrip into the trace for scrubbable playback in the trace player, not just one frame per action. Runs the screencast recorder (polling mode on Nightwatch) for the session. The polling recorder skips a tick while a command or a per-action capture holds the driver (once the newest frame is three intervals old it polls anyway, so the longest gap is about three intervals plus one screenshot; never during a click, typing or a capture), and keeps each per-action capture's screenshot as a frame, so its polls do not queue in front of the test. Only applies when `mode: 'trace'`. | | `traceResources` | `'styles'` \| `'none'` | `'styles'` | Trace mode only. `'styles'` stores the stylesheets and fonts each archive's pages used, once each by content hash, so the player replays them from the archive rather than the site; `'none'` stores nothing. Images always replay from the site. See the [root README](../../README.md#options) for caps and how the reads are kept out of the network rows. | | `screenshot` | `'off' \| 'on' \| 'only-on-failure'` | `'off'` | Per-test screenshot. Trace mode + `traceGranularity: 'test'` only. **Produce-only** — the PNG is written to the trace output dir (and listed in the artifacts manifest when `emitArtifactsManifest: true`); it is NOT attached inline to Allure (see note below). | | `video` | `'off' \| TraceRetentionPolicy` | `'off'` | Per-test video slice, retained per the given policy (e.g. `'retain-on-failure'`). Trace mode + `traceGranularity: 'test'` only. Setting a non-`off` policy starts the screencast recorder itself — you do **not** also need `filmstrip` or `screencast.enabled`; the recorder runs continuously for the session and each test's slice is cut from it by wall-clock window. **Produce-only** — the `.webm` is written to the trace output dir (and listed in the manifest when `emitArtifactsManifest: true`); NOT attached inline to Allure. | diff --git a/packages/selenium-devtools/README.md b/packages/selenium-devtools/README.md index b9825064..c38b62f8 100644 --- a/packages/selenium-devtools/README.md +++ b/packages/selenium-devtools/README.md @@ -334,7 +334,7 @@ DevTools.configure({ mode: 'trace', traceGranularity: 'test' }) **Default:** `'zip'` (a single `trace-<sessionId>.zip`). `'ndjson-directory'` writes the same `trace.trace` + `trace.network` + `resources/` layout unpacked into `trace-<id>/`, skipping the unzip step for scripted / agentic consumers. Trace mode only. #### `filmstrip` — dense scrubbable screencast in the trace -**Default:** `true`. Records a continuous screencast (CDP push on Chrome, screenshot polling elsewhere) into the trace so the player timeline scrubs frame-by-frame. On by default in trace mode; set `false` to record just one frame per action. Trace mode only. The polling recorder skips a tick while a command or a per-action capture holds the driver (up to three intervals without a frame, never during a click or typing), and keeps each per-action capture's screenshot as a frame, so its polls do not queue in front of the test. +**Default:** `true`. Records a continuous screencast (CDP push on Chrome, screenshot polling elsewhere) into the trace so the player timeline scrubs frame-by-frame. On by default in trace mode; set `false` to record just one frame per action. Trace mode only. The polling recorder skips a tick while a command or a per-action capture holds the driver (once the newest frame is three intervals old it polls anyway, so the longest gap is about three intervals plus one screenshot; never during a click, typing or a capture), and keeps each per-action capture's screenshot as a frame, so its polls do not queue in front of the test. ```javascript DevTools.configure({ mode: 'trace', filmstrip: true }) @@ -405,7 +405,7 @@ The trace uses a portable NDJSON schema, so the same `.zip` (or `ndjson-director | `traceFormat` | `'zip' \| 'ndjson-directory'` | `'zip'` | Trace artifact layout — a single `.zip` or an unpacked directory. Trace mode only. | | `traceGranularity` | `'session' \| 'spec' \| 'test'` | `'session'` | One trace per session / spec file / test. `'test'` writes each to `test-results/<spec>-<title>-<browser>[-retryN]/trace.zip`. Trace mode only. | | `tracePolicy` | `'on' \| 'retain-on-failure' \| 'retain-on-first-failure' \| 'on-first-retry' \| 'on-all-retries' \| 'retain-on-failure-and-retries'` | `'on'` | Which traces to keep. Pairs with `traceGranularity: 'test'`. Trace mode only. | -| `filmstrip` | `boolean` | `true` | Record a dense, continuous screencast into the trace for frame-by-frame scrubbing in the player. Trace mode only. The polling recorder skips a tick while a command or a per-action capture holds the driver (up to three intervals without a frame, never during a click or typing), and keeps each per-action capture's screenshot as a frame, so its polls do not queue in front of the test. | +| `filmstrip` | `boolean` | `true` | Record a dense, continuous screencast into the trace for frame-by-frame scrubbing in the player. Trace mode only. The polling recorder skips a tick while a command or a per-action capture holds the driver (once the newest frame is three intervals old it polls anyway, so the longest gap is about three intervals plus one screenshot; never during a click, typing or a capture), and keeps each per-action capture's screenshot as a frame, so its polls do not queue in front of the test. | | `traceResources` | `'styles'` \| `'none'` | `'styles'` | Trace mode only. `'styles'` stores the stylesheets and fonts each archive's pages used, once each by content hash, so the player replays them from the archive rather than the site; `'none'` stores nothing. Images always replay from the site. See the [root README](../../README.md#options) for caps and how the reads are kept out of the network rows. | | `screenshot` | `'off' \| 'on' \| 'only-on-failure'` | `'off'` | Trace mode + `traceGranularity: 'test'`. Per-test screenshot, attached inline to Allure (`image/png`) via `allure-js-commons` when an Allure runner adapter is active. | | `video` | `'off' \| TraceRetentionPolicy` | `'off'` | Trace mode + `traceGranularity: 'test'`. Per-test screencast video, retained per the given policy, attached inline to Allure (`video/webm`) via `allure-js-commons` when an Allure runner adapter is active. | diff --git a/packages/service/README.md b/packages/service/README.md index b25d5db3..acd6e333 100644 --- a/packages/service/README.md +++ b/packages/service/README.md @@ -273,7 +273,7 @@ services: [['devtools', options]] | `tracePolicy` | `TraceRetentionPolicy` | `'on'` | Trace mode only. Which traces to keep: `'on'` \| `'retain-on-failure'` \| `'retain-on-first-failure'` \| `'on-first-retry'` \| `'on-all-retries'` \| `'retain-on-failure-and-retries'`. The retry-aware policies pair best with `traceGranularity: 'test'`. | | `screenshot` | `'off' \| 'on' \| 'only-on-failure'` | `'off'` | Trace mode + `traceGranularity: 'test'`. Per-test screenshot, attached inline to Allure (`image/png`). WDIO-service-specific. | | `video` | `'off' \| TraceRetentionPolicy` | `'off'` | Trace mode + `traceGranularity: 'test'`. Per-test screencast video, retained per the given policy, attached inline to Allure (`video/webm`). WDIO-service-specific. | -| `filmstrip` | `boolean` | `true` | Trace mode only. Records a dense, continuous screencast filmstrip *into* the trace so the player scrubs smooth playback: dense frames are added alongside the per-action frames (not one frame per action). Frames are thinned (≥100 ms apart, ~600 max) and content-addressed (identical frames, such as a static wait, collapse to one resource); windowed per slice at any `traceGranularity`. Runs the screencast recorder (CDP push on Chrome, polling elsewhere). The polling recorder skips a tick while a command or a per-action capture holds the driver (up to three intervals without a frame, never during a click or typing), and keeps each per-action capture's screenshot as a frame, so its polls do not queue in front of the test. | +| `filmstrip` | `boolean` | `true` | Trace mode only. Records a dense, continuous screencast filmstrip *into* the trace so the player scrubs smooth playback: dense frames are added alongside the per-action frames (not one frame per action). Frames are thinned (≥100 ms apart, ~600 max) and content-addressed (identical frames, such as a static wait, collapse to one resource); windowed per slice at any `traceGranularity`. Runs the screencast recorder (CDP push on Chrome, polling elsewhere). The polling recorder skips a tick while a command or a per-action capture holds the driver (once the newest frame is three intervals old it polls anyway, so the longest gap is about three intervals plus one screenshot; never during a click, typing or a capture), and keeps each per-action capture's screenshot as a frame, so its polls do not queue in front of the test. | | `traceResources` | `'styles'` \| `'none'` | `'styles'` | Trace mode only. `'styles'` stores the stylesheets and fonts each archive's pages used, once each by content hash, so the player replays them from the archive rather than the site; `'none'` stores nothing. Images always replay from the site. See the [root README](../../README.md#options) for caps and how the reads are kept out of the network rows. | | `emitArtifactsManifest` | `boolean` | `false` | Trace mode only. Writes `devtools-artifacts-<sessionId>.json` next to the trace — a generic index of every produced artifact (trace/screenshot/video) plus each test's state, for reporters/CI to consume. Off by default; **auto-enabled when `@wdio/allure-reporter` is in the config**. | | `captureAssertions` | `boolean` | `true` | Capture assertions as command/action rows — `node:assert` plus passing *and* failing expect-webdriverio matchers, folded into single `expect.<matcher>` rows (e.g. `toHaveText`, `toExist`). Set `false` to opt out. |