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
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
15 changes: 13 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
42 changes: 42 additions & 0 deletions examples/wdio/mocha/native/clock.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// 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()
})
})
80 changes: 80 additions & 0 deletions examples/wdio/mocha/wdio.native.conf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// 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
48 changes: 44 additions & 4 deletions packages/core/src/screencast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export abstract class ScreencastRecorderBase<TDriver = unknown> {
protected options: Required<ScreencastOptions>
protected driver?: TDriver
#pollTimer: ReturnType<typeof setInterval> | undefined
#pollInFlight = false
/** Bumped by start and stop alike. A shot that outlives its loop compares
* against this: its frame belongs to the old recording, and the latch it
* holds is not the successor's to clear. */
#pollGeneration = 0
#isRecording = false
#cdpActive = false
#startIndex = 0
Expand All @@ -41,21 +46,33 @@ export abstract class ScreencastRecorderBase<TDriver = unknown> {
if (this.#isRecording) {
return
}
// Claimed before the first await, because a stop() arriving during any of
// them has nothing else to invalidate: nothing is armed, and #isRecording is
// still false, so without this the loop would be armed after the caller
// stopped it. A native session's first screenshot runs ~1.2 s.
const generation = ++this.#pollGeneration
this.driver = driver
const cdpOk = await this.tryStartCdp()
if (generation !== this.#pollGeneration) {
return
}
if (cdpOk) {
this.#cdpActive = true
this.#isRecording = true
return
}
await this.#startPolling()
await this.#startPolling(generation)
}

/**
* Stop recording and release resources. Safe to call even if start() was
* never called or failed.
*/
async stop(): Promise<void> {
// Bumped before the early return: a stop() that lands while start() is still
// awaiting its first screenshot finds nothing armed and #isRecording still
// false, so returning above would let start() arm the loop afterwards.
this.#pollGeneration++
if (!this.#isRecording) {
return
}
Expand Down Expand Up @@ -209,9 +226,12 @@ export abstract class ScreencastRecorderBase<TDriver = unknown> {

// ─── Polling implementation ─────────────────────────────────────────────

async #startPolling(): Promise<void> {
async #startPolling(generation: number): Promise<void> {
try {
const first = await this.takeScreenshot()
if (generation !== this.#pollGeneration) {
return
}
if (first === null) {
this.onUnavailable(new Error('first screenshot returned null'))
return
Expand All @@ -227,14 +247,28 @@ export abstract class ScreencastRecorderBase<TDriver = unknown> {
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 (data !== null) {
if (data !== null && generation === this.#pollGeneration) {
this.#appendFrame({ data, timestamp: Date.now() })
}
} catch {
// Session ended mid-interval — stop polling gracefully.
this.#stopPolling()
if (generation === this.#pollGeneration) {
this.#stopPolling()
}
} finally {
if (generation === this.#pollGeneration) {
this.#pollInFlight = false
}
}
}, intervalMs)

Expand All @@ -249,6 +283,12 @@ export abstract class ScreencastRecorderBase<TDriver = unknown> {
if (this.#pollTimer !== undefined) {
clearInterval(this.#pollTimer)
this.#pollTimer = undefined
// A shot issued just before the stop outlives it. Bumping the generation
// keeps that orphan from appending into a later recording or clearing the
// successor's latch, and clearing the latch here lets a restart tick
// without waiting on it.
this.#pollGeneration++
this.#pollInFlight = false
this.onPollingStopped(this.buffer.length)
}
}
Expand Down
8 changes: 8 additions & 0 deletions packages/core/tests/allure-artifacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,14 @@ describe('lastRenderedScreenshot', () => {
expect(lastRenderedScreenshot(snaps, 100)).toBe('BB')
})

it('returns a last-action frame that carries the action name', () => {
// The service captures the FINAL action's result in its own finalize, named
// after that action — the marker is only for a session with no action at
// all, so the screenshot a failing test is judged on is the failure's.
const snaps = [snap('setValue', 200, 'BB'), snap('click', 300, 'CC')]
expect(lastRenderedScreenshot(snaps, 100)).toBe('CC')
})

it('returns undefined when the only snapshots predate the test start', () => {
expect(
lastRenderedScreenshot([snap('click', 50, 'AA')], 100)
Expand Down
Loading
Loading