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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/one-capture-per-action.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@wdio/devtools-backend": patch
"@wdio/devtools-core": patch
"@wdio/devtools-service": patch
---

Take one DOM capture per action again. Trace mode had grown a second, eager post-action capture beside the pre-action one, with a `readyState` poll and a 250 ms pause on top to hide the fact that the eager one lands while the screen is still moving — so every action paid two captures, and on a native Appium session each capture is two serial round trips. Measured on the native example spec: 15 screenshots and 15 page-source reads against 8 and 8, and a 14.0–14.7 s test against 11.4 s, with the captured frames equivalent.

The pre-action capture is the one that was right: taken before the command is issued, it is the moment the driver is guaranteed idle, so an action's result is the next action's "before". Only the last action has no successor to hand its result to, so a settle survives in exactly that one place, and it is gated rather than timed — no navigation, no wait. The eager capture, the poll that patched it and the document tag it was built on are deleted. Two related fixes ride along: a row with no capture of its own now replays the latest state at or before it rather than the nearest in absolute distance, which could hand it its successor's; and the screencast poll keeps at most one screenshot outstanding, so it cannot queue ahead of the test's own commands on a serialised driver.
10 changes: 10 additions & 0 deletions .changeset/serialise-screencast-start-and-stop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@wdio/devtools-core": patch
"@wdio/devtools-service": patch
"@wdio/selenium-devtools": patch
"@wdio/nightwatch-devtools": patch
---

Stop a screencast session outliving the recording it was armed for. A `stop()` arriving while the CDP handshake was still in flight returned early — the recording flag it checks is only set once the handshake finishes — so the session and its frame listener stayed live after teardown and kept pushing frames into the buffer the next recording reuses. `start()` and `stop()` are now serialised, so a stop always runs against a start that has finished arming and tears down what that start armed.

The visible consequence of the fix: `stop()` now waits for an in-flight handshake rather than returning immediately. Only Selenium caps its own; the service's CDP handshake and the polling path's first screenshot do not, so a driver that wedges in one of those now wedges `stop()` as well.
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ The DOM-walking scripts run in the page via `browser.execute`, so — like `scri

Per-framework demo projects used for manual verification.

- `examples/wdio/` — WebdriverIO, split into `cucumber/` and `mocha/` (shared page objects in `pageobjects/`). Run via `pnpm demo:wdio` (Cucumber) or `pnpm demo:wdio:mocha`.
- `examples/wdio/` — WebdriverIO, split into `cucumber/` and `mocha/` (shared page objects in `pageobjects/`). Run via `pnpm demo:wdio` (Cucumber), `pnpm demo:wdio:mocha`, or `pnpm demo:wdio:native` (Appium native app — needs a running Appium server and a device, see the README's Mobile testing section).
- `examples/nightwatch/` — Nightwatch (both vanilla and Cucumber). Run via `pnpm demo:nightwatch`.
- `examples/selenium/` — Selenium with subdirs for `mocha-test/`, `jest-test/`, `cucumber-test/`, `jasmine-test/`, `vitest-test/`. `pnpm demo:selenium` runs mocha; `pnpm --filter @wdio/selenium-devtools example:<runner>` runs the others.

Expand Down
16 changes: 14 additions & 2 deletions CLAUDE.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ services: [[DevToolsHookService, {

Adapters detect mobile sessions via `platformName: 'android' | 'ios'` (case-insensitive) and adjust the per-action snapshot to extract elements from the mobile XML tree instead of the DOM. The trace's `context-options` records `title: 'android' — <deviceName>` / `'ios' — <deviceName>` so the viewer labels frames correctly.

A reference WDIO config is at [examples/wdio/cucumber/wdio.mobile.conf.ts](examples/wdio/cucumber/wdio.mobile.conf.ts). Prereqs to run it end-to-end with a local emulator:
A reference WDIO config is at [examples/wdio/cucumber/wdio.mobile.conf.ts](examples/wdio/cucumber/wdio.mobile.conf.ts) — that one drives Chrome *on* the device. For a native app (no document at all, so the snapshot reads the page-source XML) there is [examples/wdio/mocha/wdio.native.conf.ts](examples/wdio/mocha/wdio.native.conf.ts), run via `pnpm demo:wdio:native`: it needs no APK, launches a preinstalled app, and reads its Appium endpoint from `APPIUM_HOST` / `APPIUM_PORT` / `APPIUM_DEVICE` (plus `APPIUM_APP` to install a bundle instead). No Chromedriver is involved. Prereqs to run either end-to-end with a local emulator:

1. **Java JDK** — `brew install --cask temurin`
2. **Android SDK** — `brew install --cask android-commandlinetools` then `yes | sdkmanager --licenses && sdkmanager "platform-tools" "emulator" "system-images;android-34;google_apis_playstore;arm64-v8a"`. The brew cask installs sdkmanager under `/opt/homebrew/share/android-commandlinetools/`, and sdkmanager downloads other SDK pieces alongside it — set `ANDROID_HOME` to that path (not `~/Library/Android/sdk/`).
Expand Down
44 changes: 44 additions & 0 deletions examples/wdio/mocha/native/clock.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// A native Android spec: no document, no URL, no DOM — the capture path a
// browser session never exercises. Drives Clock, which ships with every Android
// system image, so the example needs no APK and no app upload.
//
// Every selector below was read off an emulator (API 37, Clock from
// com.google.android.deskclock); resource-ids are used over text because the
// countdown text changes every second.
import { expect } from '@wdio/globals'

const APP_ID = 'com.google.android.deskclock'

const byId = (id: string) =>
$(
`android=new UiSelector().resourceId("com.google.android.deskclock:id/${id}")`
)

describe('Clock (native)', () => {
it('starts a preset timer, pauses it, and clears it', async () => {
console.log('[TEST] launching the Clock app')
// `mobile: activateApp` rather than an `appium:app`/`appActivity`
// capability: the activity name is build-specific and this needs no
// adb_shell, which Appium does not enable by default.
await browser.execute('mobile: activateApp', { appId: APP_ID })

console.log('[TEST] opening the Timers tab')
await byId('tab_menu_timer').click()

console.log('[TEST] starting the 5 minute preset')
// This build starts the timer straight from the preset — verified on the
// device — so the running countdown is the evidence the tap landed.
await byId('timer_preset_2').click()
await expect(byId('timer_text')).toHaveText(/^\d{2}:\d{2}$/)

console.log('[TEST] pausing the timer')
await byId('play_pause_button').click()
// The button's accessibility label flips with the timer's state; asserting
// on it keeps this step off the countdown's own clock.
await expect($('~Start 5 minutes timer')).toBeDisplayed()

console.log('[TEST] clearing the timer')
await byId('delete_button').click()
await expect(byId('timer_text')).not.toBeDisplayed()
})
})
79 changes: 79 additions & 0 deletions examples/wdio/mocha/wdio.native.conf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Native-app variant of wdio.trace.conf.ts: drives a preinstalled Android app
// over Appium, so a trace can be produced from a session that has NO document —
// the path where the per-action snapshot reads page-source XML instead of
// running page scripts. The mobile-WEB variant lives in
// cucumber/wdio.mobile.conf.ts; that one drives Chrome on the device and takes
// the web capture path, so the two are not interchangeable.
//
// Prerequisites: an Appium server with the UiAutomator2 driver
// (`appium driver install uiautomator2`) and an emulator or device attached.
// The endpoint and device come from the environment, so a remote host works:
//
// APPIUM_HOST=100.69.254.5 APPIUM_PORT=4723 pnpm native
//
// No APK is needed — the spec launches a preinstalled app itself. Set
// APPIUM_APP to a bundle path or URL to install one instead.
export const config: WebdriverIO.Config = {
runner: 'local',

// Native specs live in their own folder so the web configs' `./specs/**`
// glob can't pick them up — they drive Appium, not a browser.
specs: ['./native/**/*.e2e.ts'],
exclude: [],

hostname: process.env.APPIUM_HOST ?? '127.0.0.1',
port: Number(process.env.APPIUM_PORT ?? 4723),
path: '/',

maxInstances: 1,
capabilities: [
{
platformName: 'Android',
'appium:automationName': 'UiAutomator2',
'appium:deviceName': process.env.APPIUM_DEVICE ?? 'emulator-5554',
// Keep whatever the app already has on the device — this example drives
// an app it did not install.
'appium:noReset': true,
...(process.env.APPIUM_APP
? { 'appium:app': process.env.APPIUM_APP }
: {}),
// Appium's BiDi shim for UiAutomator2 doesn't implement every BiDi
// command (e.g. script.addPreloadScript), so keep WDIO on classic.
'wdio:enforceWebDriverClassic': true
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any,

logLevel: 'warn',
bail: 0,
waitforTimeout: 15000,
connectionRetryTimeout: 120000,
connectionRetryCount: 3,
services: [
[
'devtools',
{
// Trace by default; DEVTOOLS_MODE=live gives the same spec a baseline to
// measure the capture cost against.
mode: (process.env.DEVTOOLS_MODE === 'live' ? 'live' : 'trace') as
'live' | 'trace',
traceGranularity: (process.env.DEVTOOLS_TRACE_GRANULARITY ??
'session') as 'session' | 'spec' | 'test',
tracePolicy: (process.env.DEVTOOLS_TRACE_POLICY ?? 'on') as
'on' | 'retain-on-failure' | 'retain-on-first-failure',
// Off by default because a native session has no CDP: the recorder
// falls back to polling `takeScreenshot` on an interval, which against a
// phone is a second, competing source of driver round trips. Set
// DEVTOOLS_FILMSTRIP=on to record one anyway.
filmstrip: process.env.DEVTOOLS_FILMSTRIP === 'on',
emitArtifactsManifest: true
}
]
],
framework: 'mocha',
reporters: ['spec'],
mochaOpts: {
ui: 'bdd',
timeout: 120000
}
}
4 changes: 4 additions & 0 deletions examples/wdio/mocha/wdio.trace.conf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ export const config: WebdriverIO.Config = {
| 'on-first-retry'
| 'on-all-retries'
| 'retain-on-failure-and-retries',
// Dense screencast frames written into the trace; on by default, and the
// heaviest part of a session's teardown. DEVTOOLS_FILMSTRIP=off measures
// the trace without them.
filmstrip: process.env.DEVTOOLS_FILMSTRIP !== 'off',
// Always emit the manifest so the artifact set is inspectable per run.
emitArtifactsManifest: true
}
Expand Down
1 change: 1 addition & 0 deletions examples/wdio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"cucumber": "wdio run ./cucumber/wdio.conf.ts",
"mocha": "wdio run ./mocha/wdio.conf.ts",
"mobile": "wdio run ./cucumber/wdio.mobile.conf.ts",
"native": "wdio run ./mocha/wdio.native.conf.ts",
"trace": "wdio run ./cucumber/wdio.trace.conf.ts",
"retention": "wdio run ./cucumber/wdio.retention.conf.ts"
}
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"demo:wdio": "wdio run ./examples/wdio/cucumber/wdio.conf.ts",
"demo:wdio:mocha": "wdio run ./examples/wdio/mocha/wdio.conf.ts",
"demo:wdio:retry": "wdio run ./examples/wdio/mocha/wdio.retry.conf.ts",
"demo:wdio:native": "wdio run ./examples/wdio/mocha/wdio.native.conf.ts",
"demo:nightwatch": "pnpm --filter @wdio/nightwatch-devtools example",
"demo:nightwatch:retry": "pnpm --filter @wdio/nightwatch-devtools example:retry",
"demo:selenium": "pnpm --filter @wdio/selenium-devtools example",
Expand Down
21 changes: 14 additions & 7 deletions packages/backend/src/trace-reader-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,20 +290,27 @@ export function buildSources(
return sources
}

/** The frame that shows a command's state: the latest capture at or before it,
* since a capture is stamped when the document was read. Falling back to the
* next later frame only when nothing precedes keeps a row without a capture of
* its own from replaying the state its SUCCESSOR produced, which is what an
* absolute-nearest rule does when the successor's frame is the closer one. */
export function nearestFrame(
frames: TracePlayerFrame[],
timestamp: number
): TracePlayerFrame | undefined {
let best: TracePlayerFrame | undefined
let bestDelta = Infinity
let preceding: TracePlayerFrame | undefined
let following: TracePlayerFrame | undefined
for (const frame of frames) {
const delta = Math.abs(frame.timestamp - timestamp)
if (delta < bestDelta) {
bestDelta = delta
best = frame
if (frame.timestamp <= timestamp) {
if (!preceding || frame.timestamp > preceding.timestamp) {
preceding = frame
}
} else if (!following || frame.timestamp < following.timestamp) {
following = frame
}
}
return best
return preceding ?? following
}

export function buildMetadata(ctx: ContextOptionsEvent | undefined): Metadata {
Expand Down
29 changes: 28 additions & 1 deletion packages/backend/tests/trace-reader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import type {
TraceActionGroupNode
} from '@wdio/devtools-shared'
import { parseTraceZip } from '../src/trace-reader.js'
import { buildSources, stackToCallSource } from '../src/trace-reader-utils.js'
import {
buildSources,
nearestFrame,
stackToCallSource
} from '../src/trace-reader-utils.js'
import type { BeforeEvent } from '../src/trace-reader-types.js'

function allGroups(children: TraceActionChild[]): TraceActionGroupNode[] {
Expand Down Expand Up @@ -858,3 +862,26 @@ describe('glued callSource recovery from older zips', () => {
expect(sources).toEqual({ [clean]: 'glued source' })
})
})

describe('nearestFrame', () => {
const frame = (timestamp: number) => ({ timestamp, screenshot: 'x' })

it('shows a row the state it observed, not the one its successor produced', () => {
// One capture per action means a row without one of its own (an assert row,
// an internal command) sits BETWEEN two captures. The later one is the
// successor's result, so the earlier is the state this row actually saw.
const frames = [frame(100), frame(300)]
expect(nearestFrame(frames, 220)).toEqual(frame(100))
expect(nearestFrame(frames, 260)).toEqual(frame(100))
})

it('takes the next frame when nothing precedes the row', () => {
expect(nearestFrame([frame(300)], 220)).toEqual(frame(300))
})

it('prefers the frame at the row own timestamp', () => {
const frames = [frame(100), frame(300)]
expect(nearestFrame(frames, 300)).toEqual(frame(300))
expect(nearestFrame(frames, 100)).toEqual(frame(100))
})
})
20 changes: 14 additions & 6 deletions packages/core/src/allure-artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,21 @@ export async function attachTraceArtifact(
}
}

/** A snapshot's command for a session that ran no action at all, so the frame
* carries no result to show and may be a blank post-teardown page. Written by
* the service's per-scenario finalize, skipped by `lastRenderedScreenshot`.
* Shared rather than repeated: a rename on one side would silently stop the
* skip from matching and start attaching those frames as test screenshots. */
export const FINAL_SNAPSHOT_COMMAND = '__final__'

/**
* The base64 of the last rendered action snapshot for the current test, skipping
* the end-of-scenario `__final__` frame (captured post-teardown, often blank when
* a reloadSession runs before the after-hook). Scoped to `>= startWallTime` so a
* test that captured nothing doesn't borrow the previous test's frame. Reused as
* the per-test screenshot — reload-immune and one fewer WebDriver command than a
* fresh end-of-test capture.
* a `FINAL_SNAPSHOT_COMMAND` frame — which a session that ran no action at all
* produces, so it carries no result to show and may be a blank post-teardown
* page. Scoped to `>= startWallTime` so a test that captured nothing doesn't
* borrow the previous test's frame. Reused as the per-test screenshot —
* reload-immune and one fewer WebDriver command than a fresh end-of-test
* capture.
*/
export function lastRenderedScreenshot(
snapshots: readonly ActionSnapshot[],
Expand All @@ -98,7 +106,7 @@ export function lastRenderedScreenshot(
if (snap.timestamp < startWallTime) {
return undefined
}
if (snap.command !== '__final__' && snap.screenshot) {
if (snap.command !== FINAL_SNAPSHOT_COMMAND && snap.screenshot) {
return snap.screenshot
}
}
Expand Down
Loading
Loading