Skip to content

Commit c5b36b2

Browse files
vishnuv688claude
andcommitted
Store each action screenshot once in the trace zip
Every per-action frame-snapshot carried its screenshot again as base64, beside the same image under resources/. On the WDIO cucumber example that was 529 KB of trace.trace's 538 KB. The snapshot's <img> now points at a URL that a resource override maps to the stored file, which takes that zip from 1.63 MB to 1.10 MB. The frame-snapshots exist for other viewers of the format. Playwright's viewer applies an override only to a resource recorded before the snapshot, so each image type gets one synthetic resource-snapshot at _monotonicTime -1. The URL is on a .invalid host, so a viewer without override support fetches nothing. Our own player replays from the mutation stream and never read these events. Closes #495 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 0fd97cf commit c5b36b2

8 files changed

Lines changed: 302 additions & 19 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@wdio/devtools-backend': patch
3+
'@wdio/devtools-service': patch
4+
'@wdio/selenium-devtools': patch
5+
'@wdio/nightwatch-devtools': patch
6+
---
7+
8+
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.

‎ARCHITECTURE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ Contains:
6464
- `writeTraceZip` / `buildTraceZip` — the zip writer and its resource model (`TraceZipResource`).
6565
- `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.
6666
- `buildGroupPath` — the suite/test/step hierarchy a row is nested under.
67-
- `buildImageFrameSnapshots` / `upsertRichestSnapshot` / `buildDenseScreencast` / `thinScreencastFrames` — filmstrip and per-action frame snapshots.
67+
- `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).
6868
- `buildMutationsNdjson` / `reattributeDomAnchors` — the DOM mutation stream and its anchor repositioning.
6969
- `buildConsoleEvents`, `networkRequestToHar`, `buildSourceResources` / `sourceResourceName` / `callSourceToStack`, `generateTranscript`, `sha1Hex`.
7070
- `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.

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ In trace mode no DevTools UI window opens. At session end the adapter writes tra
223223
| **Python** | `pytest --devtools-trace`, or `devtools.enable(trace=True)` in a plain script |
224224

225225
The trace artifact contains:
226-
- `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.
226+
- `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/`.
227227
- `trace.network` — HAR-style network entries derived from the existing capture
228228
- `resources/page@<id>-<ts>.jpeg` — screenshot per user-facing action
229229
- `resources/page@<id>-<ts>-elements.json` — flat interactable element list extracted by the page-injected scripts in `@wdio/devtools-core/element-scripts`

‎packages/shared/src/trace-file.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export const TRACE_EVENT_TYPES = {
3030
before: 'before',
3131
after: 'after',
3232
frameSnapshot: 'frame-snapshot',
33+
/** Here only the response a frame-snapshot's screenshot override patches; captured traffic is in `.network`. */
34+
resourceSnapshot: 'resource-snapshot',
3335
screencastFrame: 'screencast-frame',
3436
contextOptions: 'context-options',
3537
console: 'console',

‎packages/trace/src/trace-exporter.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,10 @@ import {
3535
import { buildDenseScreencast } from './screencast-trace.js'
3636
import {
3737
buildImageFrameSnapshots,
38+
buildScreenshotResourceSnapshots,
3839
FrameSnapshotIndex,
39-
type FrameSnapshotEvent
40+
type FrameSnapshotEvent,
41+
type ScreenshotResourceEvent
4042
} from './trace-frame-snapshots.js'
4143
import { buildActionEvents, type ActionEvent } from './trace-action-events.js'
4244
import { buildSourceResources } from './trace-sources.js'
@@ -138,6 +140,7 @@ type TraceEvent =
138140
| ConsoleEvent
139141
| StdioEvent
140142
| FrameSnapshotEvent
143+
| ScreenshotResourceEvent
141144

142145
function shortId(sessionId?: string): string {
143146
return (sessionId ?? Math.random().toString(36).slice(2, 10)).slice(0, 8)
@@ -255,6 +258,8 @@ function eventTime(e: TraceEvent): number {
255258
switch (e.type) {
256259
case 'context-options':
257260
return -Infinity
261+
case 'resource-snapshot':
262+
return e.snapshot._monotonicTime
258263
case 'before':
259264
return e.startTime
260265
case 'after':
@@ -279,6 +284,7 @@ function eventTime(e: TraceEvent): number {
279284
function eventOrder(e: TraceEvent): number {
280285
switch (e.type) {
281286
case 'context-options':
287+
case 'resource-snapshot':
282288
return 0
283289
case 'after':
284290
return 1
@@ -340,6 +346,7 @@ function buildEventStream(
340346
wallTime,
341347
viewport
342348
),
349+
...buildScreenshotResourceSnapshots(snapshotIndex.refs(), pageId),
343350
...buildConsoleEvents(trace.consoleLogs, pageId, wallTime)
344351
]
345352
events.sort(compareEvents)

‎packages/trace/src/trace-frame-snapshots.ts‎

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1-
// Image-backed frame-snapshot synthesis: each action screenshot becomes a
2-
// minimal DOM document so standard trace viewers render the action pane.
3-
// Compatibility shim until real DOM snapshots are captured.
1+
// Image-backed frame-snapshots, a shim until real DOM snapshots are captured: each action is a page
2+
// whose <img> resolves, through `resourceOverrides`, to the screenshot already stored under `resources/`.
43

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

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

48+
/** The response a frame-snapshot's screenshot override patches; the viewer applies an override only to a recorded resource. */
49+
export interface ScreenshotResourceEvent extends ResourceSnapshotEntry {
50+
snapshot: ResourceSnapshotEntry['snapshot'] & {
51+
_monotonicTime: number
52+
pageref: string
53+
_frameref: string
54+
}
55+
}
56+
4357
export interface FrameSnapshotRef {
4458
callId: string
4559
snapshotName: string
@@ -133,8 +147,13 @@ function frameIdForPage(pageId: string): string {
133147
return `frame@${suffix}`
134148
}
135149

150+
/** One URL per image type, so the shared response's content type is right for every override of it. */
151+
export function screenshotUrl(screenshot: string): string {
152+
const extension = imageMime(screenshot).slice('image/'.length)
153+
return `${SCREENSHOT_URL_ORIGIN}/action.${extension}`
154+
}
155+
136156
function imageDocument(snap: ActionSnapshot): FrameSnapshotNode {
137-
const screenshot = snap.screenshot ?? ''
138157
return [
139158
'HTML',
140159
{},
@@ -145,7 +164,7 @@ function imageDocument(snap: ActionSnapshot): FrameSnapshotNode {
145164
[
146165
'IMG',
147166
{
148-
src: `data:${imageMime(screenshot)};base64,${screenshot}`,
167+
src: screenshotUrl(snap.screenshot ?? ''),
149168
style: IMAGE_STYLE
150169
}
151170
]
@@ -175,8 +194,55 @@ export function buildImageFrameSnapshots(
175194
timestamp: Math.max(0, ref.snapshot.timestamp - wallTime),
176195
wallTime: ref.snapshot.timestamp,
177196
collectionTime: 0,
178-
resourceOverrides: [],
197+
resourceOverrides: [
198+
{
199+
url: screenshotUrl(ref.snapshot.screenshot ?? ''),
200+
sha1: screenshotResourceName(pageId, ref.snapshot.timestamp)
201+
}
202+
],
179203
isMainFrame: true
180204
}
181205
}))
182206
}
207+
208+
/** One resource-snapshot per screenshot URL the frame-snapshots reference. */
209+
export function buildScreenshotResourceSnapshots(
210+
refs: FrameSnapshotRef[],
211+
pageId: string
212+
): ScreenshotResourceEvent[] {
213+
const byUrl = new Map<string, ScreenshotResourceEvent>()
214+
for (const { snapshot } of refs) {
215+
const screenshot = snapshot.screenshot ?? ''
216+
const url = screenshotUrl(screenshot)
217+
if (byUrl.has(url)) {
218+
continue
219+
}
220+
const mimeType = imageMime(screenshot)
221+
const har = networkRequestToHar({
222+
id: url,
223+
url,
224+
method: 'GET',
225+
status: 200,
226+
statusText: 'OK',
227+
timestamp: snapshot.timestamp,
228+
startTime: snapshot.timestamp,
229+
time: 0,
230+
type: 'image',
231+
response: { fromCache: false, headers: {}, mimeType, status: 200 }
232+
})
233+
har.snapshot.response.content._sha1 = screenshotResourceName(
234+
pageId,
235+
snapshot.timestamp
236+
)
237+
byUrl.set(url, {
238+
type: 'resource-snapshot',
239+
snapshot: {
240+
...har.snapshot,
241+
_monotonicTime: SCREENSHOT_RESOURCE_TIME,
242+
pageref: pageId,
243+
_frameref: frameIdForPage(pageId)
244+
}
245+
})
246+
}
247+
return [...byUrl.values()]
248+
}

‎packages/trace/src/trace-snapshots.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@ export function upsertRichestSnapshot(
7070
mergeSnapshotInto(snapshots[idx]!, snap)
7171
}
7272

73+
/** Name under `resources/` of the screenshot captured at `timestamp`. */
74+
export function screenshotResourceName(
75+
pageId: string,
76+
timestamp: number
77+
): string {
78+
return `${pageId}-${timestamp}.jpeg`
79+
}
80+
7381
export function buildSnapshotResources(
7482
rawSnapshots: ActionSnapshot[],
7583
pageId: string
@@ -80,7 +88,7 @@ export function buildSnapshotResources(
8088
const base = `${pageId}-${snap.timestamp}`
8189
if (snap.screenshot) {
8290
out.push({
83-
resourceName: `${base}.jpeg`,
91+
resourceName: screenshotResourceName(pageId, snap.timestamp),
8492
data: Buffer.from(snap.screenshot, 'base64')
8593
})
8694
}
@@ -110,7 +118,7 @@ function frameForSnapshot(
110118
const frame: ScreencastFrameEvent = {
111119
type: 'screencast-frame',
112120
pageId,
113-
sha1: `${base}.jpeg`,
121+
sha1: screenshotResourceName(pageId, snap.timestamp),
114122
width: viewport.width,
115123
height: viewport.height,
116124
timestamp

0 commit comments

Comments
 (0)