Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .changeset/frame-snapshot-screenshot-once.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@wdio/devtools-backend': patch
'@wdio/devtools-service': patch
'@wdio/selenium-devtools': patch
'@wdio/nightwatch-devtools': patch
---

A trace zip stores each action screenshot once. The `frame-snapshot` event for an action used to carry its screenshot again as base64 inside the page it describes, beside the same screenshot under `resources/`; it now references that file through `resourceOverrides`, which a trace viewer resolves to the stored image. On a WebdriverIO cucumber example trace this takes the zip from 1.63 MB to 1.10 MB and `trace.trace` from 538 KB to 5 KB. The DevTools trace player reads such a zip as before.
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Contains:
- `writeTraceZip` / `buildTraceZip` — the zip writer and its resource model (`TraceZipResource`).
- `buildActionEvents`: commands → the ordered before/after event stream the player replays, including the `(startTime, sequence, index)` ordering key, with a row's children written inside its span under its callId.
- `buildGroupPath` — the suite/test/step hierarchy a row is nested under.
- `buildImageFrameSnapshots` / `upsertRichestSnapshot` / `buildDenseScreencast` / `thinScreencastFrames` — filmstrip and per-action frame snapshots.
- `buildImageFrameSnapshots` + `buildScreenshotResourceSnapshots` / `upsertRichestSnapshot` / `buildDenseScreencast` / `thinScreencastFrames` — filmstrip and per-action frame snapshots (each a page whose image is the stored screenshot, reached through a `resourceOverrides` entry and the one recorded response it patches).
- `buildMutationsNdjson` / `reattributeDomAnchors` — the DOM mutation stream and its anchor repositioning.
- `buildConsoleEvents`, `networkRequestToHar`, `buildSourceResources` / `sourceResourceName` / `callSourceToStack`, `generateTranscript`, `sha1Hex`.
- `parseTraceZip` (`trace-reader.ts` and its `trace-reader-*` siblings) — the reverse direction: a `trace.zip`, ours or a foreign one, back into the `TracePlayerData` the player renders. Imported by subpath (`@wdio/devtools-trace/trace-reader`) and kept out of the package index, so adapters that reach `trace` through `core` never inline it. It reaches no Node built-in, because the hosted player runs it in the browser (`tests/trace-reader-browser-safe.test.ts` walks its imports); the disk read is `readTraceZip` in `read-trace-zip.ts`, and source paths are hashed with the pure `portableSha1Hex` so the reader needs no `node:crypto`; resource bytes keep native `sha1Hex`, ~30x faster.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ In trace mode no DevTools UI window opens. At session end the adapter writes tra
| **Python** | `pytest --devtools-trace`, or `devtools.enable(trace=True)` in a plain script |

The trace artifact contains:
- `trace.trace` — NDJSON `context-options` + `before`/`after` action events. When test hooks are available (Mocha's `it()` / Cucumber's `Scenario()`), each test becomes a `Tracing.tracingGroup` span — an open/close `before`/`after` pair with `method: "tracingGroup"` and `params.name` set to the test title. Child actions inside the group carry `parentId` pointing back to the group's `callId`, so timeline viewers render tests as labelled spans wrapping their commands.
- `trace.trace` — NDJSON `context-options` + `before`/`after` action events. When test hooks are available (Mocha's `it()` / Cucumber's `Scenario()`), each test becomes a `Tracing.tracingGroup` span — an open/close `before`/`after` pair with `method: "tracingGroup"` and `params.name` set to the test title. Child actions inside the group carry `parentId` pointing back to the group's `callId`, so timeline viewers render tests as labelled spans wrapping their commands. For viewers that render a DOM snapshot per action, it also carries one `frame-snapshot` event per action: a page holding just that action's screenshot. The image is not stored a second time: its `src` is a placeholder `https://screenshot.wdio-devtools.invalid/` address, which the event's `resourceOverrides` maps to the screenshot's file under `resources/`.
- `trace.network` — HAR-style network entries derived from the existing capture
- `resources/page@<id>-<ts>.jpeg` — screenshot per user-facing action
- `resources/page@<id>-<ts>-elements.json` — flat interactable element list extracted by the page-injected scripts in `@wdio/devtools-core/element-scripts`
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/trace-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export const TRACE_EVENT_TYPES = {
before: 'before',
after: 'after',
frameSnapshot: 'frame-snapshot',
/** Here only the response a frame-snapshot's screenshot override patches; captured traffic is in `.network`. */
resourceSnapshot: 'resource-snapshot',
screencastFrame: 'screencast-frame',
contextOptions: 'context-options',
console: 'console',
Expand Down
9 changes: 8 additions & 1 deletion packages/trace/src/trace-exporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@ import {
import { buildDenseScreencast } from './screencast-trace.js'
import {
buildImageFrameSnapshots,
buildScreenshotResourceSnapshots,
FrameSnapshotIndex,
type FrameSnapshotEvent
type FrameSnapshotEvent,
type ScreenshotResourceEvent
} from './trace-frame-snapshots.js'
import { buildActionEvents, type ActionEvent } from './trace-action-events.js'
import { buildSourceResources } from './trace-sources.js'
Expand Down Expand Up @@ -138,6 +140,7 @@ type TraceEvent =
| ConsoleEvent
| StdioEvent
| FrameSnapshotEvent
| ScreenshotResourceEvent

function shortId(sessionId?: string): string {
return (sessionId ?? Math.random().toString(36).slice(2, 10)).slice(0, 8)
Expand Down Expand Up @@ -255,6 +258,8 @@ function eventTime(e: TraceEvent): number {
switch (e.type) {
case 'context-options':
return -Infinity
case 'resource-snapshot':
return e.snapshot._monotonicTime
case 'before':
return e.startTime
case 'after':
Expand All @@ -279,6 +284,7 @@ function eventTime(e: TraceEvent): number {
function eventOrder(e: TraceEvent): number {
switch (e.type) {
case 'context-options':
case 'resource-snapshot':
return 0
case 'after':
return 1
Expand Down Expand Up @@ -340,6 +346,7 @@ function buildEventStream(
wallTime,
viewport
),
...buildScreenshotResourceSnapshots(snapshotIndex.refs(), pageId),
...buildConsoleEvents(trace.consoleLogs, pageId, wallTime)
]
events.sort(compareEvents)
Expand Down
78 changes: 72 additions & 6 deletions packages/trace/src/trace-frame-snapshots.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
// Image-backed frame-snapshot synthesis: each action screenshot becomes a
// minimal DOM document so standard trace viewers render the action pane.
// Compatibility shim until real DOM snapshots are captured.
// Image-backed frame-snapshots, a shim until real DOM snapshots are captured: each action is a page
// whose <img> resolves, through `resourceOverrides`, to the screenshot already stored under `resources/`.

import { imageMime, type ActionSnapshot } from '@wdio/devtools-shared'
import { networkRequestToHar, type ResourceSnapshotEntry } from './trace-har.js'
import { screenshotResourceName } from './trace-snapshots.js'

const SNAPSHOT_DOCTYPE = 'html'
// `.invalid` never resolves (RFC 6761), so a viewer that ignores the override fetches nothing.
const SCREENSHOT_URL_ORIGIN = 'https://screenshot.wdio-devtools.invalid'
// Before every snapshot, including one clamped to 0: the viewer only serves a resource loaded before the snapshot.
const SCREENSHOT_RESOURCE_TIME = -1
const FALLBACK_FRAME_URL = 'about:blank'
const BODY_STYLE = 'margin:0'
const IMAGE_STYLE = 'display:block;width:100vw;height:100vh;object-fit:contain'
Expand Down Expand Up @@ -40,6 +45,15 @@ export interface FrameSnapshotEvent {
snapshot: FrameSnapshot
}

/** The response a frame-snapshot's screenshot override patches; the viewer applies an override only to a recorded resource. */
export interface ScreenshotResourceEvent extends ResourceSnapshotEntry {
snapshot: ResourceSnapshotEntry['snapshot'] & {
_monotonicTime: number
pageref: string
_frameref: string
}
}

export interface FrameSnapshotRef {
callId: string
snapshotName: string
Expand Down Expand Up @@ -133,8 +147,13 @@ function frameIdForPage(pageId: string): string {
return `frame@${suffix}`
}

/** One URL per image type, so the shared response's content type is right for every override of it. */
export function screenshotUrl(screenshot: string): string {
const extension = imageMime(screenshot).slice('image/'.length)
return `${SCREENSHOT_URL_ORIGIN}/action.${extension}`
}

function imageDocument(snap: ActionSnapshot): FrameSnapshotNode {
const screenshot = snap.screenshot ?? ''
return [
'HTML',
{},
Expand All @@ -145,7 +164,7 @@ function imageDocument(snap: ActionSnapshot): FrameSnapshotNode {
[
'IMG',
{
src: `data:${imageMime(screenshot)};base64,${screenshot}`,
src: screenshotUrl(snap.screenshot ?? ''),
style: IMAGE_STYLE
}
]
Expand Down Expand Up @@ -175,8 +194,55 @@ export function buildImageFrameSnapshots(
timestamp: Math.max(0, ref.snapshot.timestamp - wallTime),
wallTime: ref.snapshot.timestamp,
collectionTime: 0,
resourceOverrides: [],
resourceOverrides: [
{
url: screenshotUrl(ref.snapshot.screenshot ?? ''),
sha1: screenshotResourceName(pageId, ref.snapshot.timestamp)
}
],
isMainFrame: true
}
}))
}

/** One resource-snapshot per screenshot URL the frame-snapshots reference. */
export function buildScreenshotResourceSnapshots(
refs: FrameSnapshotRef[],
pageId: string
): ScreenshotResourceEvent[] {
const byUrl = new Map<string, ScreenshotResourceEvent>()
for (const { snapshot } of refs) {
const screenshot = snapshot.screenshot ?? ''
const url = screenshotUrl(screenshot)
if (byUrl.has(url)) {
continue
}
const mimeType = imageMime(screenshot)
const har = networkRequestToHar({
id: url,
url,
method: 'GET',
status: 200,
statusText: 'OK',
timestamp: snapshot.timestamp,
startTime: snapshot.timestamp,
time: 0,
type: 'image',
response: { fromCache: false, headers: {}, mimeType, status: 200 }
})
har.snapshot.response.content._sha1 = screenshotResourceName(
pageId,
snapshot.timestamp
)
byUrl.set(url, {
type: 'resource-snapshot',
snapshot: {
...har.snapshot,
_monotonicTime: SCREENSHOT_RESOURCE_TIME,
pageref: pageId,
_frameref: frameIdForPage(pageId)
}
})
}
return [...byUrl.values()]
}
12 changes: 10 additions & 2 deletions packages/trace/src/trace-snapshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ export function upsertRichestSnapshot(
mergeSnapshotInto(snapshots[idx]!, snap)
}

/** Name under `resources/` of the screenshot captured at `timestamp`. */
export function screenshotResourceName(
pageId: string,
timestamp: number
): string {
return `${pageId}-${timestamp}.jpeg`
}

export function buildSnapshotResources(
rawSnapshots: ActionSnapshot[],
pageId: string
Expand All @@ -80,7 +88,7 @@ export function buildSnapshotResources(
const base = `${pageId}-${snap.timestamp}`
if (snap.screenshot) {
out.push({
resourceName: `${base}.jpeg`,
resourceName: screenshotResourceName(pageId, snap.timestamp),
data: Buffer.from(snap.screenshot, 'base64')
})
}
Expand Down Expand Up @@ -110,7 +118,7 @@ function frameForSnapshot(
const frame: ScreencastFrameEvent = {
type: 'screencast-frame',
pageId,
sha1: `${base}.jpeg`,
sha1: screenshotResourceName(pageId, snap.timestamp),
width: viewport.width,
height: viewport.height,
timestamp
Expand Down
Loading
Loading