From d27aa95b3df0b7149198510d8b7bf9f54cd9d38f Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Thu, 2 Jul 2026 18:53:58 +0530 Subject: [PATCH 01/21] feat(core,backend): emit console/stdout/stderr events into trace.zip and round-trip them in the reader --- packages/backend/src/trace-reader-types.ts | 17 +++ packages/backend/src/trace-reader-utils.ts | 58 +++++++- packages/backend/src/trace-reader.ts | 26 ++-- packages/backend/tests/trace-reader.test.ts | 38 +++++- packages/core/src/index.ts | 2 + packages/core/src/trace-console.ts | 93 +++++++++++++ packages/core/src/trace-exporter.ts | 141 +++++--------------- packages/core/src/trace-snapshots.ts | 102 ++++++++++++++ packages/core/tests/trace-console.test.ts | 111 +++++++++++++++ 9 files changed, 465 insertions(+), 123 deletions(-) create mode 100644 packages/core/src/trace-console.ts create mode 100644 packages/core/src/trace-snapshots.ts create mode 100644 packages/core/tests/trace-console.test.ts diff --git a/packages/backend/src/trace-reader-types.ts b/packages/backend/src/trace-reader-types.ts index 63bc3139..3a9d5e77 100644 --- a/packages/backend/src/trace-reader-types.ts +++ b/packages/backend/src/trace-reader-types.ts @@ -24,6 +24,22 @@ export interface ScreencastFrameEvent { timestamp: number } +export interface ConsoleEvent { + type: 'console' + time: number + messageType: string + text: string + args?: { preview: string; value: unknown }[] +} + +export interface StdioEvent { + type: 'stdout' | 'stderr' + timestamp: number + text?: string + /** Extension field restoring the test-vs-terminal origin; absent in foreign zips. */ + source?: 'test' | 'terminal' +} + export interface ContextOptionsEvent { type: 'context-options' wallTime: number @@ -54,4 +70,5 @@ export interface CategorizedEvents { befores: Map afters: Map frameEvents: ScreencastFrameEvent[] + consoleEvents: (ConsoleEvent | StdioEvent)[] } diff --git a/packages/backend/src/trace-reader-utils.ts b/packages/backend/src/trace-reader-utils.ts index 16a0972e..b7162c12 100644 --- a/packages/backend/src/trace-reader-utils.ts +++ b/packages/backend/src/trace-reader-utils.ts @@ -3,13 +3,20 @@ import { TraceType, + type ConsoleLog, + type LogLevel, type Metadata, type NetworkRequest, type TracePlayerFrame, type Viewport } from '@wdio/devtools-shared' -import type { ContextOptionsEvent, HarSnapshot } from './trace-reader-types.js' +import type { + ConsoleEvent, + ContextOptionsEvent, + HarSnapshot, + StdioEvent +} from './trace-reader-types.js' export function parseNdjson(text: string): Record[] { return text @@ -44,9 +51,9 @@ export function paramsToArgs( return indexKeys.map((key) => params[key]) } -// Playwright/vibium-style action label, built from class.method + the most -// meaningful param (value for fill/type, url for navigate, nothing for click) — -// matching what `playwright show-trace` renders for the same trace. +// Trace-viewer action label, built from class.method + the most meaningful +// param (value for fill/type, url for navigate, nothing for click) — +// matching what standard trace viewers render for the same trace. export function actionLabel( cls: string, method: string, @@ -109,7 +116,7 @@ export function harToNetworkRequest( ): NetworkRequest { const started = Date.parse(snapshot.startedDateTime) const startTime = Number.isFinite(started) ? started : 0 - // A foreign trace.zip (the reader accepts any Vibium/Playwright zip) can carry + // A foreign trace.zip (the reader accepts any standard-format zip) can carry // a pending or failed request with no response or content; default those. const response = snapshot.response const content = response?.content @@ -137,6 +144,47 @@ export function harToNetworkRequest( } } +const LOG_LEVELS: ReadonlySet = new Set([ + 'trace', + 'debug', + 'log', + 'info', + 'warn', + 'error' +]) + +// Reverse of the writer's level mapping ('warn' → 'warning'); a foreign +// trace zip can carry levels outside our union, which default to 'log'. +function fromTraceLevel(messageType: string): LogLevel { + if (messageType === 'warning') { + return 'warn' + } + return LOG_LEVELS.has(messageType) ? (messageType as LogLevel) : 'log' +} + +export function buildConsoleLogs( + consoleEvents: (ConsoleEvent | StdioEvent)[], + wallTime: number +): ConsoleLog[] { + return consoleEvents.map((event) => { + if (event.type === 'console') { + return { + type: fromTraceLevel(event.messageType), + args: event.args?.map((arg) => arg.value) ?? [event.text], + timestamp: wallTime + event.time, + source: 'browser' as const + } + } + return { + type: event.type === 'stderr' ? ('error' as const) : ('log' as const), + args: [event.text ?? ''], + timestamp: wallTime + event.timestamp, + // Our zips carry the origin; foreign stdio events default to terminal. + source: event.source ?? ('terminal' as const) + } + }) +} + export function nearestFrame( frames: TracePlayerFrame[], timestamp: number diff --git a/packages/backend/src/trace-reader.ts b/packages/backend/src/trace-reader.ts index d8643f39..6146998f 100644 --- a/packages/backend/src/trace-reader.ts +++ b/packages/backend/src/trace-reader.ts @@ -1,10 +1,11 @@ // Reads a trace.zip produced by core/trace-exporter.ts back into a player // payload. The writer is the inverse: this reconstructs commands from // before/after events, the frame filmstrip from screencast-frame events + -// resources/*.jpeg, and network requests from the HAR resource-snapshot -// entries. Fields the zip never carried (console logs, mutations, sources, -// suites) come back empty. Constants, event types, and pure helpers live in the -// sibling trace-reader-{constants,types,utils}.ts files. +// resources/*.jpeg, console logs from console/stdout/stderr events, and +// network requests from the HAR resource-snapshot entries. Fields the zip +// never carried (mutations, sources, suites) come back empty. Constants, +// event types, and pure helpers live in the sibling +// trace-reader-{constants,types,utils}.ts files. import fs from 'node:fs/promises' import { unzipSync, strFromU8 } from 'fflate' @@ -20,12 +21,15 @@ import type { AfterEvent, BeforeEvent, CategorizedEvents, + ConsoleEvent, ContextOptionsEvent, HarSnapshot, - ScreencastFrameEvent + ScreencastFrameEvent, + StdioEvent } from './trace-reader-types.js' import { actionLabel, + buildConsoleLogs, buildMetadata, harToNetworkRequest, nearestFrame, @@ -39,6 +43,7 @@ function categorizeEvents( const befores = new Map() const afters = new Map() const frameEvents: ScreencastFrameEvent[] = [] + const consoleEvents: (ConsoleEvent | StdioEvent)[] = [] let ctx: ContextOptionsEvent | undefined for (const event of events) { switch (event.type) { @@ -58,9 +63,14 @@ function categorizeEvents( case 'screencast-frame': frameEvents.push(event as unknown as ScreencastFrameEvent) break + case 'console': + case 'stdout': + case 'stderr': + consoleEvents.push(event as unknown as ConsoleEvent | StdioEvent) + break } } - return { ctx, befores, afters, frameEvents } + return { ctx, befores, afters, frameEvents, consoleEvents } } function buildFrames( @@ -100,7 +110,7 @@ function buildCommands( command: REVERSE_ACTION_MAP[`${before.class}.${before.method}`] ?? before.method, args: paramsToArgs(before.params), - // Show the Playwright/vibium label (`Element.fill("x")`); the command + // Show the trace-style label (`Element.fill("x")`); the command // name above still drives the UI's category colour and icon. title: actionLabel(before.class, before.method, before.params), startTime: wallTime + before.startTime, @@ -143,7 +153,7 @@ export function parseTraceZip(zip: Uint8Array): TracePlayerData { const trace: TraceLog = { mutations: [], logs: [], - consoleLogs: [], + consoleLogs: buildConsoleLogs(categorized.consoleEvents, wallTime), networkRequests, metadata: buildMetadata(categorized.ctx), commands, diff --git a/packages/backend/tests/trace-reader.test.ts b/packages/backend/tests/trace-reader.test.ts index f2aa880e..8df8f9ff 100644 --- a/packages/backend/tests/trace-reader.test.ts +++ b/packages/backend/tests/trace-reader.test.ts @@ -73,7 +73,18 @@ function fixtureZip(): Uint8Array { width: 1024, height: 768, timestamp: 260 - } + }, + { + type: 'console', + time: 120, + pageId: 'page@abcd1234', + messageType: 'warning', + text: 'low disk', + args: [{ preview: 'low disk', value: 'low disk' }], + location: { url: '', lineNumber: 0, columnNumber: 0 } + }, + { type: 'stdout', timestamp: 130, text: 'spec started', source: 'test' }, + { type: 'stderr', timestamp: 140, text: 'worker warning' } ] const networkEntry = { type: 'resource-snapshot', @@ -159,9 +170,32 @@ describe('parseTraceZip', () => { (trace.metadata.capabilities as { browserName: string }).browserName ).toBe('chrome') expect(trace.metadata.sessionId).toBe('abcd1234') - expect(trace.consoleLogs).toEqual([]) expect(trace.mutations).toEqual([]) expect(trace.suites).toEqual([]) expect(trace.sources).toEqual({}) }) + + it('reconstructs console logs from console and stdio events', () => { + const { trace } = parseTraceZip(fixtureZip()) + expect(trace.consoleLogs).toEqual([ + { + type: 'warn', + args: ['low disk'], + timestamp: WALL_TIME + 120, + source: 'browser' + }, + { + type: 'log', + args: ['spec started'], + timestamp: WALL_TIME + 130, + source: 'test' + }, + { + type: 'error', + args: ['worker warning'], + timestamp: WALL_TIME + 140, + source: 'terminal' + } + ]) + }) }) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 21fcdf08..36b8bcd7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,8 +8,10 @@ export * from './assert-patcher.js' export * from './element-snapshot.js' export * from './element-scripts.js' export * from './element-types.js' +export * from './trace-console.js' export * from './trace-exporter.js' export * from './trace-har.js' +export * from './trace-snapshots.js' export * from './trace-zip-writer.js' export * from './bidi.js' export * from './console.js' diff --git a/packages/core/src/trace-console.ts b/packages/core/src/trace-console.ts new file mode 100644 index 00000000..f9db30fd --- /dev/null +++ b/packages/core/src/trace-console.ts @@ -0,0 +1,93 @@ +// Maps captured ConsoleLog entries into trace-event vocabulary: browser +// console entries become `console` events; test/terminal output becomes +// `stdout`/`stderr` events (which carry no location semantics — matching +// what we capture for Node-side lines). + +import type { ConsoleLog, LogSource } from '@wdio/devtools-shared' + +export interface ConsoleEvent { + type: 'console' + time: number + pageId?: string + messageType: string + text: string + args?: { preview: string; value: unknown }[] + location: { url: string; lineNumber: number; columnNumber: number } +} + +export interface StdioEvent { + type: 'stdout' | 'stderr' + timestamp: number + text?: string + /** Extension field: preserves the test-vs-terminal origin the standard + * stdio vocabulary can't express. Foreign viewers ignore it. */ + source?: Extract +} + +// Keeps a pathological run (console.log in a tight loop) from producing a +// trace.trace the viewer can't open. +const MAX_CONSOLE_EVENTS = 10_000 + +/** The trace format's console vocabulary uses 'warning'; 'trace' has no + * equivalent — 'debug' is the nearest severity. */ +function toTraceLevel(level: ConsoleLog['type']): string { + if (level === 'warn') { + return 'warning' + } + if (level === 'trace') { + return 'debug' + } + return level +} + +function previewArg(arg: unknown): string { + if (typeof arg === 'string') { + return arg + } + try { + return JSON.stringify(arg) ?? String(arg) + } catch { + return String(arg) + } +} + +export function buildConsoleEvents( + logs: ConsoleLog[], + pageId: string, + wallTime: number +): (ConsoleEvent | StdioEvent)[] { + const capped = logs.slice(0, MAX_CONSOLE_EVENTS) + const events: (ConsoleEvent | StdioEvent)[] = capped.map((log) => { + const time = Math.max(0, log.timestamp - wallTime) + const text = log.args.map(previewArg).join(' ') + // Untagged entries predate source tagging; they came from the page. + if (log.source === 'browser' || log.source === undefined) { + return { + type: 'console', + time, + pageId, + messageType: toTraceLevel(log.type), + text, + args: log.args.map((arg) => ({ preview: previewArg(arg), value: arg })), + // Location isn't captured at the console patch site; the event + // shape requires the field, so it ships zeroed. + location: { url: '', lineNumber: 0, columnNumber: 0 } + } satisfies ConsoleEvent + } + return { + type: log.type === 'error' || log.type === 'warn' ? 'stderr' : 'stdout', + timestamp: time, + text, + source: log.source + } satisfies StdioEvent + }) + if (logs.length > MAX_CONSOLE_EVENTS) { + const last = capped[capped.length - 1] + events.push({ + type: 'stderr', + timestamp: last ? Math.max(0, last.timestamp - wallTime) : 0, + text: `[devtools] console truncated: dropped ${logs.length - MAX_CONSOLE_EVENTS} entries` + }) + } + return events +} diff --git a/packages/core/src/trace-exporter.ts b/packages/core/src/trace-exporter.ts index 24488442..8fd7d575 100644 --- a/packages/core/src/trace-exporter.ts +++ b/packages/core/src/trace-exporter.ts @@ -20,6 +20,16 @@ import { FILL_METHODS, type TraceAction } from './action-mapping.js' +import { + buildConsoleEvents, + type ConsoleEvent, + type StdioEvent +} from './trace-console.js' +import { + buildFilmstripEvents, + buildSnapshotResources, + type ScreencastFrameEvent +} from './trace-snapshots.js' import { networkRequestToHar } from './trace-har.js' import { buildTraceZip, type TraceZipResource } from './trace-zip-writer.js' @@ -52,7 +62,7 @@ interface BeforeEvent { pageId: string params: Record title: string - /** Playwright-compatible API name (e.g. 'page.goBack', 'element.click'). */ + /** Trace-viewer API name (e.g. 'page.goBack', 'element.click'). */ apiName: string /** CallId of the Tracing.tracingGroup that wraps this action (if any). */ parentId?: string @@ -67,22 +77,13 @@ interface AfterEvent { parentId?: string } -interface ScreencastFrameEvent { - type: 'screencast-frame' - pageId: string - sha1: string - elements?: string - snapshot?: string - width: number - height: number - timestamp: number -} - type TraceEvent = | ContextOptionsEvent | BeforeEvent | AfterEvent | ScreencastFrameEvent + | ConsoleEvent + | StdioEvent function shortId(sessionId?: string): string { return (sessionId ?? Math.random().toString(36).slice(2, 10)).slice(0, 8) @@ -294,63 +295,6 @@ function buildNetworkNdjson( return Buffer.from(lines.join('\n'), 'utf8') } -function buildSnapshotResources( - snapshots: ActionSnapshot[], - pageId: string -): TraceZipResource[] { - const out: TraceZipResource[] = [] - for (const snap of snapshots) { - const base = `${pageId}-${snap.timestamp}` - if (snap.screenshot) { - out.push({ - resourceName: `${base}.jpeg`, - data: Buffer.from(snap.screenshot, 'base64') - }) - } - if (snap.elements && snap.elements.length) { - out.push({ - resourceName: `${base}-elements.json`, - data: Buffer.from(JSON.stringify(snap.elements), 'utf8') - }) - } - if (snap.snapshotText) { - out.push({ - resourceName: `${base}-snapshot.txt`, - data: Buffer.from(snap.snapshotText, 'utf8') - }) - } - } - return out -} - -function buildScreencastFrames( - snapshots: ActionSnapshot[], - pageId: string, - wallTime: number, - viewport: { width: number; height: number } -): ScreencastFrameEvent[] { - return snapshots - .filter((s) => s.screenshot) - .map((s) => { - const base = `${pageId}-${s.timestamp}` - const frame: ScreencastFrameEvent = { - type: 'screencast-frame', - pageId, - sha1: `${base}.jpeg`, - width: viewport.width, - height: viewport.height, - timestamp: Math.max(0, s.timestamp - wallTime) - } - if (s.elements && s.elements.length) { - frame.elements = `${base}-elements.json` - } - if (s.snapshotText) { - frame.snapshot = `${base}-snapshot.txt` - } - return frame - }) -} - /** * Build a trace.zip buffer from the captured TraceLog. * Filters commands through ACTION_MAP and renames to trace vocabulary; @@ -368,13 +312,19 @@ function eventTime(e: TraceEvent): number { return e.endTime case 'screencast-frame': return e.timestamp + case 'console': + return e.time + case 'stdout': + case 'stderr': + return e.timestamp } } /** At the same timestamp T: an action's `after` ends first, then the - * snapshot captured at the action boundary, then the next action's `before`. - * Matches the viewer's expectation that the screencast frame shows the - * state between the previous action's completion and the next one's start. */ + * snapshot captured at the action boundary, then console output observed + * at the boundary, then the next action's `before`. Matches the viewer's + * expectation that the screencast frame shows the state between the + * previous action's completion and the next one's start. */ function eventOrder(e: TraceEvent): number { switch (e.type) { case 'context-options': @@ -383,8 +333,12 @@ function eventOrder(e: TraceEvent): number { return 1 case 'screencast-frame': return 2 - case 'before': + case 'console': + case 'stdout': + case 'stderr': return 3 + case 'before': + return 4 } } @@ -464,41 +418,12 @@ function buildTraceBundle( const viewport = trace.metadata.viewport ?? { width: 1280, height: 720 } const snapshots = trace.actionSnapshots ?? [] const ctxOptions = buildContextOptions(trace, contextId, wallTime) - const events: TraceEvent[] = [ctxOptions] - - // Emit initial screencast-frame (timestamp=0) using the first snapshot's - // resources so trace viewers show the page state before any interaction. - const firstSnap = snapshots.find((s) => s.screenshot) - if (firstSnap) { - const base = `${pageId}-${firstSnap.timestamp}` - const initFrame: ScreencastFrameEvent = { - type: 'screencast-frame', - pageId, - sha1: `${base}.jpeg`, - width: viewport.width, - height: viewport.height, - timestamp: 0 - } - if (firstSnap.elements && firstSnap.elements.length) { - initFrame.elements = `${base}-elements.json` - } - if (firstSnap.snapshotText) { - initFrame.snapshot = `${base}-snapshot.txt` - } - events.push(initFrame) - } - - events.push( - // Skip the first snapshot in buildScreencastFrames — it was already emitted - // as the initial t=0 frame above. - ...buildScreencastFrames( - firstSnap ? snapshots.filter((s) => s !== firstSnap) : snapshots, - pageId, - wallTime, - viewport - ), - ...buildActionEvents(trace.commands, pageId, wallTime, opts.testMetadata) - ) + const events: TraceEvent[] = [ + ctxOptions, + ...buildFilmstripEvents(snapshots, pageId, wallTime, viewport), + ...buildActionEvents(trace.commands, pageId, wallTime, opts.testMetadata), + ...buildConsoleEvents(trace.consoleLogs, pageId, wallTime) + ] events.sort(compareEvents) const ctxBName = ctxOptions.title return { diff --git a/packages/core/src/trace-snapshots.ts b/packages/core/src/trace-snapshots.ts new file mode 100644 index 00000000..0f139bdb --- /dev/null +++ b/packages/core/src/trace-snapshots.ts @@ -0,0 +1,102 @@ +// Per-action snapshot resources and the screencast-frame filmstrip derived +// from them. Split from trace-exporter.ts; the exporter composes these into +// the trace event stream. + +import type { ActionSnapshot } from '@wdio/devtools-shared' +import type { TraceZipResource } from './trace-zip-writer.js' + +export interface ScreencastFrameEvent { + type: 'screencast-frame' + pageId: string + sha1: string + elements?: string + snapshot?: string + width: number + height: number + timestamp: number +} + +export function buildSnapshotResources( + snapshots: ActionSnapshot[], + pageId: string +): TraceZipResource[] { + const out: TraceZipResource[] = [] + for (const snap of snapshots) { + const base = `${pageId}-${snap.timestamp}` + if (snap.screenshot) { + out.push({ + resourceName: `${base}.jpeg`, + data: Buffer.from(snap.screenshot, 'base64') + }) + } + if (snap.elements && snap.elements.length) { + out.push({ + resourceName: `${base}-elements.json`, + data: Buffer.from(JSON.stringify(snap.elements), 'utf8') + }) + } + if (snap.snapshotText) { + out.push({ + resourceName: `${base}-snapshot.txt`, + data: Buffer.from(snap.snapshotText, 'utf8') + }) + } + } + return out +} + +function frameForSnapshot( + snap: ActionSnapshot, + pageId: string, + timestamp: number, + viewport: { width: number; height: number } +): ScreencastFrameEvent { + const base = `${pageId}-${snap.timestamp}` + const frame: ScreencastFrameEvent = { + type: 'screencast-frame', + pageId, + sha1: `${base}.jpeg`, + width: viewport.width, + height: viewport.height, + timestamp + } + if (snap.elements && snap.elements.length) { + frame.elements = `${base}-elements.json` + } + if (snap.snapshotText) { + frame.snapshot = `${base}-snapshot.txt` + } + return frame +} + +/** + * Full filmstrip for the trace: the first snapshot is re-anchored to t=0 so + * viewers show the page state before any interaction; the rest keep their + * wall-time offsets. + */ +export function buildFilmstripEvents( + snapshots: ActionSnapshot[], + pageId: string, + wallTime: number, + viewport: { width: number; height: number } +): ScreencastFrameEvent[] { + const firstSnap = snapshots.find((s) => s.screenshot) + const events: ScreencastFrameEvent[] = [] + if (firstSnap) { + events.push(frameForSnapshot(firstSnap, pageId, 0, viewport)) + } + for (const snap of snapshots) { + if (snap === firstSnap || !snap.screenshot) { + continue + } + events.push( + frameForSnapshot( + snap, + pageId, + Math.max(0, snap.timestamp - wallTime), + viewport + ) + ) + } + return events +} diff --git a/packages/core/tests/trace-console.test.ts b/packages/core/tests/trace-console.test.ts new file mode 100644 index 00000000..5bb6ee59 --- /dev/null +++ b/packages/core/tests/trace-console.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from 'vitest' +import { buildConsoleEvents } from '@wdio/devtools-core' +import type { ConsoleLog } from '@wdio/devtools-shared' + +const WALL_TIME = 1000 +const PAGE_ID = 'page@abc123' + +function log(overrides: Partial = {}): ConsoleLog { + return { + type: 'log', + args: ['hello'], + timestamp: WALL_TIME + 50, + source: 'browser', + ...overrides + } +} + +describe('buildConsoleEvents', () => { + it('returns empty array for no logs', () => { + expect(buildConsoleEvents([], PAGE_ID, WALL_TIME)).toEqual([]) + }) + + it('maps browser logs to console events with monotonic offsets', () => { + const events = buildConsoleEvents([log()], PAGE_ID, WALL_TIME) + expect(events).toEqual([ + { + type: 'console', + time: 50, + pageId: PAGE_ID, + messageType: 'log', + text: 'hello', + args: [{ preview: 'hello', value: 'hello' }], + location: { url: '', lineNumber: 0, columnNumber: 0 } + } + ]) + }) + + it('treats untagged logs as browser console', () => { + const events = buildConsoleEvents( + [log({ source: undefined })], + PAGE_ID, + WALL_TIME + ) + expect(events[0]!.type).toBe('console') + }) + + it("maps 'warn' to 'warning' and 'trace' to 'debug'", () => { + const events = buildConsoleEvents( + [log({ type: 'warn' }), log({ type: 'trace' })], + PAGE_ID, + WALL_TIME + ) + expect( + events.map((e) => (e.type === 'console' ? e.messageType : '')) + ).toEqual(['warning', 'debug']) + }) + + it('previews non-string args as JSON and joins into text', () => { + const events = buildConsoleEvents( + [log({ args: ['count', { a: 1 }, 2] })], + PAGE_ID, + WALL_TIME + ) + const event = events[0]! + expect(event.type).toBe('console') + if (event.type === 'console') { + expect(event.text).toBe('count {"a":1} 2') + expect(event.args?.[1]).toEqual({ preview: '{"a":1}', value: { a: 1 } }) + } + }) + + it('routes test/terminal logs to stdout/stderr by level with source kept', () => { + const events = buildConsoleEvents( + [ + log({ source: 'test', type: 'log', args: ['out'] }), + log({ source: 'test', type: 'error', args: ['bad'] }), + log({ source: 'terminal', type: 'warn', args: ['careful'] }) + ], + PAGE_ID, + WALL_TIME + ) + expect(events).toEqual([ + { type: 'stdout', timestamp: 50, text: 'out', source: 'test' }, + { type: 'stderr', timestamp: 50, text: 'bad', source: 'test' }, + { type: 'stderr', timestamp: 50, text: 'careful', source: 'terminal' } + ]) + }) + + it('floors offsets at zero for logs before wallTime', () => { + const events = buildConsoleEvents( + [log({ timestamp: WALL_TIME - 500 })], + PAGE_ID, + WALL_TIME + ) + const event = events[0]! + expect(event.type === 'console' ? event.time : -1).toBe(0) + }) + + it('caps output and appends a truncation marker', () => { + const logs = Array.from({ length: 10_001 }, (_, i) => + log({ timestamp: WALL_TIME + i }) + ) + const events = buildConsoleEvents(logs, PAGE_ID, WALL_TIME) + expect(events).toHaveLength(10_001) + const last = events[events.length - 1]! + expect(last.type).toBe('stderr') + if (last.type === 'stderr') { + expect(last.text).toContain('dropped 1 entries') + } + }) +}) From 9eb326c9c6ae8d8088dd4c8b2e7e4f4c85eddd2c Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Thu, 2 Jul 2026 19:56:46 +0530 Subject: [PATCH 02/21] =?UTF-8?q?feat(app):=20trace-player=20layout=20pari?= =?UTF-8?q?ty=20=E2=80=94=20full=20workbench=20with=20top-docked=20timelin?= =?UTF-8?q?e=20strip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/app/src/app.ts | 7 +- .../browser/trace-player-controls.ts | 145 ++++++++ .../browser/trace-timeline-constants.ts | 26 +- .../browser/trace-timeline-styles.ts | 45 +-- .../src/components/browser/trace-timeline.ts | 313 +++++------------- packages/app/src/components/workbench.ts | 135 ++++++-- packages/app/src/controller/constants.ts | 9 + 7 files changed, 357 insertions(+), 323 deletions(-) create mode 100644 packages/app/src/components/browser/trace-player-controls.ts diff --git a/packages/app/src/app.ts b/packages/app/src/app.ts index d9a89e5d..d8076a4b 100644 --- a/packages/app/src/app.ts +++ b/packages/app/src/app.ts @@ -232,9 +232,10 @@ export class WebdriverIODevtoolsApplication extends Element { class="flex h-[calc(100%-40px)] w-full relative" > ${ - // Only render the test-suite sidebar (and its resize slider) when the - // trace came from a testrunner — the player (standalone) has no tree, - // so the slider would otherwise show a stray dragger on hover. + // Only render the test-suite sidebar (and its resize slider) for a + // live testrunner session. The player has no run/rerun affordances, + // so the tree is dead weight even for testrunner-captured zips. + !this.dataManager.playerMode && this.dataManager.traceType === TraceType.Testrunner ? html` { + this.playerState = (event as CustomEvent).detail + } + + #emit(name: string, detail?: unknown): void { + window.dispatchEvent(new CustomEvent(name, { detail })) + } + + #button( + title: string, + icon: TemplateResult, + onClick: () => void, + extra = '' + ): TemplateResult { + return html`` + } + + #renderSpeedSelect(speed: number): TemplateResult { + return html` + + ` + } + + render() { + const { currentMs, duration, playing, speed } = this.playerState + return html` +
+ ${formatTimecode(currentMs)} + / + ${formatTimecode(duration)} + + ${this.#button( + 'Restart', + html``, + () => this.#emit(PLAYER_RESTART_EVENT) + )} + ${this.#button( + 'Previous action', + html``, + () => this.#emit(KBD.step, { dir: -1 }) + )} + ${this.#button( + playing ? 'Pause' : 'Play', + playing + ? html`` + : html``, + () => this.#emit(KBD.togglePlay), + 'text-chartsBlue' + )} + ${this.#button( + 'Next action', + html``, + () => this.#emit(KBD.step, { dir: 1 }) + )} + ${this.#renderSpeedSelect(speed)} +
+ ` + } +} + +declare global { + interface HTMLElementTagNameMap { + [COMPONENT]: TracePlayerControls + } +} diff --git a/packages/app/src/components/browser/trace-timeline-constants.ts b/packages/app/src/components/browser/trace-timeline-constants.ts index d88f1c61..1e6a1dad 100644 --- a/packages/app/src/components/browser/trace-timeline-constants.ts +++ b/packages/app/src/components/browser/trace-timeline-constants.ts @@ -1,19 +1,15 @@ -import type { ActionCategory } from '../workbench/actionItems/category.js' - /** Playback speed multipliers offered in the timeline controls. */ export const SPEEDS = [0.5, 1, 2, 3, 5] -/** Width of the track-label gutter (px) — lanes start after it. */ -export const GUTTER = 80 - -/** Right breathing room (px) so end-of-timeline markers don't hug the edge. */ -export const INSET = 14 - -/** Tailwind background class per action category, for the timeline chips. */ -export const CATEGORY_BG: Record = { - navigation: 'bg-chartsBlue', - input: 'bg-chartsPurple', - assertion: 'bg-chartsGreen', - query: 'bg-chartsYellow', - other: 'bg-gray-500' +/** Window events linking the controls bar and the timeline strip — same + * decoupling pattern as the KBD events, so either side can be re-homed. */ +export const PLAYER_STATE_EVENT = 'trace-player:state' +export const PLAYER_RESTART_EVENT = 'trace-player:restart' +export const PLAYER_SPEED_EVENT = 'trace-player:speed' + +export interface PlayerState { + currentMs: number + duration: number + playing: boolean + speed: number } diff --git a/packages/app/src/components/browser/trace-timeline-styles.ts b/packages/app/src/components/browser/trace-timeline-styles.ts index f2e276ad..d65cbe24 100644 --- a/packages/app/src/components/browser/trace-timeline-styles.ts +++ b/packages/app/src/components/browser/trace-timeline-styles.ts @@ -1,7 +1,6 @@ import { css } from 'lit' -/** Styles for the trace-player timeline: host layout, hidden scrollbars, and - * the network-detail drawer. Detail-block styles come from networkStyles. */ +/** Styles for the trace-player timeline strip: host layout + hidden scrollbars. */ export const timelineStyles = css` :host { position: relative; @@ -19,46 +18,4 @@ export const timelineStyles = css` .no-scrollbar::-webkit-scrollbar { display: none; } - .net-drawer { - position: absolute; - left: 0; - right: 0; - bottom: 0; - max-height: 62%; - display: flex; - flex-direction: column; - background: var(--vscode-sideBar-background); - border-top: 1px solid var(--accent, #ff7a3c); - box-shadow: 0 -16px 40px -24px #000; - z-index: 30; - } - .net-drawer-head { - display: flex; - align-items: center; - gap: 8px; - padding: 6px 10px; - border-bottom: 1px solid var(--vscode-panel-border); - font-size: 12px; - } - .net-drawer-head .url { - font-family: monospace; - font-size: 11.5px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - opacity: 0.85; - } - .net-drawer-head .close { - margin-left: auto; - cursor: pointer; - padding: 2px 6px; - border-radius: 4px; - } - .net-drawer-head .close:hover { - background: var(--vscode-toolbar-hoverBackground); - } - .net-drawer-body { - overflow: auto; - padding: 4px 0; - } ` diff --git a/packages/app/src/components/browser/trace-timeline.ts b/packages/app/src/components/browser/trace-timeline.ts index c2a62d52..2abc3753 100644 --- a/packages/app/src/components/browser/trace-timeline.ts +++ b/packages/app/src/components/browser/trace-timeline.ts @@ -1,41 +1,30 @@ import { Element } from '@core/element' -import { html, nothing, type TemplateResult } from 'lit' +import { html, type TemplateResult } from 'lit' import { customElement, state, query } from 'lit/decorators.js' import { consume } from '@lit/context' import type { CommandLog, TracePlayerFrame } from '@wdio/devtools-shared' -import { - commandContext, - framesContext, - networkRequestContext -} from '../../controller/context.js' -import { commandCategory } from '../workbench/actionItems/category.js' +import { commandContext, framesContext } from '../../controller/context.js' import { activeTimestampAt } from '../workbench/active-entry.js' -import { networkStyles } from '../workbench/network/styles.js' -import { renderNetworkRequestDetail } from '../workbench/network/request-detail.js' import { KBD } from '../../controller/keyboard.js' import { - CATEGORY_BG, - GUTTER, - INSET, - SPEEDS + PLAYER_RESTART_EVENT, + PLAYER_SPEED_EVENT, + PLAYER_STATE_EVENT, + SPEEDS, + type PlayerState } from './trace-timeline-constants.js' import { formatTimecode, imageMime } from './trace-timeline-utils.js' import { timelineStyles } from './trace-timeline-styles.js' -import '~icons/mdi/play.js' -import '~icons/mdi/pause.js' -import '~icons/mdi/skip-previous.js' -import '~icons/mdi/skip-next.js' -import '~icons/mdi/restart.js' - const COMPONENT = 'wdio-devtools-trace-timeline' /** - * Trace-player timeline (replaces the workbench dock in `pnpm show-trace` - * mode). Owns the playback clock, the screenshot filmstrip, the per-track - * timeline (actions / network / console), and the playhead. Advancing the - * clock dispatches `show-command` so the reused browser pane swaps screenshots. + * Trace-player timeline strip, docked above the workbench in `pnpm show-trace` + * mode. Owns the playback clock, the screenshot filmstrip, and the playhead; + * the controls bar (trace-player-controls) and keyboard drive it via window + * events, and it broadcasts its state back the same way. Advancing the clock + * dispatches `show-command` so the reused browser pane and actions list follow. */ @customElement(COMPONENT) export class TraceTimeline extends Element { @@ -47,10 +36,6 @@ export class TraceTimeline extends Element { @state() frames: TracePlayerFrame[] = [] - @consume({ context: networkRequestContext, subscribe: true }) - @state() - networkRequests: NetworkRequest[] = [] - /** Playback position in ms relative to the recording start. */ @state() currentMs = 0 @state() playing = false @@ -61,14 +46,11 @@ export class TraceTimeline extends Element { #activeTimestamp?: number #started = false - @query('[data-lanes]') lanesEl?: HTMLElement + @query('[data-scrub]') scrubEl?: HTMLElement #dragging = false - /** Network request whose detail drawer is open, or undefined. */ - @state() selectedRequest?: NetworkRequest - - static styles = [...Element.styles, networkStyles, timelineStyles] + static styles = [...Element.styles, timelineStyles] connectedCallback(): void { super.connectedCallback() @@ -76,6 +58,8 @@ export class TraceTimeline extends Element { window.addEventListener(KBD.step, this.#onKbdStep) window.addEventListener(KBD.jump, this.#onKbdJump) window.addEventListener(KBD.speed, this.#onKbdSpeed) + window.addEventListener(PLAYER_RESTART_EVENT, this.#onRestartEvent) + window.addEventListener(PLAYER_SPEED_EVENT, this.#onSpeedEvent) } disconnectedCallback(): void { @@ -87,6 +71,13 @@ export class TraceTimeline extends Element { window.removeEventListener(KBD.step, this.#onKbdStep) window.removeEventListener(KBD.jump, this.#onKbdJump) window.removeEventListener(KBD.speed, this.#onKbdSpeed) + window.removeEventListener(PLAYER_RESTART_EVENT, this.#onRestartEvent) + window.removeEventListener(PLAYER_SPEED_EVENT, this.#onSpeedEvent) + } + + #onRestartEvent = (): void => this.#restart() + #onSpeedEvent = (event: Event): void => { + this.speed = (event as CustomEvent<{ value: number }>).detail.value } #onKbdTogglePlay = (): void => this.#togglePlay() @@ -152,6 +143,17 @@ export class TraceTimeline extends Element { if (!this.#started && this.commands.length) { this.#syncActiveCommand() } + // Mirror playback state to the controls bar on the tab-header line. + window.dispatchEvent( + new CustomEvent(PLAYER_STATE_EVENT, { + detail: { + currentMs: this.currentMs, + duration: this.#duration, + playing: this.playing, + speed: this.speed + } + }) + ) } #stopRaf(): void { @@ -243,23 +245,14 @@ export class TraceTimeline extends Element { } } - #onSpeedChange(event: Event): void { - this.speed = Number((event.target as HTMLSelectElement).value) - } - - // ─── scrubbing (free-flow playhead drag) ─────────────────────────────────── + // ─── scrubbing (drag anywhere on the strip) ─────────────────────────────── #fractionFromClientX(clientX: number): number { - const rect = this.lanesEl?.getBoundingClientRect() - if (!rect) { + const rect = this.scrubEl?.getBoundingClientRect() + if (!rect || rect.width <= 0) { return 0 } - const laneStart = rect.left + GUTTER - const laneWidth = rect.width - GUTTER - INSET - if (laneWidth <= 0) { - return 0 - } - return Math.min(1, Math.max(0, (clientX - laneStart) / laneWidth)) + return Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)) } #onPointerDown = (event: PointerEvent): void => { @@ -290,72 +283,6 @@ export class TraceTimeline extends Element { // ─── render ─────────────────────────────────────────────────────────────── - #ctrlButton( - title: string, - icon: TemplateResult, - onClick: () => void, - extra = '' - ): TemplateResult { - return html`` - } - - #renderControls(): TemplateResult { - return html` -
- ${this.#ctrlButton( - 'Restart', - html``, - () => this.#restart() - )} - ${this.#ctrlButton( - 'Previous action', - html``, - () => this.#step(-1) - )} - ${this.#ctrlButton( - this.playing ? 'Pause' : 'Play', - this.playing - ? html`` - : html``, - () => this.#togglePlay(), - 'text-chartsBlue' - )} - ${this.#ctrlButton( - 'Next action', - html``, - () => this.#step(1) - )} - ${formatTimecode(this.currentMs)} - / - ${formatTimecode(this.#duration)} - -
- ` - } - /** Timestamp of the frame nearest the playhead — drives filmstrip highlight. */ get #activeFrameTimestamp(): number | undefined { const clock = this.#start + this.currentMs @@ -371,16 +298,10 @@ export class TraceTimeline extends Element { return best } - // CSS left for a marker inside a track body (which starts after the gutter), - // leaving INSET of right margin so end-of-timeline markers don't hug the edge. - #laneLeft(fraction: number): string { - return `calc(${fraction} * (100% - ${INSET}px))` - } - #renderFilmstrip(): TemplateResult { if (!this.frames.length) { return html`
No frames captured
` @@ -388,150 +309,64 @@ export class TraceTimeline extends Element { const activeFrame = this.#activeFrameTimestamp return html`
-
-
- ${this.frames.map( - (frame) => - html`` - )} -
-
- ` - } - - #renderTrack( - label: string, - body: TemplateResult | typeof nothing - ): TemplateResult { - return html` -
-
- ${label} -
-
${body}
+ ${this.frames.map( + (frame) => + html`` + )}
` } - #renderActionsTrack(): TemplateResult { - const body = html`${this.#sortedCommands.map((command) => { - const ts = command.timestamp ?? 0 - const fraction = this.#fraction(ts) - const active = ts === this.#activeTimestamp - const color = CATEGORY_BG[commandCategory(command.command)] - // Track chips stay compact with the short command name; the full - // Playwright label is the hover tooltip (and the left Actions list). - return html`` - })}` - return this.#renderTrack('Actions', body) - } - - #renderNetworkTrack(): TemplateResult { - if (!this.networkRequests.length) { - return this.#renderTrack('Network', nothing) - } - const body = html`${this.networkRequests.map((request) => { - const leftFr = this.#fraction(request.startTime) - const rawFr = Math.max(0.004, (request.time ?? 0) / this.#duration) - const widthFr = Math.min(rawFr, 1 - leftFr) - const selected = this.selectedRequest?.id === request.id - // stopPropagation so a click selects the request rather than scrubbing the - // playhead (the lanes container owns the pointerdown drag handler). - return html`
` - })}` - return this.#renderTrack('Network', body) - } - - #renderNetworkDrawer(): TemplateResult | typeof nothing { - const req = this.selectedRequest - if (!req) { - return nothing - } + // Slim full-width ruler under the controls: action ticks + drag-to-scrub. + #renderRuler(): TemplateResult { return html` -
-
- ${req.method} ${req.url} - -
-
${renderNetworkRequestDetail(req)}
+
+ ${this.#sortedCommands.map((command) => { + const ts = command.timestamp ?? 0 + return html`
` + })}
` } #renderPlayhead(): TemplateResult { const fraction = Math.min(1, Math.max(0, this.currentMs / this.#duration)) - // Anchored at the gutter and inset on the right so it tracks the same lane - // coordinates as the action/network markers. return html`
` } render() { return html` - ${this.#renderControls()} ${this.#renderFilmstrip()}
- ${this.#renderActionsTrack()} ${this.#renderNetworkTrack()} - ${this.#renderTrack('Console', nothing)} ${this.#renderPlayhead()} + ${this.#renderRuler()} ${this.#renderFilmstrip()} + ${this.#renderPlayhead()}
- ${this.#renderNetworkDrawer()} ` } } diff --git a/packages/app/src/components/workbench.ts b/packages/app/src/components/workbench.ts index 5b87cd9d..0d56d7d9 100644 --- a/packages/app/src/components/workbench.ts +++ b/packages/app/src/components/workbench.ts @@ -26,23 +26,36 @@ import './workbench/network.js' import './workbench/compare.js' import './browser/snapshot.js' import './browser/trace-timeline.js' +import './browser/trace-player-controls.js' import { + HEADER_HEIGHT, MIN_WORKBENCH_HEIGHT, MIN_METATAB_WIDTH, ACTIONS_DEFAULT_WIDTH, BROWSER_HEIGHT_RATIO, - RERENDER_TIMEOUT + PLAYER_CONTROLS_HEIGHT, + PLAYER_DOCK_DEFAULT_HEIGHT, + PLAYER_DOCK_MIN_HEIGHT, + RERENDER_TIMEOUT, + TRACE_TIMELINE_MIN_HEIGHT, + TRACE_TIMELINE_DEFAULT_HEIGHT } from '../controller/constants.js' const COMPONENT = 'wdio-devtools-workbench' + +/** Pixel value from a DragController position string (`flex-basis: 123px`). */ +function basisPx(position: string): number | undefined { + const value = parseFloat(position.split(':')[1] ?? '') + return Number.isFinite(value) ? value : undefined +} @customElement(COMPONENT) export class DevtoolsWorkbench extends Element { #toolbarCollapsed = localStorage.getItem('toolbar') === 'true' #workbenchSidebarCollapsed = localStorage.getItem('workbenchSidebar') === 'true' - // Trace-player mode (`pnpm show-trace`): hide the Metadata tab and swap the - // workbench tabs for the timeline player. + // Trace-player mode (`pnpm show-trace`): the full workbench renders as in + // live mode, plus the playback timeline docked above the browser pane. @property({ type: Boolean }) playerMode = false @@ -99,6 +112,33 @@ export class DevtoolsWorkbench extends Element { direction: Direction.horizontal }) + #dragTimeline = new DragController(this, { + localStorageKey: 'traceTimelineHeight', + minPosition: TRACE_TIMELINE_MIN_HEIGHT, + maxPosition: window.innerHeight * 0.4, + initialPosition: TRACE_TIMELINE_DEFAULT_HEIGHT, + getContainerEl: () => this.#getVerticalWindow(), + direction: Direction.vertical + }) + + // Player-mode browser-pane height. Separate controller + storage key so + // resizing the player never disturbs the live-mode split. + #dragVerticalPlayer = new DragController(this, { + localStorageKey: 'playerBrowserHeight', + minPosition: MIN_WORKBENCH_HEIGHT, + maxPosition: window.innerHeight * 0.8, + initialPosition: Math.max( + MIN_WORKBENCH_HEIGHT, + window.innerHeight - + HEADER_HEIGHT - + PLAYER_CONTROLS_HEIGHT - + TRACE_TIMELINE_DEFAULT_HEIGHT - + PLAYER_DOCK_DEFAULT_HEIGHT + ), + getContainerEl: () => this.#getVerticalWindow(), + direction: Direction.vertical + }) + async #getHorizontalWindow() { await this.updateComplete return this.horizontalResizerWindow as Element @@ -144,13 +184,43 @@ export class DevtoolsWorkbench extends Element { if (this.#toolbarCollapsed) { return '' } - const m = this.#dragVertical.getPosition().match(/(\d+(?:\.\d+)?)px/) - const raw = m ? parseFloat(m[1]) : window.innerHeight * BROWSER_HEIGHT_RATIO + if (this.playerMode) { + // Player proportions: the snapshot dominates — pane gets everything the + // timeline, controls bar, and a compact dock don't need, clamped in CSS + // so the dock never drops below its minimum inside the viewport. + const fallback = + window.innerHeight - + HEADER_HEIGHT - + PLAYER_CONTROLS_HEIGHT - + this.#timelinePaneHeight() - + PLAYER_DOCK_DEFAULT_HEIGHT + const raw = basisPx(this.#dragVerticalPlayer.getPosition()) ?? fallback + const paneHeight = Math.max(MIN_WORKBENCH_HEIGHT, raw) + const maxHeight = `calc(100vh - ${ + HEADER_HEIGHT + PLAYER_CONTROLS_HEIGHT + PLAYER_DOCK_MIN_HEIGHT + }px - ${this.#timelinePaneHeight()}px)` + return `flex:0 0 ${paneHeight}px; height:${paneHeight}px; max-height:${maxHeight}; min-height:0;` + } + const raw = + basisPx(this.#dragVertical.getPosition()) ?? + window.innerHeight * BROWSER_HEIGHT_RATIO const capped = Math.min(raw, window.innerHeight * 0.7) const paneHeight = Math.max(MIN_WORKBENCH_HEIGHT, capped) return `flex:0 0 ${paneHeight}px; height:${paneHeight}px; max-height:70vh; min-height:0;` } + #timelinePaneHeight(): number { + const raw = + basisPx(this.#dragTimeline.getPosition()) ?? TRACE_TIMELINE_DEFAULT_HEIGHT + const capped = Math.min(raw, window.innerHeight * 0.4) + return Math.max(TRACE_TIMELINE_MIN_HEIGHT, capped) + } + + #computeTimelinePaneStyle(): string { + const paneHeight = this.#timelinePaneHeight() + return `flex:0 0 ${paneHeight}px; height:${paneHeight}px; max-height:40vh; min-height:0;` + } + #computeSidebarStyle(): string { if (this.#workbenchSidebarCollapsed) { return 'width:0; flex:0 0 0; overflow:hidden;' @@ -173,11 +243,9 @@ export class DevtoolsWorkbench extends Element { - ${this.playerMode - ? nothing - : html` - - `} + + +
diff --git a/packages/app/src/components/browser/trace-timeline-constants.ts b/packages/app/src/components/browser/trace-timeline-constants.ts index 1e6a1dad..9f057994 100644 --- a/packages/app/src/components/browser/trace-timeline-constants.ts +++ b/packages/app/src/components/browser/trace-timeline-constants.ts @@ -1,8 +1,16 @@ /** Playback speed multipliers offered in the timeline controls. */ export const SPEEDS = [0.5, 1, 2, 3, 5] -/** Window events linking the controls bar and the timeline strip — same - * decoupling pattern as the KBD events, so either side can be re-homed. */ +/** Candidate ruler intervals (ms); tickStep picks the smallest fitting one. */ +export const TICK_STEPS = [ + 100, 250, 500, 1_000, 2_000, 5_000, 10_000, 15_000, 30_000, 60_000, 120_000, + 300_000, 600_000 +] + +/** Ruler divisions to aim for — keeps labels readable at any duration. */ +export const TICK_TARGET_DIVISIONS = 14 + +/** Window events linking the controls bar and the timeline strip (KBD-style). */ export const PLAYER_STATE_EVENT = 'trace-player:state' export const PLAYER_RESTART_EVENT = 'trace-player:restart' export const PLAYER_SPEED_EVENT = 'trace-player:speed' diff --git a/packages/app/src/components/browser/trace-timeline-styles.ts b/packages/app/src/components/browser/trace-timeline-styles.ts index d65cbe24..f1482adc 100644 --- a/packages/app/src/components/browser/trace-timeline-styles.ts +++ b/packages/app/src/components/browser/trace-timeline-styles.ts @@ -1,6 +1,6 @@ import { css } from 'lit' -/** Styles for the trace-player timeline strip: host layout + hidden scrollbars. */ +/** Host layout for the trace-player timeline strip. */ export const timelineStyles = css` :host { position: relative; @@ -11,11 +11,4 @@ export const timelineStyles = css` background-color: var(--vscode-editor-background); color: var(--vscode-foreground); } - .no-scrollbar { - scrollbar-width: none; - -ms-overflow-style: none; - } - .no-scrollbar::-webkit-scrollbar { - display: none; - } ` diff --git a/packages/app/src/components/browser/trace-timeline-utils.ts b/packages/app/src/components/browser/trace-timeline-utils.ts index 1083ee65..ef99a363 100644 --- a/packages/app/src/components/browser/trace-timeline-utils.ts +++ b/packages/app/src/components/browser/trace-timeline-utils.ts @@ -1,9 +1,37 @@ +import { + TICK_STEPS, + TICK_TARGET_DIVISIONS +} from './trace-timeline-constants.js' + /** Detect image mime from a base64 string's magic bytes — trace screenshots * may be PNG (polling capture) or JPEG (CDP), and the zip names both `.jpeg`. */ export function imageMime(base64: string): string { return base64.startsWith('/9j/') ? 'image/jpeg' : 'image/png' } +export function tickStep( + durationMs: number, + targetTicks = TICK_TARGET_DIVISIONS +): number { + const raw = durationMs / targetTicks + return ( + TICK_STEPS.find((step) => step >= raw) ?? TICK_STEPS[TICK_STEPS.length - 1] + ) +} + +/** Ruler tick label: `500ms`, `3.5s`, `1:15`. */ +export function formatTickLabel(ms: number): string { + if (ms < 1_000) { + return `${ms}ms` + } + if (ms < 60_000) { + return `${(ms / 1_000).toFixed(1)}s` + } + const minutes = Math.floor(ms / 60_000) + const seconds = Math.round((ms % 60_000) / 1_000) + return `${minutes}:${String(seconds).padStart(2, '0')}` +} + /** `m:ss.cc` timecode (e.g. 32_270ms → `0:32.27`). */ export function formatTimecode(ms: number): string { const safe = Number.isFinite(ms) && ms > 0 ? ms : 0 diff --git a/packages/app/src/components/browser/trace-timeline.ts b/packages/app/src/components/browser/trace-timeline.ts index 2abc3753..21f7ffce 100644 --- a/packages/app/src/components/browser/trace-timeline.ts +++ b/packages/app/src/components/browser/trace-timeline.ts @@ -14,18 +14,17 @@ import { SPEEDS, type PlayerState } from './trace-timeline-constants.js' -import { formatTimecode, imageMime } from './trace-timeline-utils.js' +import { + formatTickLabel, + formatTimecode, + imageMime, + tickStep +} from './trace-timeline-utils.js' import { timelineStyles } from './trace-timeline-styles.js' const COMPONENT = 'wdio-devtools-trace-timeline' -/** - * Trace-player timeline strip, docked above the workbench in `pnpm show-trace` - * mode. Owns the playback clock, the screenshot filmstrip, and the playhead; - * the controls bar (trace-player-controls) and keyboard drive it via window - * events, and it broadcasts its state back the same way. Advancing the clock - * dispatches `show-command` so the reused browser pane and actions list follow. - */ +/** Player timeline strip: owns the playback clock, filmstrip, and playhead; wired to the controls bar and keyboard via window events, and drives the workbench via `show-command`. */ @customElement(COMPONENT) export class TraceTimeline extends Element { @consume({ context: commandContext, subscribe: true }) @@ -298,7 +297,45 @@ export class TraceTimeline extends Element { return best } - #renderFilmstrip(): TemplateResult { + get #ticks(): number[] { + const step = tickStep(this.#duration) + const out: number[] = [] + for (let t = step; t < this.#duration; t += step) { + out.push(t) + } + return out + } + + // Faint vertical gridlines at each ruler tick, spanning the whole strip. + #renderGridlines(): TemplateResult { + return html`${this.#ticks.map( + (tick) => + html`
` + )}` + } + + // Ruler labels stay inside the strip via the bounded translateX trick. + #renderRulerLabels(): TemplateResult { + return html` +
+ ${this.#ticks.map((tick) => { + const fraction = tick / this.#duration + return html`${formatTickLabel(tick)}` + })} +
+ ` + } + + // Thumbnails sit at their wall-clock position along the axis. + #renderThumbTrack(): TemplateResult { if (!this.frames.length) { return html`
- ${this.frames.map( - (frame) => - html`` - )} +
+ ${this.frames.map((frame) => { + const fraction = this.#fraction(frame.timestamp) + const active = frame.timestamp === activeFrame + return html`` + })}
` } - // Slim full-width ruler under the controls: action ticks + drag-to-scrub. - #renderRuler(): TemplateResult { + // Bottom scrub bar: full-width line, action tick marks, draggable knob. + #renderScrubBar(): TemplateResult { + const fraction = Math.min(1, Math.max(0, this.currentMs / this.#duration)) return html` -
+
+
${this.#sortedCommands.map((command) => { - const ts = command.timestamp ?? 0 + const tickFraction = this.#fraction(command.timestamp ?? 0) return html`
` })} +
` } - #renderPlayhead(): TemplateResult { - const fraction = Math.min(1, Math.max(0, this.currentMs / this.#duration)) - return html`
` - } - render() { return html`
- ${this.#renderRuler()} ${this.#renderFilmstrip()} - ${this.#renderPlayhead()} + ${this.#renderGridlines()} ${this.#renderRulerLabels()} + ${this.#renderThumbTrack()} ${this.#renderScrubBar()}
` } diff --git a/packages/app/src/components/workbench.ts b/packages/app/src/components/workbench.ts index 0d56d7d9..0bb9f243 100644 --- a/packages/app/src/components/workbench.ts +++ b/packages/app/src/components/workbench.ts @@ -6,10 +6,11 @@ import { consume } from '@lit/context' import { DragController, Direction } from '../utils/DragController.js' import { consoleLogContext, + metadataContext, networkRequestContext, baselineContext } from '../controller/context.js' -import type { PreservedAttempt } from '@wdio/devtools-shared' +import type { Metadata, PreservedAttempt } from '@wdio/devtools-shared' import '~icons/mdi/arrow-collapse-down.js' import '~icons/mdi/arrow-collapse-up.js' @@ -28,6 +29,7 @@ import './browser/snapshot.js' import './browser/trace-timeline.js' import './browser/trace-player-controls.js' import { + BROWSER_BACKDROP_GRADIENT, HEADER_HEIGHT, MIN_WORKBENCH_HEIGHT, MIN_METATAB_WIDTH, @@ -36,6 +38,7 @@ import { PLAYER_CONTROLS_HEIGHT, PLAYER_DOCK_DEFAULT_HEIGHT, PLAYER_DOCK_MIN_HEIGHT, + PLAYER_SNAPSHOT_WIDTH_RATIO, RERENDER_TIMEOUT, TRACE_TIMELINE_MIN_HEIGHT, TRACE_TIMELINE_DEFAULT_HEIGHT @@ -54,8 +57,7 @@ export class DevtoolsWorkbench extends Element { #workbenchSidebarCollapsed = localStorage.getItem('workbenchSidebar') === 'true' - // Trace-player mode (`pnpm show-trace`): the full workbench renders as in - // live mode, plus the playback timeline docked above the browser pane. + // Trace-player mode: full workbench plus the timeline strip and controls bar. @property({ type: Boolean }) playerMode = false @@ -71,6 +73,10 @@ export class DevtoolsWorkbench extends Element { @state() baselines: Map | undefined = undefined + @consume({ context: metadataContext, subscribe: true }) + @state() + metadata: Metadata | undefined = undefined + static styles = [ ...Element.styles, css` @@ -115,18 +121,18 @@ export class DevtoolsWorkbench extends Element { #dragTimeline = new DragController(this, { localStorageKey: 'traceTimelineHeight', minPosition: TRACE_TIMELINE_MIN_HEIGHT, - maxPosition: window.innerHeight * 0.4, + maxPosition: () => window.innerHeight * 0.4, initialPosition: TRACE_TIMELINE_DEFAULT_HEIGHT, getContainerEl: () => this.#getVerticalWindow(), direction: Direction.vertical }) - // Player-mode browser-pane height. Separate controller + storage key so - // resizing the player never disturbs the live-mode split. + // Player-mode pane height; own storage key so it never disturbs the live split. + // The live max bound keeps the handle (and pane) inside the current budget. #dragVerticalPlayer = new DragController(this, { - localStorageKey: 'playerBrowserHeight', + localStorageKey: 'playerPaneHeight', minPosition: MIN_WORKBENCH_HEIGHT, - maxPosition: window.innerHeight * 0.8, + maxPosition: () => this.#playerPaneBudget(), initialPosition: Math.max( MIN_WORKBENCH_HEIGHT, window.innerHeight - @@ -139,6 +145,27 @@ export class DevtoolsWorkbench extends Element { direction: Direction.vertical }) + // Player snapshot keeps the recorded viewport's shape, slightly narrowed. + #playerAspectRatio(): string { + const viewport = this.metadata?.viewport + const width = Math.round( + (viewport?.width || 1280) * PLAYER_SNAPSHOT_WIDTH_RATIO + ) + return `${width} / ${viewport?.height || 800}` + } + + // Space left for the snapshot pane once the fixed rows and dock minimum eat theirs. + #playerPaneBudget(): number { + return Math.max( + MIN_WORKBENCH_HEIGHT, + window.innerHeight - + HEADER_HEIGHT - + PLAYER_CONTROLS_HEIGHT - + PLAYER_DOCK_MIN_HEIGHT - + this.#timelinePaneHeight() + ) + } + async #getHorizontalWindow() { await this.updateComplete return this.horizontalResizerWindow as Element @@ -185,21 +212,12 @@ export class DevtoolsWorkbench extends Element { return '' } if (this.playerMode) { - // Player proportions: the snapshot dominates — pane gets everything the - // timeline, controls bar, and a compact dock don't need, clamped in CSS - // so the dock never drops below its minimum inside the viewport. - const fallback = - window.innerHeight - - HEADER_HEIGHT - - PLAYER_CONTROLS_HEIGHT - - this.#timelinePaneHeight() - - PLAYER_DOCK_DEFAULT_HEIGHT - const raw = basisPx(this.#dragVerticalPlayer.getPosition()) ?? fallback - const paneHeight = Math.max(MIN_WORKBENCH_HEIGHT, raw) + // Snapshot pane dominates; the CSS clamp keeps the dock minimum in view. + // Literal getPosition() basis lets adjustPosition sync slider ↔ clamped height. const maxHeight = `calc(100vh - ${ HEADER_HEIGHT + PLAYER_CONTROLS_HEIGHT + PLAYER_DOCK_MIN_HEIGHT }px - ${this.#timelinePaneHeight()}px)` - return `flex:0 0 ${paneHeight}px; height:${paneHeight}px; max-height:${maxHeight}; min-height:0;` + return `flex-grow:0; flex-shrink:0; ${this.#dragVerticalPlayer.getPosition()}; max-height:${maxHeight}; min-height:${MIN_WORKBENCH_HEIGHT}px;` } const raw = basisPx(this.#dragVertical.getPosition()) ?? @@ -344,6 +362,31 @@ export class DevtoolsWorkbench extends Element { ` } + #renderBrowserPane() { + // Player: the boxed host goes transparent and the pane carries the shared + // backdrop, so the aspect box blends instead of showing a gradient seam. + const playerPaneExtra = this.playerMode + ? ` background:${BROWSER_BACKDROP_GRADIENT};` + : '' + return html` +
+ ${this.playerMode + ? html`
+ +
` + : html``} +
+ ` + } + // Full-width playback strip above the workbench row — player mode only. #renderTimelineStrip() { if (!this.playerMode) { @@ -388,12 +431,7 @@ export class DevtoolsWorkbench extends Element {
-
- -
+ ${this.#renderBrowserPane()} ${!this.#toolbarCollapsed ? (this.playerMode ? this.#dragVerticalPlayer diff --git a/packages/app/src/controller/constants.ts b/packages/app/src/controller/constants.ts index 105e74ad..6992ebf4 100644 --- a/packages/app/src/controller/constants.ts +++ b/packages/app/src/controller/constants.ts @@ -14,6 +14,11 @@ export const PLAYER_DOCK_MIN_HEIGHT = 140 export const PLAYER_DOCK_DEFAULT_HEIGHT = 220 /** Controls bar on the tab-header line in player mode (matches h-10 headers). */ export const PLAYER_CONTROLS_HEIGHT = 40 +/** Width factor on the player snapshot's aspect box — trims width, keeps height. */ +export const PLAYER_SNAPSHOT_WIDTH_RATIO = 0.9 +/** Backdrop behind the browser chrome — shared with the snapshot component styles. */ +export const BROWSER_BACKDROP_GRADIENT = + 'radial-gradient(120% 120% at 50% 0%, var(--vscode-editorWidget-background), var(--vscode-editor-background))' /** Fixed app-header height (see header.ts / app.ts `h-[calc(100%-40px)]`). */ export const HEADER_HEIGHT = 40 export const LOG_ICONS: Record = { diff --git a/packages/app/src/controller/keyboard.ts b/packages/app/src/controller/keyboard.ts index 87559253..f94ce52c 100644 --- a/packages/app/src/controller/keyboard.ts +++ b/packages/app/src/controller/keyboard.ts @@ -32,7 +32,7 @@ function isTyping(event: KeyboardEvent): boolean { ) } -function emit(name: string, detail?: unknown): void { +export function emit(name: string, detail?: unknown): void { window.dispatchEvent(new CustomEvent(name, { detail })) } diff --git a/packages/app/src/utils/DragController.ts b/packages/app/src/utils/DragController.ts index 593d452d..d675570f 100644 --- a/packages/app/src/utils/DragController.ts +++ b/packages/app/src/utils/DragController.ts @@ -14,15 +14,22 @@ export enum Direction { type DragControllerHost = HTMLElement & ReactiveControllerHost type AsyncGetElFn = () => Element | Promise +/** Bounds accept getters so panes with a layout-dependent budget clamp live. */ +type Bound = number | (() => number) + interface DragControllerOptions { initialPosition: number direction: Direction localStorageKey?: string - minPosition?: number - maxPosition?: number + minPosition?: Bound + maxPosition?: Bound getContainerEl: AsyncGetElFn } +function resolveBound(bound: Bound | undefined): number | undefined { + return typeof bound === 'function' ? bound() : bound +} + type State = 'dragging' | 'idle' const defaultOptions = { @@ -107,16 +114,18 @@ export class DragController implements ReactiveController { } #setPosition(x: number, y: number) { + const min = resolveBound(this.#options.minPosition) ?? 0 + const max = resolveBound(this.#options.maxPosition) if (this.#options.direction === Direction.horizontal) { - let nx = Math.max(x, this.#options.minPosition || 0) - if (this.#options.maxPosition !== undefined) { - nx = Math.min(nx, this.#options.maxPosition) + let nx = Math.max(x, min) + if (max !== undefined) { + nx = Math.min(nx, max) } this.#x = nx } else { - let ny = Math.max(y, this.#options.minPosition || 0) - if (this.#options.maxPosition !== undefined) { - ny = Math.min(ny, this.#options.maxPosition) + let ny = Math.max(y, min) + if (max !== undefined) { + ny = Math.min(ny, max) } this.#y = ny } diff --git a/packages/app/tests/trace-timeline-utils.test.ts b/packages/app/tests/trace-timeline-utils.test.ts new file mode 100644 index 00000000..e3197c7b --- /dev/null +++ b/packages/app/tests/trace-timeline-utils.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest' +import { + formatTickLabel, + tickStep +} from '../src/components/browser/trace-timeline-utils.js' + +describe('tickStep', () => { + it('picks the smallest step yielding at most the target tick count', () => { + expect(tickStep(10_000)).toBe(1_000) + expect(tickStep(78_460)).toBe(10_000) + expect(tickStep(1_200)).toBe(100) + }) + + it('caps at the largest step for very long traces', () => { + expect(tickStep(3 * 60 * 60 * 1000)).toBe(600_000) + }) +}) + +describe('formatTickLabel', () => { + it('formats sub-second ticks as milliseconds', () => { + expect(formatTickLabel(500)).toBe('500ms') + }) + + it('formats seconds with one decimal', () => { + expect(formatTickLabel(1_000)).toBe('1.0s') + expect(formatTickLabel(3_500)).toBe('3.5s') + }) + + it('formats minutes as m:ss', () => { + expect(formatTickLabel(75_000)).toBe('1:15') + expect(formatTickLabel(60_000)).toBe('1:00') + }) +}) From 56af6567ac3ed97805c331be38b805bd71262b32 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Fri, 3 Jul 2026 17:35:17 +0530 Subject: [PATCH 04/21] refactor(shared,backend,core): derive LogLevel from runtime LOG_LEVELS; tighten comments --- packages/backend/src/trace-reader-constants.ts | 5 ++++- packages/backend/src/trace-reader-utils.ts | 15 +++------------ packages/core/src/trace-console.ts | 12 ++++-------- packages/core/src/trace-snapshots.ts | 6 +----- packages/shared/src/types.ts | 10 +++++++++- 5 files changed, 21 insertions(+), 27 deletions(-) diff --git a/packages/backend/src/trace-reader-constants.ts b/packages/backend/src/trace-reader-constants.ts index 6e738464..0572b51f 100644 --- a/packages/backend/src/trace-reader-constants.ts +++ b/packages/backend/src/trace-reader-constants.ts @@ -1,4 +1,7 @@ -import { ACTION_MAP } from '@wdio/devtools-shared' +import { ACTION_MAP, LOG_LEVELS } from '@wdio/devtools-shared' + +/** Runtime lookup for narrowing foreign trace levels to the shared union. */ +export const LOG_LEVEL_SET: ReadonlySet = new Set(LOG_LEVELS) // Inverse of ACTION_MAP, derived so it can never drift from the forward map. // The forward map is many-to-one (url/navigateTo/get all → Page.navigate); the diff --git a/packages/backend/src/trace-reader-utils.ts b/packages/backend/src/trace-reader-utils.ts index b7162c12..e1f8e3dd 100644 --- a/packages/backend/src/trace-reader-utils.ts +++ b/packages/backend/src/trace-reader-utils.ts @@ -11,6 +11,7 @@ import { type Viewport } from '@wdio/devtools-shared' +import { LOG_LEVEL_SET } from './trace-reader-constants.js' import type { ConsoleEvent, ContextOptionsEvent, @@ -144,22 +145,12 @@ export function harToNetworkRequest( } } -const LOG_LEVELS: ReadonlySet = new Set([ - 'trace', - 'debug', - 'log', - 'info', - 'warn', - 'error' -]) - -// Reverse of the writer's level mapping ('warn' → 'warning'); a foreign -// trace zip can carry levels outside our union, which default to 'log'. +// Reverse level mapping; foreign levels outside our union default to 'log'. function fromTraceLevel(messageType: string): LogLevel { if (messageType === 'warning') { return 'warn' } - return LOG_LEVELS.has(messageType) ? (messageType as LogLevel) : 'log' + return LOG_LEVEL_SET.has(messageType) ? (messageType as LogLevel) : 'log' } export function buildConsoleLogs( diff --git a/packages/core/src/trace-console.ts b/packages/core/src/trace-console.ts index f9db30fd..78deb7aa 100644 --- a/packages/core/src/trace-console.ts +++ b/packages/core/src/trace-console.ts @@ -19,17 +19,14 @@ export interface StdioEvent { type: 'stdout' | 'stderr' timestamp: number text?: string - /** Extension field: preserves the test-vs-terminal origin the standard - * stdio vocabulary can't express. Foreign viewers ignore it. */ + /** Extension field: test-vs-terminal origin; foreign viewers ignore it. */ source?: Extract } -// Keeps a pathological run (console.log in a tight loop) from producing a -// trace.trace the viewer can't open. +// Caps pathological runs (console.log in a loop) so the trace stays openable. const MAX_CONSOLE_EVENTS = 10_000 -/** The trace format's console vocabulary uses 'warning'; 'trace' has no - * equivalent — 'debug' is the nearest severity. */ +/** Trace vocabulary uses 'warning'; 'trace' maps to the nearest severity, 'debug'. */ function toTraceLevel(level: ConsoleLog['type']): string { if (level === 'warn') { return 'warning' @@ -69,8 +66,7 @@ export function buildConsoleEvents( messageType: toTraceLevel(log.type), text, args: log.args.map((arg) => ({ preview: previewArg(arg), value: arg })), - // Location isn't captured at the console patch site; the event - // shape requires the field, so it ships zeroed. + // Location isn't captured at the patch site; the required field ships zeroed. location: { url: '', lineNumber: 0, columnNumber: 0 } } satisfies ConsoleEvent } diff --git a/packages/core/src/trace-snapshots.ts b/packages/core/src/trace-snapshots.ts index 0f139bdb..9e98704d 100644 --- a/packages/core/src/trace-snapshots.ts +++ b/packages/core/src/trace-snapshots.ts @@ -69,11 +69,7 @@ function frameForSnapshot( return frame } -/** - * Full filmstrip for the trace: the first snapshot is re-anchored to t=0 so - * viewers show the page state before any interaction; the rest keep their - * wall-time offsets. - */ +/** Filmstrip events; the first snapshot is re-anchored to t=0 for pre-interaction state. */ export function buildFilmstripEvents( snapshots: ActionSnapshot[], pageId: string, diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 23ad3c7f..2f209aed 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -4,7 +4,15 @@ // these shapes. The backend stores and forwards them. The app consumes them. // See ARCHITECTURE.md §2 and CLAUDE.md §2.1. -export type LogLevel = 'trace' | 'debug' | 'log' | 'info' | 'warn' | 'error' +export const LOG_LEVELS = [ + 'trace', + 'debug', + 'log', + 'info', + 'warn', + 'error' +] as const +export type LogLevel = (typeof LOG_LEVELS)[number] /** Where a captured ConsoleLog entry originated. */ export type LogSource = 'browser' | 'test' | 'terminal' From fe190388f4fa334ba842da3504eaf89f259e728a Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Fri, 3 Jul 2026 17:35:44 +0530 Subject: [PATCH 05/21] fix(backend): skip tracing-group markers when reconstructing player commands --- packages/backend/src/trace-reader.ts | 5 +++++ packages/backend/tests/trace-reader.test.ts | 15 +++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/packages/backend/src/trace-reader.ts b/packages/backend/src/trace-reader.ts index 6146998f..3c6b84ea 100644 --- a/packages/backend/src/trace-reader.ts +++ b/packages/backend/src/trace-reader.ts @@ -103,6 +103,11 @@ function buildCommands( const commands: CommandLog[] = [] let maxOffset = 0 for (const [callId, before] of events.befores) { + // Group markers are structure, not actions — as command rows their end + // timestamp ties with the last action and steals the active highlight. + if (before.class === 'Tracing') { + continue + } const after = events.afters.get(callId) const endOffset = after?.endTime ?? before.startTime maxOffset = Math.max(maxOffset, endOffset) diff --git a/packages/backend/tests/trace-reader.test.ts b/packages/backend/tests/trace-reader.test.ts index 8df8f9ff..7d1f059e 100644 --- a/packages/backend/tests/trace-reader.test.ts +++ b/packages/backend/tests/trace-reader.test.ts @@ -36,6 +36,14 @@ function fixtureZip(): Uint8Array { params: { selector: '#name', value: 'vishnu' } }, { type: 'after', callId: 'call@2', endTime: 160 }, + { + type: 'before', + callId: 'call@0', + startTime: 0, + class: 'Tracing', + method: 'tracingGroup', + params: { name: 'my test' } + }, { type: 'before', callId: 'call@3', @@ -50,6 +58,7 @@ function fixtureZip(): Uint8Array { endTime: 260, error: { message: 'boom' } }, + { type: 'after', callId: 'call@0', endTime: 260 }, { type: 'screencast-frame', pageId: 'page@abcd1234', @@ -175,6 +184,12 @@ describe('parseTraceZip', () => { expect(trace.sources).toEqual({}) }) + it('skips tracing group markers so the last command stays the last action', () => { + const { trace } = parseTraceZip(fixtureZip()) + expect(trace.commands.some((c) => c.command === 'tracingGroup')).toBe(false) + expect(trace.commands[trace.commands.length - 1].command).toBe('click') + }) + it('reconstructs console logs from console and stdio events', () => { const { trace } = parseTraceZip(fixtureZip()) expect(trace.consoleLogs).toEqual([ From c7f94b46a33e16ab23baf7377edea5b8ded1f74f Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Fri, 3 Jul 2026 19:23:03 +0530 Subject: [PATCH 06/21] feat(shared,backend,app): nested action tree with failure rollup in the trace player --- examples/wdio/wdio.conf.ts | 2 +- .../src/components/workbench/action-tree.ts | 72 +++ .../workbench/actionItems/command.ts | 9 +- .../workbench/actionItems/duration.ts | 16 +- .../components/workbench/actionItems/group.ts | 69 +++ .../components/workbench/actionItems/item.ts | 17 + .../app/src/components/workbench/actions.ts | 106 ++++- .../src/components/workbench/call-source.ts | 45 ++ .../app/src/components/workbench/source.ts | 66 ++- .../src/components/workbench/source/styles.ts | 13 + packages/app/src/controller/DataManager.ts | 92 ++-- packages/app/src/controller/context.ts | 7 + packages/app/tests/action-tree.test.ts | 95 ++++ packages/app/tests/call-source.test.ts | 93 +++- packages/app/tests/duration.test.ts | 9 + .../backend/src/trace-reader-constants.ts | 12 + packages/backend/src/trace-reader-groups.ts | 165 +++++++ packages/backend/src/trace-reader-types.ts | 21 +- packages/backend/src/trace-reader-utils.ts | 99 +++- packages/backend/src/trace-reader.ts | 258 ++++++++--- packages/backend/tests/trace-reader.test.ts | 435 +++++++++++++++++- packages/core/src/index.ts | 4 + packages/core/src/sha1.ts | 6 + packages/core/src/trace-action-events.ts | 195 ++++++++ packages/core/src/trace-exporter.ts | 186 ++------ packages/core/src/trace-frame-snapshots.ts | 154 +++++++ packages/core/src/trace-sources.ts | 65 +++ packages/core/tests/trace-exporter.test.ts | 181 +++++++- .../core/tests/trace-frame-snapshots.test.ts | 180 ++++++++ packages/core/tests/trace-sources.test.ts | 71 +++ packages/shared/src/trace-player.ts | 22 + 31 files changed, 2458 insertions(+), 307 deletions(-) create mode 100644 packages/app/src/components/workbench/action-tree.ts create mode 100644 packages/app/src/components/workbench/actionItems/group.ts create mode 100644 packages/app/tests/action-tree.test.ts create mode 100644 packages/backend/src/trace-reader-groups.ts create mode 100644 packages/core/src/sha1.ts create mode 100644 packages/core/src/trace-action-events.ts create mode 100644 packages/core/src/trace-frame-snapshots.ts create mode 100644 packages/core/src/trace-sources.ts create mode 100644 packages/core/tests/trace-frame-snapshots.test.ts create mode 100644 packages/core/tests/trace-sources.test.ts diff --git a/examples/wdio/wdio.conf.ts b/examples/wdio/wdio.conf.ts index 0f7b3470..b6305819 100644 --- a/examples/wdio/wdio.conf.ts +++ b/examples/wdio/wdio.conf.ts @@ -131,7 +131,7 @@ export const config: Options.Testrunner = { [ 'devtools', { - mode: 'live' as const, + mode: 'trace' as const, screencast: { enabled: true, pollIntervalMs: 200 } } ] diff --git a/packages/app/src/components/workbench/action-tree.ts b/packages/app/src/components/workbench/action-tree.ts new file mode 100644 index 00000000..723f83af --- /dev/null +++ b/packages/app/src/components/workbench/action-tree.ts @@ -0,0 +1,72 @@ +// Pure helpers behind the player's collapsible action tree: flattening the +// group tree into render rows and deciding which groups start expanded. + +import type { + TraceActionChild, + TraceActionGroupNode +} from '@wdio/devtools-shared' + +export interface GroupRow { + kind: 'group' + group: TraceActionGroupNode + depth: number + expanded: boolean +} + +export interface CommandRow { + kind: 'command' + commandIndex: number + depth: number +} + +export type ActionTreeRow = GroupRow | CommandRow + +/** Command indices anywhere under a group, nested groups included. */ +export function collectCommandIndices(group: TraceActionGroupNode): number[] { + const indices: number[] = [] + for (const child of group.children) { + if ('group' in child) { + indices.push(...collectCommandIndices(child.group)) + } else { + indices.push(child.commandIndex) + } + } + return indices +} + +/** Groups open by default when failed or when holding the active command. */ +export function defaultExpanded( + group: TraceActionGroupNode, + activeCommandIndex?: number +): boolean { + if (group.failed) { + return true + } + return ( + activeCommandIndex !== undefined && + collectCommandIndices(group).includes(activeCommandIndex) + ) +} + +/** Flatten the tree into render rows, descending only into expanded groups. */ +export function flattenActionTree( + children: TraceActionChild[], + isExpanded: (group: TraceActionGroupNode) => boolean, + depth = 0 +): ActionTreeRow[] { + const rows: ActionTreeRow[] = [] + for (const child of children) { + if ('group' in child) { + const expanded = isExpanded(child.group) + rows.push({ kind: 'group', group: child.group, depth, expanded }) + if (expanded) { + rows.push( + ...flattenActionTree(child.group.children, isExpanded, depth + 1) + ) + } + } else { + rows.push({ kind: 'command', commandIndex: child.commandIndex, depth }) + } + } + return rows +} diff --git a/packages/app/src/components/workbench/actionItems/command.ts b/packages/app/src/components/workbench/actionItems/command.ts index a587369e..1a596f56 100644 --- a/packages/app/src/components/workbench/actionItems/command.ts +++ b/packages/app/src/components/workbench/actionItems/command.ts @@ -33,6 +33,10 @@ export class CommandItem extends ActionItem { @property({ type: Object, attribute: true }) entry?: CommandLog + willUpdate(): void { + this.failed = Boolean(this.entry?.error) + } + #highlightLine() { const event = new CustomEvent('show-command', { detail: { @@ -85,7 +89,10 @@ export class CommandItem extends ActionItem { @click="${() => this.#highlightLine()}" > ${this.iconChip(this.#renderIcon(entry.command))} - ${entry.title ?? entry.command} ${this.renderTime()} diff --git a/packages/app/src/components/workbench/actionItems/duration.ts b/packages/app/src/components/workbench/actionItems/duration.ts index db4b7fbc..5d2df61f 100644 --- a/packages/app/src/components/workbench/actionItems/duration.ts +++ b/packages/app/src/components/workbench/actionItems/duration.ts @@ -3,17 +3,19 @@ export type DurationHeat = 'fast' | 'mid' | 'slow' const ONE_SECOND = 1000 const ONE_MINUTE = ONE_SECOND * 60 -/** Human-readable duration: `ms` under a second, `s` under a minute, `m s` above. */ +/** Human-readable duration: `ms` under a second, `s` under a minute, `m s` + * above. Rounds first — reconstructed traces carry fractional-ms clocks. */ export function formatDuration(ms: number): string { - if (ms > ONE_MINUTE) { - const minutes = Math.floor(ms / ONE_MINUTE) - const seconds = Math.floor((ms - minutes * ONE_MINUTE) / ONE_SECOND) + const rounded = Math.round(ms) + if (rounded > ONE_MINUTE) { + const minutes = Math.floor(rounded / ONE_MINUTE) + const seconds = Math.floor((rounded - minutes * ONE_MINUTE) / ONE_SECOND) return `${minutes}m ${seconds}s` } - if (ms > ONE_SECOND) { - return `${(ms / ONE_SECOND).toFixed(2)}s` + if (rounded > ONE_SECOND) { + return `${(rounded / ONE_SECOND).toFixed(2)}s` } - return `${ms}ms` + return `${rounded}ms` } /** Bucket a step duration so slow steps stand out: fast < 500ms ≤ mid < 2s ≤ slow. */ diff --git a/packages/app/src/components/workbench/actionItems/group.ts b/packages/app/src/components/workbench/actionItems/group.ts new file mode 100644 index 00000000..10780076 --- /dev/null +++ b/packages/app/src/components/workbench/actionItems/group.ts @@ -0,0 +1,69 @@ +import { html } from 'lit' +import { customElement, property } from 'lit/decorators.js' + +import type { TraceActionGroupNode } from '@wdio/devtools-shared' + +import { ActionItem } from './item.js' +import '~icons/mdi/chevron-right.js' + +const SOURCE_COMPONENT = 'wdio-devtools-group-item' + +/** Collapsible step/group row of the trace player's action tree. */ +@customElement(SOURCE_COMPONENT) +export class GroupItem extends ActionItem { + @property({ type: Object }) + group?: TraceActionGroupNode + + /** Whether the group's children are currently rendered below it. */ + @property({ type: Boolean, reflect: true }) + expanded = false + + willUpdate(): void { + this.failed = Boolean(this.group?.failed) + this.duration = this.group + ? this.group.endTime - this.group.startTime + : undefined + } + + #toggle() { + this.dispatchEvent( + new CustomEvent('group-toggle', { + detail: { callId: this.group?.callId, expanded: this.expanded }, + bubbles: true, + composed: true + }) + ) + } + + render() { + if (!this.group) { + return + } + return html` + + ` + } +} + +declare global { + interface HTMLElementTagNameMap { + [SOURCE_COMPONENT]: GroupItem + } +} diff --git a/packages/app/src/components/workbench/actionItems/item.ts b/packages/app/src/components/workbench/actionItems/item.ts index 931a2299..945a79db 100644 --- a/packages/app/src/components/workbench/actionItems/item.ts +++ b/packages/app/src/components/workbench/actionItems/item.ts @@ -29,6 +29,10 @@ export class ActionItem extends Element { @property({ type: Boolean, reflect: true }) active = false + /** Whether this row's action errored — drives the red row treatment. */ + @property({ type: Boolean, reflect: true }) + failed = false + static styles = [ ...Element.styles, css` @@ -67,6 +71,19 @@ export class ActionItem extends Element { :host([active]) .ic { border-color: var(--accent); } + :host([failed]) button { + background: color-mix( + in srgb, + var(--vscode-charts-red) 8%, + transparent + ); + box-shadow: inset 2px 0 0 var(--vscode-charts-red); + } + :host([failed][active]) button { + box-shadow: + inset 2px 0 0 var(--vscode-charts-red), + inset 0 0 0 1px var(--vscode-panel-border); + } ` ] diff --git a/packages/app/src/components/workbench/actions.ts b/packages/app/src/components/workbench/actions.ts index 86f4e856..842f4f56 100644 --- a/packages/app/src/components/workbench/actions.ts +++ b/packages/app/src/components/workbench/actions.ts @@ -1,21 +1,38 @@ import { Element } from '@core/element' -import { html, css } from 'lit' +import { html, css, nothing } from 'lit' import { customElement, state } from 'lit/decorators.js' import { consume } from '@lit/context' -import type { CommandLog } from '@wdio/devtools-shared' -import { mutationContext, commandContext } from '../../controller/context.js' +import type { + CommandLog, + TraceActionChild, + TraceActionGroupNode +} from '@wdio/devtools-shared' +import { + mutationContext, + commandContext, + actionGroupsContext +} from '../../controller/context.js' import '../placeholder.js' import './actionItems/command.js' +import './actionItems/group.js' import './actionItems/mutation.js' import { stepDurations } from './actionItems/duration.js' import { activeTimestampAt } from './active-entry.js' +import { + defaultExpanded, + flattenActionTree, + type ActionTreeRow +} from './action-tree.js' type TimelineEntry = TraceMutation | CommandLog const SOURCE_COMPONENT = 'wdio-devtools-actions' +/** Horizontal shift per tree depth level in the player's action tree. */ +const TREE_INDENT_PX = 14 + @customElement(SOURCE_COMPONENT) export class DevtoolsActions extends Element { static styles = [ @@ -47,6 +64,11 @@ export class DevtoolsActions extends Element { background: var(--vscode-panel-border); pointer-events: none; } + + /* Tree mode indents rows, so the straight rail no longer lines up. */ + .timeline.tree::before { + display: none; + } ` ] @@ -56,12 +78,32 @@ export class DevtoolsActions extends Element { @consume({ context: commandContext, subscribe: true }) commands: CommandLog[] = [] + @consume({ context: actionGroupsContext, subscribe: true }) + groups?: TraceActionChild[] + // The selected timeline row, tracked by object reference — timestamps aren't // unique (commands logged in the same millisecond would all match), so // reference identity is what highlights exactly one row. @state() private activeEntry?: TimelineEntry + // User chevron toggles, by group callId; unset groups follow the default + // (failed or containing the active command → open). + @state() + private expandOverrides: ReadonlyMap = new Map() + + #onGroupToggle = (event: Event) => { + const { callId, expanded } = ( + event as CustomEvent<{ callId?: string; expanded: boolean }> + ).detail + if (!callId) { + return + } + const next = new Map(this.expandOverrides) + next.set(callId, !expanded) + this.expandOverrides = next + } + #onShowCommand = (event: Event) => { const command = (event as CustomEvent<{ command?: CommandLog }>).detail ?.command @@ -146,7 +188,65 @@ export class DevtoolsActions extends Element { } } + // Player tree mode: group rows expand/collapse; leaf rows are the same + // command items as the flat list, indented under their group. + #renderTree(rootChildren: TraceActionChild[]) { + const commands = this.commands || [] + const activeIndex = + this.activeEntry && 'command' in this.activeEntry + ? commands.indexOf(this.activeEntry) + : -1 + const isExpanded = (group: TraceActionGroupNode) => + this.expandOverrides.get(group.callId) ?? + defaultExpanded(group, activeIndex >= 0 ? activeIndex : undefined) + const rows = flattenActionTree(rootChildren, isExpanded) + const baseline = commands[0]?.timestamp ?? 0 + const gaps = stepDurations(commands.map((command) => command.timestamp)) + return html`
+ ${rows.map((row) => this.#renderTreeRow(row, commands, baseline, gaps))} +
` + } + + #renderTreeRow( + row: ActionTreeRow, + commands: CommandLog[], + baseline: number, + gaps: Array + ) { + const indent = `padding-left: ${row.depth * TREE_INDENT_PX}px` + if (row.kind === 'group') { + return html` + + ` + } + const entry = commands[row.commandIndex] + if (!entry) { + return nothing + } + // Reconstructed zips carry the real invocation span; gap is the fallback. + const duration = + entry.startTime !== undefined + ? entry.timestamp - entry.startTime + : gaps[row.commandIndex] + return html` + + ` + } + render() { + if (this.groups?.length) { + return this.#renderTree(this.groups) + } const entries = this.#sortedEntries() if (!entries.length) { diff --git a/packages/app/src/components/workbench/call-source.ts b/packages/app/src/components/workbench/call-source.ts index 9349b184..3fa2d5ca 100644 --- a/packages/app/src/components/workbench/call-source.ts +++ b/packages/app/src/components/workbench/call-source.ts @@ -23,6 +23,51 @@ export function parseCallSource( } } +/** Path with any trailing `:line` / `:line:column` suffix stripped — some + * recorded traces glue the line onto the file, so lookups compare clean paths. */ +export function normalizeSourcePath(path: string): string { + const match = path.match(/:\d+:\d+$/) || path.match(/:\d+$/) + return match && match.index ? path.slice(0, match.index) : path +} + +/** Key in `sources` holding `file`'s content — exact match first, then a + * normalized-path match so suffixed keys and clean queries still pair up. */ +export function resolveSourceFile( + sources: Record, + file: string +): string | undefined { + if (file in sources) { + return file + } + const target = normalizeSourcePath(file) + return Object.keys(sources).find((key) => normalizeSourcePath(key) === target) +} + +/** Normalized display list: every captured source file plus any file referenced + * by a command call source, deduplicated by clean path. */ +export function listSourceFiles( + sources: Record, + callSources: (string | undefined)[] +): string[] { + const files: string[] = [] + const seen = new Set() + const add = (path: string) => { + const normalized = normalizeSourcePath(path) + if (!seen.has(normalized)) { + seen.add(normalized) + files.push(normalized) + } + } + Object.keys(sources).forEach(add) + for (const callSource of callSources) { + const parsed = callSource ? parseCallSource(callSource) : null + if (parsed) { + add(parsed.file) + } + } + return files +} + /** Last path segment, handling both POSIX (`/`) and Windows (`\`) separators. */ export function fileBasename(path: string): string { const segments = path.split(/[/\\]/) diff --git a/packages/app/src/components/workbench/source.ts b/packages/app/src/components/workbench/source.ts index 81a12d0b..fcdb6fe5 100644 --- a/packages/app/src/components/workbench/source.ts +++ b/packages/app/src/components/workbench/source.ts @@ -13,7 +13,14 @@ import type { CommandLog } from '@wdio/devtools-shared' import { sourceContext, commandContext } from '../../controller/context.js' import { commandCategory, type ActionCategory } from './actionItems/category.js' -import { parseCallSource, fileBasename, pathSegments } from './call-source.js' +import { + parseCallSource, + fileBasename, + pathSegments, + normalizeSourcePath, + resolveSourceFile, + listSourceFiles +} from './call-source.js' import { sourceStyles } from './source/styles.js' import '../placeholder.js' @@ -95,12 +102,26 @@ export class DevtoolsSource extends Element { return document.body.classList.contains('dark') } + /** Captured files plus files referenced by command call sources (clean paths). */ + get #fileList(): string[] { + return listSourceFiles( + this.sources || {}, + (this.commands || []).map((c) => c.callSource) + ) + } + /** File to show: an explicit selection/call-site, else the first available. */ get #effectiveFile(): string | undefined { - if (this.activeFile && this.sources?.[this.activeFile]) { + const files = this.#fileList + if (this.activeFile && files.includes(this.activeFile)) { return this.activeFile } - return Object.keys(this.sources || {})[0] + return files[0] + } + + #contentFor(file: string): string | undefined { + const key = resolveSourceFile(this.sources || {}, file) + return key !== undefined ? this.sources[key] : undefined } connectedCallback(): void { @@ -145,8 +166,7 @@ export class DevtoolsSource extends Element { super.disconnectedCallback() window.removeEventListener('app-source-highlight', this.#onHighlight) window.removeEventListener('app-source-track', this.#onTrack) - this.#editorView?.destroy() - this.#editorView = undefined + this.#unmountEditor() this.#tabObserver?.disconnect() this.#tabObserver = undefined this.#themeObserver?.disconnect() @@ -158,6 +178,10 @@ export class DevtoolsSource extends Element { if (!target) { return } + if (this.#contentFor(target) === undefined) { + this.#unmountEditor() + return + } this.#mountEditor(target) this.#refreshCallSite() } @@ -167,10 +191,11 @@ export class DevtoolsSource extends Element { if (!parsed) { return } - this.activeFile = parsed.file - this.callSiteFile = parsed.file + const file = normalizeSourcePath(parsed.file) + this.activeFile = file + this.callSiteFile = file this.callSiteLine = parsed.line - const cmd = this.#commandAt(parsed.file, parsed.line) + const cmd = this.#commandAt(file, parsed.line) this.callSiteCommand = cmd?.command this.callSiteCategory = cmd ? commandCategory(cmd.command) : 'other' if (activateTab) { @@ -184,7 +209,11 @@ export class DevtoolsSource extends Element { return false } const parsed = parseCallSource(c.callSource) - return parsed?.file === file && parsed.line === line + return ( + !!parsed && + normalizeSourcePath(parsed.file) === file && + parsed.line === line + ) }) } @@ -192,9 +221,15 @@ export class DevtoolsSource extends Element { this.activeFile = file } + #unmountEditor() { + this.#editorView?.destroy() + this.#editorView = undefined + this.#mountedFile = undefined + } + #mountEditor(filePath: string) { - const source = this.sources?.[filePath] - if (!source) { + const source = this.#contentFor(filePath) + if (source === undefined) { return } const container = @@ -256,7 +291,7 @@ export class DevtoolsSource extends Element { } #renderFileTabs(active: string) { - return Object.keys(this.sources || {}).map( + return this.#fileList.map( (file) => html` ` } @@ -197,6 +214,9 @@ export class DevtoolsTab extends Element { @property({ type: Number }) badge?: number + @property({ type: String }) + badgeTone?: BadgeTone + static styles = [ ...Element.styles, css` diff --git a/packages/app/src/components/workbench.ts b/packages/app/src/components/workbench.ts index 0bb9f243..a97b20c9 100644 --- a/packages/app/src/components/workbench.ts +++ b/packages/app/src/components/workbench.ts @@ -8,9 +8,17 @@ import { consoleLogContext, metadataContext, networkRequestContext, - baselineContext + baselineContext, + commandContext, + suiteContext } from '../controller/context.js' -import type { Metadata, PreservedAttempt } from '@wdio/devtools-shared' +import type { + CommandLog, + Metadata, + PreservedAttempt +} from '@wdio/devtools-shared' +import type { SuiteStatsFragment } from '../controller/types.js' +import { collectErrors } from './workbench/errors/collect.js' import '~icons/mdi/arrow-collapse-down.js' import '~icons/mdi/arrow-collapse-up.js' @@ -24,6 +32,7 @@ import './workbench/logs.js' import './workbench/console.js' import './workbench/metadata.js' import './workbench/network.js' +import './workbench/errors.js' import './workbench/compare.js' import './browser/snapshot.js' import './browser/trace-timeline.js' @@ -73,6 +82,14 @@ export class DevtoolsWorkbench extends Element { @state() baselines: Map | undefined = undefined + @consume({ context: commandContext, subscribe: true }) + @state() + commands: CommandLog[] | undefined = undefined + + @consume({ context: suiteContext, subscribe: true }) + @state() + suites: Record[] | undefined = undefined + @consume({ context: metadataContext, subscribe: true }) @state() metadata: Metadata | undefined = undefined @@ -317,6 +334,44 @@ export class DevtoolsWorkbench extends Element { ` } + #errorCount(): number { + return collectErrors(this.commands, this.suites).length + } + + // Dock tab list — extracted so #renderWorkbenchTabs stays under the size cap. + #renderDockTabItems() { + return html` + + + + + + + + + + + + + + + + ${this.#renderCompareTabIfAvailable()} + ` + } + #renderWorkbenchTabs() { return html` - - - - - - - - - - - - - ${this.#renderCompareTabIfAvailable()} + ${this.#renderDockTabItems()}
+ ` + } + + render() { + const errors = collectErrors(this.commands, this.suites) + if (!errors.length) { + return html` +
+
+
No errors
+
+ ` + } + return html`${errors.map((error) => this.#renderEntry(error))}` + } +} + +declare global { + interface HTMLElementTagNameMap { + [COMPONENT]: DevtoolsErrors + } +} diff --git a/packages/app/src/components/workbench/errors/collect.ts b/packages/app/src/components/workbench/errors/collect.ts new file mode 100644 index 00000000..ca7429e1 --- /dev/null +++ b/packages/app/src/components/workbench/errors/collect.ts @@ -0,0 +1,232 @@ +/** + * Pure error-collection for the workbench Errors tab. Merges failed commands + * (from `commandContext`) with failed tests (from `suiteContext`) into a single + * ordered, de-duplicated list. Kept framework-free and side-effect-free so the + * tab component only has to render what this returns. + */ + +import type { CommandLog } from '@wdio/devtools-shared' +import type { + SuiteStatsFragment, + TestStatsFragment +} from '../../../controller/types.js' +import { stripAnsi } from '../console-filter.js' + +/** One row in the Errors tab. */ +export interface CollectedError { + /** Failing action/step or test title — the row heading. */ + title: string + /** Error message shown message-first, monospace. */ + message: string + /** Optional stack, rendered under the message when present. */ + stack?: string + /** `file:line:col` source anchor for the "open source" link. */ + callSource?: string + /** The failing command, when the error came from one — lets the tab dispatch + * `show-command` to select and scroll to that action. */ + command?: CommandLog + /** Command timestamp; drives ordering and the `show-command` elapsed time. */ + timestamp?: number + /** Assertion expected value, rendered as a labelled row when present. */ + expected?: string + /** Assertion received/actual value, rendered as a labelled row when present. */ + actual?: string +} + +const ASSERTION_COMMAND_RE = /^(expect|assert|verify)\./ + +/** Display string for an expected/actual value that may already be serialized. */ +function displayValue(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined + } + if (typeof value === 'string') { + return value + } + try { + return JSON.stringify(value) + } catch { + return String(value) + } +} + +/** Assertion commands carry `[actual, expected]` in args (see the exporter's + * Assert params + synthesizeExpectFailure). */ +function assertionValues(command: CommandLog): { + actual?: string + expected?: string +} { + if (!ASSERTION_COMMAND_RE.test(command.command) || command.args.length < 2) { + return {} + } + return { + actual: displayValue(command.args[0]), + expected: displayValue(command.args[1]) + } +} + +interface ReadableError { + message?: string + name?: string + stack?: string + expected?: unknown + actual?: unknown +} + +/** Split trailing `at …` stack-frame lines off the message body. */ +function splitStack(clean: string): { body: string; stack?: string } { + const lines = clean.split('\n') + const idx = lines.findIndex((line) => /^\s*at\s/.test(line)) + if (idx === -1) { + return { body: clean.trimEnd() } + } + return { + body: lines.slice(0, idx).join('\n').trimEnd(), + stack: lines.slice(idx).join('\n').trim() + } +} + +/** Trim each line and drop blanks — assertion libraries indent continuation + * lines, which would otherwise show as ragged whitespace. */ +function dedent(text: string): string { + return text + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .join('\n') +} + +/** Pull `Expected:` / `Received:` values out of a matcher body and return the + * remaining headline. The labels may be indented (expect-webdriverio pads + * them), and `Received:` can span several lines up to the end of the body. */ +function extractDiff(body: string): { + headline: string + expected?: string + actual?: string +} { + const expected = body.match(/^[ \t]*Expected:[ \t]*(.*)$/m)?.[1]?.trim() + const receivedAt = body.search(/^[ \t]*Received:/m) + let actual: string | undefined + let headline = body + if (receivedAt !== -1) { + actual = dedent( + body.slice(receivedAt).replace(/^[ \t]*Received:[ \t]*/, '') + ) + headline = body.slice(0, receivedAt) + } + if (expected !== undefined) { + headline = headline.replace(/^[ \t]*Expected:[ \t]*.*$/m, '') + } + return { headline: dedent(headline), expected, actual } +} + +/** Clean, structured view of any error-ish value: ANSI stripped, stack split + * off the message, and assertion Expected/Received pulled into fields. */ +function readError(error: unknown): + | { + message: string + stack?: string + expected?: string + actual?: string + } + | undefined { + if (!error || typeof error !== 'object') { + return undefined + } + const e = error as ReadableError + const raw = e.message?.trim() || e.name?.trim() || '' + if (!raw && !e.stack) { + return undefined + } + const { body, stack } = splitStack(stripAnsi(raw)) + const diff = extractDiff(body) + return { + message: diff.headline || 'Error', + stack: e.stack ? stripAnsi(e.stack) : stack, + expected: diff.expected ?? displayValue(e.expected), + actual: diff.actual ?? displayValue(e.actual) + } +} + +/** Failed leaf tests across every suite map, deduped by uid (last wins, matching + * the sidebar's root-suite dedup so we read the freshest fragment). */ +function collectFailedTests( + suites: Record[] | undefined +): TestStatsFragment[] { + const byUid = new Map() + const visit = (suite: SuiteStatsFragment) => { + for (const test of suite.tests ?? []) { + if (test.state === 'failed') { + byUid.set(test.uid, test) + } + } + for (const child of suite.suites ?? []) { + visit(child) + } + } + for (const map of suites ?? []) { + for (const suite of Object.values(map)) { + visit(suite) + } + } + return [...byUid.values()] +} + +function commandErrors(commands: CommandLog[] | undefined): CollectedError[] { + return (commands ?? []) + .flatMap((command) => { + const read = readError(command.error) + if (!read) { + return [] + } + const values = assertionValues(command) + return [ + { + title: command.title ?? command.command, + message: read.message, + stack: read.stack, + callSource: command.callSource, + command, + timestamp: command.timestamp, + expected: values.expected ?? read.expected, + actual: values.actual ?? read.actual + } + ] + }) + .sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0)) +} + +/** + * Build the Errors-tab list from the live/player contexts. + * + * Command failures come first (time-ordered) because they carry the clickable + * action; a failed test that only echoes a command's message is dropped so the + * same failure isn't listed twice (e.g. a Cucumber `Then` fails as both the + * assertion command and the scenario). + */ +export function collectErrors( + commands: CommandLog[] | undefined, + suites: Record[] | undefined +): CollectedError[] { + const fromCommands = commandErrors(commands) + const seenMessages = new Set(fromCommands.map((e) => e.message)) + + const fromTests = collectFailedTests(suites).flatMap((test) => { + const read = readError(test.error ?? test.errors?.[0]) + if (!read || seenMessages.has(read.message)) { + return [] + } + return [ + { + title: test.fullTitle || test.title || test.uid, + message: read.message, + stack: read.stack, + callSource: test.callSource, + expected: read.expected, + actual: read.actual + } + ] + }) + + return [...fromCommands, ...fromTests] +} diff --git a/packages/app/src/components/workbench/source.ts b/packages/app/src/components/workbench/source.ts index fcdb6fe5..5b336bda 100644 --- a/packages/app/src/components/workbench/source.ts +++ b/packages/app/src/components/workbench/source.ts @@ -110,13 +110,11 @@ export class DevtoolsSource extends Element { ) } - /** File to show: an explicit selection/call-site, else the first available. */ + /** File to show: an explicit selection/call-site wins even when it wasn't + * captured as a source (the toolbar then shows a not-captured state instead + * of silently falling back to a different file), else the first available. */ get #effectiveFile(): string | undefined { - const files = this.#fileList - if (this.activeFile && files.includes(this.activeFile)) { - return this.activeFile - } - return files[0] + return this.activeFile ?? this.#fileList[0] } #contentFor(file: string): string | undefined { From dd4bc5bbcbf2bc71fe8d71fc6d5e559616b919ce Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Wed, 8 Jul 2026 10:57:11 +0530 Subject: [PATCH 15/21] fix(service,nightwatch): distinct rerun-stable uids for Cucumber steps and outline rows --- .../src/helpers/cucumberScenarioBuilder.ts | 12 +++- packages/service/src/reporter.ts | 45 ++++++++++-- packages/service/tests/reporter.test.ts | 72 +++++++++++++++++++ 3 files changed, 120 insertions(+), 9 deletions(-) diff --git a/packages/nightwatch-devtools/src/helpers/cucumberScenarioBuilder.ts b/packages/nightwatch-devtools/src/helpers/cucumberScenarioBuilder.ts index 9ea426e9..54ab1da3 100644 --- a/packages/nightwatch-devtools/src/helpers/cucumberScenarioBuilder.ts +++ b/packages/nightwatch-devtools/src/helpers/cucumberScenarioBuilder.ts @@ -57,7 +57,9 @@ function buildScenarioStepTest( ? `${featureAbsPath}:${stepLines[i]}` : undefined return { - uid: deterministicUid(featureUri, `step:${scenarioName}:${step.text}`), + // Scope by the scenario uid (which carries the scenario line) so identical + // step text in sibling scenarios and outline example rows stays distinct. + uid: deterministicUid(featureUri, `step:${scenarioUid}:${step.text}`), cid: DEFAULTS.CID, title: stepLabel, fullTitle: `${scenarioName} ${stepLabel}`, @@ -87,8 +89,12 @@ export function buildCucumberScenarioSuite( parentFeatureSuiteUid } = input // deterministicUid (no counter) so the SAME scenario gets the SAME uid - // across retries — that's what makes retry-coalescing work upstream. - const scenarioUid = deterministicUid(featureUri, `scenario:${scenarioName}`) + // across retries — that's what makes retry-coalescing work upstream. The + // scenario line disambiguates outline example rows that share a name. + const scenarioUid = deterministicUid( + featureUri, + `scenario:${scenarioName}:${scenarioLine}` + ) const scenarioSuite: SuiteStats = { uid: scenarioUid, cid: DEFAULTS.CID, diff --git a/packages/service/src/reporter.ts b/packages/service/src/reporter.ts index ad792131..801a86ad 100644 --- a/packages/service/src/reporter.ts +++ b/packages/service/src/reporter.ts @@ -26,8 +26,13 @@ function isScenario(item: SuiteStats | TestStats): boolean { // Generate stable UID for a WDIO suite/test stats object. Handles WDIO's // Cucumber-specific shapes (scenarios with featureFile/featureLine, or with // numeric uid + example-row fallback), then delegates the Mocha/Jasmine path -// to core's generateStableUid. -function generateStableUid(item: SuiteStats | TestStats): string { +// to core's generateStableUid. `parentScope` (the owning scenario's stable +// uid) disambiguates Cucumber steps so identical step text in sibling +// scenarios yields distinct, rerun-stable uids. +function generateStableUid( + item: SuiteStats | TestStats, + parentScope?: string +): string { // For Cucumber scenarios, prefer the feature file URI:line as the stable // discriminator. The Cucumber pickle carries the actual line of the example // row, which is stable across reruns regardless of how many examples run. @@ -60,11 +65,25 @@ function generateStableUid(item: SuiteStats | TestStats): string { ) } + // Cucumber step: scope by the owning scenario's stable uid via + // deterministicUid (no run-order counter), so two scenarios sharing step + // text — and scenario-outline example rows — get distinct, rerun-stable uids. + const stepFile = 'file' in item ? (item.file ?? '') : '' + if (parentScope) { + return deterministicUid( + stepFile, + parentScope, + String(item.fullTitle || item.title) + ) + } + // For Mocha/Jasmine tests and suites, use only stable identifiers // that don't change between full and partial runs // DO NOT use cid or parent as they can vary based on run context - const file = 'file' in item ? (item.file ?? '') : '' - return generateStableUidByFileName(file, String(item.fullTitle || item.title)) + return generateStableUidByFileName( + stepFile, + String(item.fullTitle || item.title) + ) } /** @@ -154,6 +173,9 @@ export class TestReporter extends WebdriverIOReporter { #loadSource: (location: string) => void #currentSpecFile?: string #suitePath: string[] = [] + /** Stable uid of the Cucumber scenario currently open, used to scope its + * step uids. Undefined outside a scenario (Mocha/Jasmine). */ + #currentScenarioUid?: string constructor( options: Reporters.Options, @@ -205,6 +227,11 @@ export class TestReporter extends WebdriverIOReporter { // Generate stable UID for consistent identification across reruns suiteStats.uid = generateStableUid(suiteStats) + // Track the open Cucumber scenario so its steps scope their uids to it. + if (isScenario(suiteStats)) { + this.#currentScenarioUid = suiteStats.uid + } + this.#currentSpecFile = suiteStats.file setCurrentSpecFile(suiteStats.file) @@ -252,8 +279,10 @@ export class TestReporter extends WebdriverIOReporter { this.#loadSource(testStats.file) } - // Generate stable UID after enriching metadata for consistent test identification - testStats.uid = generateStableUid(testStats) + // Generate stable UID after enriching metadata for consistent test + // identification. Cucumber steps are scoped by their scenario's uid so + // identical step text across scenarios stays distinct. + testStats.uid = generateStableUid(testStats, this.#currentScenarioUid) this.#sendUpstream() } @@ -290,6 +319,10 @@ export class TestReporter extends WebdriverIOReporter { onSuiteEnd(suiteStats: SuiteStats): void { super.onSuiteEnd(suiteStats) + // Stop scoping steps once the owning scenario closes. + if (isScenario(suiteStats) && suiteStats.uid === this.#currentScenarioUid) { + this.#currentScenarioUid = undefined + } // Pop the suite we pushed on start if ( suiteStats.title && diff --git a/packages/service/tests/reporter.test.ts b/packages/service/tests/reporter.test.ts index 03461046..bdaccdca 100644 --- a/packages/service/tests/reporter.test.ts +++ b/packages/service/tests/reporter.test.ts @@ -324,4 +324,76 @@ describe('TestReporter - Rerun & Stable UID', () => { expect(() => reporter.report).not.toThrow() }) }) + + describe('Cucumber step uid scoping (no cross-scenario collision)', () => { + const FEATURE = '/proj/features/login.feature' + const STEP = + 'I should see a flash message saying You logged into a secure area!' + + const scenario = (title: string, line: number): SuiteStats => + ({ + uid: `raw-${title}`, + title, + fullTitle: `Login ${title}`, + file: FEATURE, + type: 'scenario', + argument: { uri: FEATURE, line } + }) as unknown as SuiteStats + + const step = (line: number): TestStats => + ({ + uid: 'raw-step', + title: STEP, + fullTitle: STEP, + file: FEATURE, + type: 'test', + argument: { uri: FEATURE, line } + }) as unknown as TestStats + + // Drive a scenario's step through the reporter and return the assigned uid. + const runStep = ( + r: TestReporter, + scen: SuiteStats, + stepStats: TestStats + ): string => { + r.onSuiteStart(scen) + r.onTestStart(stepStats) + r.onTestEnd(stepStats) + r.onSuiteEnd(scen) + return stepStats.uid + } + + it('gives identical step text in sibling scenarios distinct uids', () => { + const scenA = scenario('Scenario A', 5) + const scenB = scenario('Scenario B', 20) + const uidA = runStep(reporter, scenA, step(8)) + const uidB = runStep(reporter, scenB, step(23)) + expect(uidA).not.toBe(uidB) + }) + + it('keeps a step uid stable when its scenario is rerun alone', () => { + // Full run: scenario A then B. + const uidBFull = (() => { + runStep(reporter, scenario('Scenario A', 5), step(8)) + return runStep(reporter, scenario('Scenario B', 20), step(23)) + })() + + // Rerun scenario B on its own (fresh reporter resets the counter). A + // run-order-counter uid would shift to A's slot here; scoping by the + // scenario keeps it stable. + const reporter2 = new TestReporter( + { logFile: '/tmp/test.log' }, + sendUpstream as any + ) + const uidBAlone = runStep(reporter2, scenario('Scenario B', 20), step(23)) + + expect(uidBAlone).toBe(uidBFull) + }) + + it('distinguishes scenario-outline example rows (same title, different line)', () => { + const row1 = runStep(reporter, scenario('greet ', 10), step(11)) + const row2 = runStep(reporter, scenario('greet ', 14), step(15)) + expect(row1).not.toBe(row2) + }) + }) }) From 3da30fa76a22f963b2767d650957c118d936b6d8 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Wed, 8 Jul 2026 10:58:24 +0530 Subject: [PATCH 16/21] fix(core,service): collapse same-timestamp snapshots so blank frames don't clobber results --- packages/core/src/trace-snapshots.ts | 34 ++++- packages/service/src/snapshot-dedupe.ts | 20 +++ .../service/tests/dedupe-snapshots.test.ts | 124 +++++++++++++++++- 3 files changed, 174 insertions(+), 4 deletions(-) diff --git a/packages/core/src/trace-snapshots.ts b/packages/core/src/trace-snapshots.ts index 9e98704d..ab7ed3f5 100644 --- a/packages/core/src/trace-snapshots.ts +++ b/packages/core/src/trace-snapshots.ts @@ -16,10 +16,39 @@ export interface ScreencastFrameEvent { timestamp: number } +// Two snapshots at the same timestamp (a real post-action capture and a blank +// end-of-scenario one) map to the same `${pageId}-${ts}` resource name, so a +// last-wins write lets a blank frame clobber the real one — and the real +// screenshot and real elements can land on different captures. Collapse to one +// per timestamp: largest screenshot, richest metadata, merged across duplicates. +function collapseByTimestamp(snapshots: ActionSnapshot[]): ActionSnapshot[] { + const byTs = new Map() + const order: number[] = [] + for (const snap of snapshots) { + const existing = byTs.get(snap.timestamp) + if (!existing) { + byTs.set(snap.timestamp, { ...snap }) + order.push(snap.timestamp) + continue + } + if ((snap.screenshot?.length ?? 0) > (existing.screenshot?.length ?? 0)) { + existing.screenshot = snap.screenshot + } + if (!existing.elements?.length && snap.elements?.length) { + existing.elements = snap.elements + } + if (!existing.snapshotText && snap.snapshotText) { + existing.snapshotText = snap.snapshotText + } + } + return order.map((ts) => byTs.get(ts)!) +} + export function buildSnapshotResources( - snapshots: ActionSnapshot[], + rawSnapshots: ActionSnapshot[], pageId: string ): TraceZipResource[] { + const snapshots = collapseByTimestamp(rawSnapshots) const out: TraceZipResource[] = [] for (const snap of snapshots) { const base = `${pageId}-${snap.timestamp}` @@ -71,11 +100,12 @@ function frameForSnapshot( /** Filmstrip events; the first snapshot is re-anchored to t=0 for pre-interaction state. */ export function buildFilmstripEvents( - snapshots: ActionSnapshot[], + rawSnapshots: ActionSnapshot[], pageId: string, wallTime: number, viewport: { width: number; height: number } ): ScreencastFrameEvent[] { + const snapshots = collapseByTimestamp(rawSnapshots) const firstSnap = snapshots.find((s) => s.screenshot) const events: ScreencastFrameEvent[] = [] if (firstSnap) { diff --git a/packages/service/src/snapshot-dedupe.ts b/packages/service/src/snapshot-dedupe.ts index d7e14a00..5e51cccd 100644 --- a/packages/service/src/snapshot-dedupe.ts +++ b/packages/service/src/snapshot-dedupe.ts @@ -19,3 +19,23 @@ export function dedupeSnapshotsByTimestamp( } return [...best.values()].sort((a, b) => a.timestamp - b.timestamp) } + +/** Insert a snapshot, or — when one already shares its timestamp — keep only + * the richer screenshot, replacing in place to preserve any index ranges into + * the list. Applies the dedupe heuristic at capture time so a blank + * end-of-scenario frame can't clobber the last action's real result on export + * paths that don't run dedupeSnapshotsByTimestamp (e.g. per-spec traces). */ +export function upsertRichestSnapshot( + snapshots: ActionSnapshot[], + snap: ActionSnapshot +): void { + const idx = snapshots.findIndex((s) => s.timestamp === snap.timestamp) + if (idx === -1) { + snapshots.push(snap) + return + } + const existing = snapshots[idx]! + if ((snap.screenshot?.length ?? 0) > (existing.screenshot?.length ?? 0)) { + snapshots[idx] = snap + } +} diff --git a/packages/service/tests/dedupe-snapshots.test.ts b/packages/service/tests/dedupe-snapshots.test.ts index c33b9b38..d1ac50ee 100644 --- a/packages/service/tests/dedupe-snapshots.test.ts +++ b/packages/service/tests/dedupe-snapshots.test.ts @@ -1,6 +1,13 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' import { describe, it, expect } from 'vitest' -import type { ActionSnapshot } from '@wdio/devtools-shared' -import { dedupeSnapshotsByTimestamp } from '../src/snapshot-dedupe.js' +import { writeTraceZip, type TraceCapturer } from '@wdio/devtools-core' +import { TraceType, type ActionSnapshot } from '@wdio/devtools-shared' +import { + dedupeSnapshotsByTimestamp, + upsertRichestSnapshot +} from '../src/snapshot-dedupe.js' function snap(timestamp: number, screenshot: string): ActionSnapshot { return { timestamp, command: 'click', screenshot } @@ -34,3 +41,116 @@ describe('dedupeSnapshotsByTimestamp', () => { expect(dedupeSnapshotsByTimestamp([])).toEqual([]) }) }) + +describe('upsertRichestSnapshot', () => { + const blank = 'AA' + const content = 'A'.repeat(100) + + it("does not let a blank __final__ clobber the last action's real frame", () => { + // The last action's post-capture (real) and the end-of-scenario __final__ + // (blank) share the last action's timestamp; the real frame must survive. + const list: ActionSnapshot[] = [snap(50, content), snap(100, content)] + const final: ActionSnapshot = { + timestamp: 100, + command: '__final__', + screenshot: blank + } + upsertRichestSnapshot(list, final) + expect(list).toHaveLength(2) + expect(list[1].screenshot).toBe(content) + expect(list[1].command).toBe('click') + }) + + it('replaces in place when the final capture is richer', () => { + // Mirror the opposite case: the action was screenshotted mid-navigation + // (blank) and the settled __final__ is the real frame. + const list: ActionSnapshot[] = [snap(50, content), snap(100, blank)] + const final: ActionSnapshot = { + timestamp: 100, + command: '__final__', + screenshot: content + } + upsertRichestSnapshot(list, final) + expect(list).toHaveLength(2) + expect(list[1].screenshot).toBe(content) + expect(list[1].command).toBe('__final__') + }) + + it('preserves array length/indices on a timestamp collision', () => { + // Spec-range slicing indexes into this array, so a collision must never + // change its length — only replace in place or skip. + const list: ActionSnapshot[] = [snap(100, content)] + upsertRichestSnapshot(list, snap(100, blank)) + expect(list).toHaveLength(1) + }) + + it('appends when the timestamp is new', () => { + const list: ActionSnapshot[] = [snap(100, content)] + upsertRichestSnapshot(list, snap(200, blank)) + expect(list.map((s) => s.timestamp)).toEqual([100, 200]) + }) +}) + +describe('final-frame regression (capture → export)', () => { + it("exports the last action's real result, not the blank __final__ frame", async () => { + // Base64 payloads chosen so byte-length ranks blank < real < result and + // each round-trips cleanly through the resource writer's base64 decode. + const blank = 'AA' + const real = 'R'.repeat(200) + const result = 'B'.repeat(400) + + // The service builds pre/post captures per action; the last action's + // post-capture (result) collides on timestamp with the trailing __final__. + const snapshots: ActionSnapshot[] = [ + { timestamp: 1200, command: 'url', screenshot: real }, + { timestamp: 1200, command: 'click', screenshot: real }, + { timestamp: 1400, command: 'click', screenshot: result } + ] + upsertRichestSnapshot(snapshots, { + timestamp: 1400, + command: '__final__', + screenshot: blank + }) + const prepared = dedupeSnapshotsByTimestamp(snapshots) + + const capturer: TraceCapturer = { + mutations: [], + traceLogs: [], + consoleLogs: [], + networkRequests: [], + commandsLog: [ + { command: 'url', args: [], timestamp: 1200, startTime: 1150 }, + { command: 'click', args: [], timestamp: 1400, startTime: 1350 } + ], + sources: new Map(), + metadata: { + type: TraceType.Testrunner, + viewport: { + width: 800, + height: 600, + offsetLeft: 0, + offsetTop: 0, + scale: 1 + } + }, + startWallTime: 1000 + } + + const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), 'final-frame-')) + const dir = await writeTraceZip(capturer, { + outputDir, + sessionId: 'sess1234', + format: 'ndjson-directory', + actionSnapshots: prepared + }) + + const frame = await fs.readFile( + path.join(dir, 'resources', 'page@sess1234-1400.jpeg') + ) + // The last action's frame is the real result, never the blank capture. + expect(frame.toString('base64')).toBe(result) + expect(frame.length).not.toBe(Buffer.from(blank, 'base64').length) + + await fs.rm(outputDir, { recursive: true, force: true }) + }) +}) From 2a26451a0bad0394ea66db1e9ccd1b12b5511748 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Wed, 8 Jul 2026 11:00:47 +0530 Subject: [PATCH 17/21] chore: Added more actions to the action mapping to appear in the timeline --- packages/app/tests/errors-collect.test.ts | 337 ++++++++++++++++++++++ packages/shared/src/trace-actions.ts | 37 ++- 2 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 packages/app/tests/errors-collect.test.ts diff --git a/packages/app/tests/errors-collect.test.ts b/packages/app/tests/errors-collect.test.ts new file mode 100644 index 00000000..c92e3680 --- /dev/null +++ b/packages/app/tests/errors-collect.test.ts @@ -0,0 +1,337 @@ +import { describe, it, expect } from 'vitest' +import type { CommandLog } from '@wdio/devtools-shared' + +import { collectErrors } from '../src/components/workbench/errors/collect.js' +import type { + SuiteStatsFragment, + TestStatsFragment +} from '../src/controller/types.js' + +function command(overrides: Partial): CommandLog { + return { + command: 'click', + args: [], + timestamp: 0, + ...overrides + } +} + +function suiteMap( + ...suites: SuiteStatsFragment[] +): Record[] { + return [Object.fromEntries(suites.map((s) => [s.uid, s]))] +} + +function failedTest(overrides: Partial): TestStatsFragment { + return { + uid: 't1', + state: 'failed', + ...overrides + } +} + +describe('collectErrors', () => { + it('returns an empty list when nothing failed', () => { + expect(collectErrors([], [])).toEqual([]) + expect(collectErrors(undefined, undefined)).toEqual([]) + expect( + collectErrors( + [command({ command: 'click' })], + suiteMap({ + uid: 's1', + state: 'passed', + tests: [failedTest({ state: 'passed' })] + }) + ) + ).toEqual([]) + }) + + it('collects failed commands with title, message, stack and source', () => { + const errors = collectErrors( + [ + command({ + command: 'expect', + title: 'expect(el).toHaveText', + callSource: 'file:///spec.ts:12:3', + error: { + name: 'Error', + message: 'expected foo, got bar', + stack: 'at spec.ts:12' + }, + timestamp: 100 + }) + ], + [] + ) + expect(errors).toHaveLength(1) + expect(errors[0]).toMatchObject({ + title: 'expect(el).toHaveText', + message: 'expected foo, got bar', + stack: 'at spec.ts:12', + callSource: 'file:///spec.ts:12:3', + timestamp: 100 + }) + expect(errors[0].command?.command).toBe('expect') + }) + + it('falls back to the command name when there is no title', () => { + const [error] = collectErrors( + [ + command({ + command: 'navigateTo', + error: { name: 'Error', message: 'boom' } + }) + ], + [] + ) + expect(error.title).toBe('navigateTo') + }) + + it('orders command errors by timestamp', () => { + const errors = collectErrors( + [ + command({ + command: 'second', + error: { name: 'Error', message: 'b' }, + timestamp: 200 + }), + command({ + command: 'first', + error: { name: 'Error', message: 'a' }, + timestamp: 100 + }) + ], + [] + ) + expect(errors.map((e) => e.message)).toEqual(['a', 'b']) + }) + + it('collects failed tests from nested suites', () => { + const errors = collectErrors( + [], + suiteMap({ + uid: 'feature', + state: 'failed', + suites: [ + { + uid: 'scenario', + state: 'failed', + tests: [ + failedTest({ + uid: 'step-1', + title: 'Then it should pass', + fullTitle: 'Scenario > Then it should pass', + callSource: 'steps.ts:8:1', + error: { name: 'AssertionError', message: 'nope' } + }) + ] + } + ] + }) + ) + expect(errors).toHaveLength(1) + expect(errors[0]).toMatchObject({ + title: 'Scenario > Then it should pass', + message: 'nope', + callSource: 'steps.ts:8:1' + }) + expect(errors[0].command).toBeUndefined() + }) + + it('reads the first entry of errors[] when error is absent', () => { + const [error] = collectErrors( + [], + suiteMap({ + uid: 's', + state: 'failed', + tests: [ + failedTest({ + uid: 'a', + errors: [{ name: 'Error', message: 'from errors array' } as Error] + }) + ] + }) + ) + expect(error.message).toBe('from errors array') + }) + + it('ignores failed tests that carry no error payload', () => { + const errors = collectErrors( + [], + suiteMap({ + uid: 's', + state: 'failed', + tests: [failedTest({ uid: 'a' })] + }) + ) + expect(errors).toEqual([]) + }) + + it('dedupes a test failure that only echoes a command failure', () => { + const shared = 'expect(locator).toHaveText failed' + const errors = collectErrors( + [ + command({ + command: 'expect', + error: { name: 'Error', message: shared }, + timestamp: 5 + }) + ], + suiteMap({ + uid: 's', + state: 'failed', + tests: [ + failedTest({ + uid: 'a', + title: 'the scenario', + error: { name: 'Error', message: shared } + }) + ] + }) + ) + expect(errors).toHaveLength(1) + expect(errors[0].command?.command).toBe('expect') + }) + + it('keeps a distinct test failure alongside command failures', () => { + const errors = collectErrors( + [ + command({ + command: 'click', + error: { name: 'Error', message: 'click failed' }, + timestamp: 1 + }) + ], + suiteMap({ + uid: 's', + state: 'failed', + tests: [ + failedTest({ + uid: 'a', + title: 'setup', + error: { name: 'Error', message: 'hook failed' } + }) + ] + }) + ) + expect(errors.map((e) => e.message)).toEqual([ + 'click failed', + 'hook failed' + ]) + }) + + it('dedupes failed tests by uid across repeated suite maps', () => { + const test = failedTest({ + uid: 'dup', + title: 'flaky', + error: { name: 'Error', message: 'x' } + }) + const errors = collectErrors( + [], + [ + { s: { uid: 's', state: 'failed', tests: [test] } }, + { s: { uid: 's', state: 'failed', tests: [test] } } + ] + ) + expect(errors).toHaveLength(1) + }) + + it('strips ANSI, splits the stack, and pulls Expected/Received from a cucumber message', () => { + const raw = + 'Expect $(`#flash`) to have text\n\n' + + 'Expected: StringContaining "secure area"\n' + + 'Received: "invalid"\n' + + ' at World. (/specs/steps.ts:31:20)\n' + + ' at process.processTicksAndRejections (node:internal:104:5)' + const [error] = collectErrors( + [ + command({ + command: 'expect.assertion', + error: { name: 'Error', message: raw } + }) + ], + [] + ) + expect(error.message).toBe('Expect $(`#flash`) to have text') + expect(error.expected).toBe('StringContaining "secure area"') + expect(error.actual).toBe('"invalid"') + expect(error.stack).toContain('at World.') + expect(error.message).not.toContain('') + expect(error.message).not.toContain('at World') + }) + + it('extracts indented Expected/Received and dedents the value', () => { + const raw = + 'Expect $(`#flash`) to have text\n\n' + + ' Expected: StringContaining "secure area"\n' + + ' Received: "invalid!\n×"\n' + + ' at World. (/specs/steps.ts:31:20)' + const [error] = collectErrors( + [command({ command: 'getText', error: { name: 'Error', message: raw } })], + [] + ) + expect(error.message).toBe('Expect $(`#flash`) to have text') + expect(error.expected).toBe('StringContaining "secure area"') + expect(error.actual).toBe('"invalid!\n×"') + expect(error.stack).toContain('at World.') + }) + + it('extracts expected/received from an assertion command', () => { + const [error] = collectErrors( + [ + command({ + command: 'expect.toHaveText', + args: ['Your username is invalid!', 'You logged into a secure area!'], + error: { name: 'Error', message: 'text mismatch' } + }) + ], + [] + ) + expect(error.actual).toBe('Your username is invalid!') + expect(error.expected).toBe('You logged into a secure area!') + }) + + it('extracts expected/received from a failed-test matcher error', () => { + const [error] = collectErrors( + [], + suiteMap({ + uid: 's', + state: 'failed', + tests: [ + failedTest({ + uid: 'a', + title: 'the scenario', + error: { + name: 'Error', + message: 'mismatch', + expected: 42, + actual: 7 + } as unknown as Error + }) + ] + }) + ) + expect(error.expected).toBe('42') + expect(error.actual).toBe('7') + }) + + it('places command errors before test errors', () => { + const errors = collectErrors( + [ + command({ + command: 'click', + error: { name: 'Error', message: 'cmd' }, + timestamp: 999 + }) + ], + suiteMap({ + uid: 's', + state: 'failed', + tests: [ + failedTest({ uid: 'a', error: { name: 'Error', message: 'test' } }) + ] + }) + ) + expect(errors.map((e) => e.message)).toEqual(['cmd', 'test']) + }) +}) diff --git a/packages/shared/src/trace-actions.ts b/packages/shared/src/trace-actions.ts index bae73200..c3bd7462 100644 --- a/packages/shared/src/trace-actions.ts +++ b/packages/shared/src/trace-actions.ts @@ -33,6 +33,8 @@ export const TRACKED_ASSERT_METHODS = [ // assert. (node:assert), verify. (nightwatch soft variants), and // expect. (synthesized failing-matcher entries) all render as Assert. +// Only FAILING expect-webdriverio matchers reach the command log today (via the +// reporter); recording passing matchers needs a per-adapter capture change. const ASSERT_COMMAND_RE = /^(?:assert|verify|expect)\.(\w+)$/ export function mapAssertCommand(command: string): TraceAction | null { @@ -68,5 +70,38 @@ export const ACTION_MAP: Record = { execute: { class: 'Page', method: 'evaluate' }, executeAsync: { class: 'Page', method: 'evaluate' }, switchToFrame: { class: 'Frame', method: 'goto' }, - touchAction: { class: 'Element', method: 'tap' } + touchAction: { class: 'Element', method: 'tap' }, + // WDIO element reads — surfaced so query steps appear in the timeline the way + // locator queries do in standard trace viewers. Adapters already capture + // these; only the export allow-list kept them out. + getText: { class: 'Element', method: 'getText' }, + getValue: { class: 'Element', method: 'getValue' }, + getAttribute: { class: 'Element', method: 'getAttribute' }, + getProperty: { class: 'Element', method: 'getProperty' }, + getCSSProperty: { class: 'Element', method: 'getCSSProperty' }, + getTagName: { class: 'Element', method: 'getTagName' }, + getLocation: { class: 'Element', method: 'getLocation' }, + getSize: { class: 'Element', method: 'getSize' }, + isDisplayed: { class: 'Element', method: 'isDisplayed' }, + isExisting: { class: 'Element', method: 'isExisting' }, + isEnabled: { class: 'Element', method: 'isEnabled' }, + isSelected: { class: 'Element', method: 'isSelected' }, + isClickable: { class: 'Element', method: 'isClickable' }, + isFocused: { class: 'Element', method: 'isFocused' }, + // Explicit user-facing waits (not the internal polling loops behind them). + waitForDisplayed: { class: 'Element', method: 'waitForDisplayed' }, + waitForExist: { class: 'Element', method: 'waitForExist' }, + waitForEnabled: { class: 'Element', method: 'waitForEnabled' }, + waitForClickable: { class: 'Element', method: 'waitForClickable' }, + waitUntil: { class: 'Browser', method: 'waitForFunction' }, + // WDIO page/browser reads + getTitle: { class: 'Page', method: 'getTitle' }, + getUrl: { class: 'Page', method: 'getUrl' }, + getPageSource: { class: 'Page', method: 'getPageSource' }, + // Selenium read aliases — normalized onto the WDIO names above so both runners + // read identically. getText/getAttribute/getTagName/isDisplayed/isEnabled/ + // isSelected share the command name across runners and need no alias. + getCssValue: { class: 'Element', method: 'getCSSProperty' }, + getRect: { class: 'Element', method: 'getRect' }, + getCurrentUrl: { class: 'Page', method: 'getUrl' } } From 9fe36d03581a193db8fe5125fee346cad254c85d Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Wed, 8 Jul 2026 11:56:54 +0530 Subject: [PATCH 18/21] chore(examples): split wdio example into cucumber/ and mocha/ with shared page objects --- .../wdio/cucumber/features/login-fail.feature | 9 +++ .../{ => cucumber}/features/login.feature | 2 +- .../features/step-definitions/steps.ts | 4 +- examples/wdio/{ => cucumber}/wdio.conf.ts | 0 .../wdio/{ => cucumber}/wdio.mobile.conf.ts | 0 examples/wdio/cucumber/wdio.retention.conf.ts | 68 +++++++++++++++++++ .../wdio/{ => cucumber}/wdio.trace.conf.ts | 0 examples/wdio/mocha/specs/login-fail.e2e.ts | 17 +++++ examples/wdio/mocha/specs/login.e2e.ts | 26 +++++++ examples/wdio/mocha/wdio.conf.ts | 53 +++++++++++++++ examples/wdio/package.json | 9 ++- .../{features => }/pageobjects/login.page.ts | 0 .../wdio/{features => }/pageobjects/page.ts | 0 .../{features => }/pageobjects/secure.page.ts | 0 examples/wdio/tsconfig.json | 8 ++- 15 files changed, 187 insertions(+), 9 deletions(-) create mode 100644 examples/wdio/cucumber/features/login-fail.feature rename examples/wdio/{ => cucumber}/features/login.feature (84%) rename examples/wdio/{ => cucumber}/features/step-definitions/steps.ts (89%) rename examples/wdio/{ => cucumber}/wdio.conf.ts (100%) rename examples/wdio/{ => cucumber}/wdio.mobile.conf.ts (100%) create mode 100644 examples/wdio/cucumber/wdio.retention.conf.ts rename examples/wdio/{ => cucumber}/wdio.trace.conf.ts (100%) create mode 100644 examples/wdio/mocha/specs/login-fail.e2e.ts create mode 100644 examples/wdio/mocha/specs/login.e2e.ts create mode 100644 examples/wdio/mocha/wdio.conf.ts rename examples/wdio/{features => }/pageobjects/login.page.ts (100%) rename examples/wdio/{features => }/pageobjects/page.ts (100%) rename examples/wdio/{features => }/pageobjects/secure.page.ts (100%) diff --git a/examples/wdio/cucumber/features/login-fail.feature b/examples/wdio/cucumber/features/login-fail.feature new file mode 100644 index 00000000..c8d325b8 --- /dev/null +++ b/examples/wdio/cucumber/features/login-fail.feature @@ -0,0 +1,9 @@ +Feature: Retention check — deliberately failing login + + # Logs in with bad credentials but asserts the SUCCESS message, so the Then + # step fails. Used to verify retain-on-failure keeps this spec's trace while + # dropping the passing login.feature. Reuses the existing step definitions. + Scenario: Failing assertion exercises retain-on-failure + Given I am on the login page + When I login with foobar and barfoo + Then I should see a flash message saying You logged into a secure area! diff --git a/examples/wdio/features/login.feature b/examples/wdio/cucumber/features/login.feature similarity index 84% rename from examples/wdio/features/login.feature rename to examples/wdio/cucumber/features/login.feature index e6dd0f87..8b9bcf9a 100644 --- a/examples/wdio/features/login.feature +++ b/examples/wdio/cucumber/features/login.feature @@ -8,5 +8,5 @@ Feature: The Internet Guinea Pig Website Examples: | username | password | message | - | tomsmith | SuperSecretPassword! | You logged into a secure area! | + | tomsmith | SuperSecretPassword1! | You logged into a secure area! | | foobar | barfoo | Your username is invalid! | diff --git a/examples/wdio/features/step-definitions/steps.ts b/examples/wdio/cucumber/features/step-definitions/steps.ts similarity index 89% rename from examples/wdio/features/step-definitions/steps.ts rename to examples/wdio/cucumber/features/step-definitions/steps.ts index 32522982..3367b631 100644 --- a/examples/wdio/features/step-definitions/steps.ts +++ b/examples/wdio/cucumber/features/step-definitions/steps.ts @@ -1,8 +1,8 @@ import { Given, When, Then, After } from '@wdio/cucumber-framework' import { browser, expect } from '@wdio/globals' -import LoginPage from '../pageobjects/login.page.js' -import SecurePage from '../pageobjects/secure.page.js' +import LoginPage from '../../../pageobjects/login.page.js' +import SecurePage from '../../../pageobjects/secure.page.js' const pages = { login: LoginPage diff --git a/examples/wdio/wdio.conf.ts b/examples/wdio/cucumber/wdio.conf.ts similarity index 100% rename from examples/wdio/wdio.conf.ts rename to examples/wdio/cucumber/wdio.conf.ts diff --git a/examples/wdio/wdio.mobile.conf.ts b/examples/wdio/cucumber/wdio.mobile.conf.ts similarity index 100% rename from examples/wdio/wdio.mobile.conf.ts rename to examples/wdio/cucumber/wdio.mobile.conf.ts diff --git a/examples/wdio/cucumber/wdio.retention.conf.ts b/examples/wdio/cucumber/wdio.retention.conf.ts new file mode 100644 index 00000000..a71b97bb --- /dev/null +++ b/examples/wdio/cucumber/wdio.retention.conf.ts @@ -0,0 +1,68 @@ +import path from 'node:path' + +// Disposable harness for verifying tracePolicy end-to-end. Runs one passing +// spec (login.feature) and one failing spec (login-fail.feature) at spec +// granularity so retain-on-failure can be seen dropping the passing spec's +// trace while keeping the failing one. Change `tracePolicy` below to try each. + +const __dirname = path.resolve(path.dirname(new URL(import.meta.url).pathname)) + +export const config: WebdriverIO.Config = { + runner: 'local', + tsConfigPath: './tsconfig.json', + + specs: ['./features/login.feature', './features/login-fail.feature'], + + maxInstances: 1, + + capabilities: [ + { + browserName: 'chrome', + browserVersion: '149.0.7827.201', // specify chromium browser version for testing + 'goog:chromeOptions': { + args: [ + '--headless', + '--disable-gpu', + '--remote-allow-origins=*', + '--window-size=1600,1200' + ] + } + } + ], + + logLevel: 'warn', + baseUrl: 'http://localhost', + waitforTimeout: 10000, + connectionRetryTimeout: 120000, + connectionRetryCount: 3, + + services: [ + [ + 'devtools', + { + mode: 'trace' as const, + traceGranularity: 'spec' as const + // tracePolicy: 'retain-on-failure' as const + } + ] + ], + + framework: 'cucumber', + reporters: ['spec'], + + cucumberOpts: { + require: [ + path.resolve(__dirname, 'features', 'step-definitions', 'steps.ts') + ], + backtrace: false, + requireModule: [], + dryRun: false, + failFast: false, + snippets: true, + source: true, + strict: false, + tagExpression: '', + timeout: 60000, + ignoreUndefinedDefinitions: false + } +} diff --git a/examples/wdio/wdio.trace.conf.ts b/examples/wdio/cucumber/wdio.trace.conf.ts similarity index 100% rename from examples/wdio/wdio.trace.conf.ts rename to examples/wdio/cucumber/wdio.trace.conf.ts diff --git a/examples/wdio/mocha/specs/login-fail.e2e.ts b/examples/wdio/mocha/specs/login-fail.e2e.ts new file mode 100644 index 00000000..b939a28b --- /dev/null +++ b/examples/wdio/mocha/specs/login-fail.e2e.ts @@ -0,0 +1,17 @@ +import { expect } from '@wdio/globals' + +import LoginPage from '../../pageobjects/login.page.js' +import SecurePage from '../../pageobjects/secure.page.js' + +// Deliberately failing — mirrors login-fail.feature, so the Errors tab and +// tracePolicy: 'retain-on-failure' have something to exercise. +describe('Login (failing)', () => { + it('asserts the wrong flash message so the run fails', async () => { + console.log('[TEST] submitting invalid credentials') + await LoginPage.open() + await LoginPage.login('foobar', 'barfoo') + await expect(SecurePage.flashAlert).toHaveText( + expect.stringContaining('You logged into a secure area!') + ) + }) +}) diff --git a/examples/wdio/mocha/specs/login.e2e.ts b/examples/wdio/mocha/specs/login.e2e.ts new file mode 100644 index 00000000..0282a744 --- /dev/null +++ b/examples/wdio/mocha/specs/login.e2e.ts @@ -0,0 +1,26 @@ +import { expect } from '@wdio/globals' + +import LoginPage from '../../pageobjects/login.page.js' +import SecurePage from '../../pageobjects/secure.page.js' + +describe('Login', () => { + it('logs into the secure area with valid credentials', async () => { + console.log('[TEST] logging in with valid credentials') + await LoginPage.open() + await LoginPage.login('tomsmith', 'SuperSecretPassword!') + await expect(SecurePage.flashAlert).toBeExisting() + await expect(SecurePage.flashAlert).toHaveText( + expect.stringContaining('You logged into a secure area!') + ) + console.log('[TEST] secure area reached') + }) + + it('shows an error message for an invalid username', async () => { + console.log('[TEST] logging in with an invalid username') + await LoginPage.open() + await LoginPage.login('foobar', 'barfoo') + await expect(SecurePage.flashAlert).toHaveText( + expect.stringContaining('Your username is invalid!') + ) + }) +}) diff --git a/examples/wdio/mocha/wdio.conf.ts b/examples/wdio/mocha/wdio.conf.ts new file mode 100644 index 00000000..474602e3 --- /dev/null +++ b/examples/wdio/mocha/wdio.conf.ts @@ -0,0 +1,53 @@ +import type { Options } from '@wdio/types' + +// Mocha counterpart to wdio.conf.ts (which runs the Cucumber example). Same +// capabilities and devtools service; only the framework + spec layout differ. +export const config: Options.Testrunner = { + runner: 'local', + autoCompileOpts: { + autoCompile: true, + tsNodeOpts: { + project: './tsconfig.json', + transpileOnly: true + } + }, + specs: ['./specs/**/*.e2e.ts'], + exclude: [], + maxInstances: 10, + capabilities: [ + { + browserName: 'chrome', + 'goog:chromeOptions': { + args: [ + '--headless', + '--disable-gpu', + '--remote-allow-origins=*', + '--window-size=1600,900' + ] + } + } + ], + logLevel: 'debug', + bail: 0, + baseUrl: 'http://localhost', + waitforTimeout: 10000, + connectionRetryTimeout: 120000, + connectionRetryCount: 3, + services: [ + [ + 'devtools', + { + mode: 'trace' as const, + traceGranularity: 'spec' as const + // tracePolicy: 'retain-on-failure' as const + // screencast: { enabled: true, pollIntervalMs: 200 } + } + ] + ], + framework: 'mocha', + reporters: ['spec'], + mochaOpts: { + ui: 'bdd', + timeout: 60000 + } +} diff --git a/examples/wdio/package.json b/examples/wdio/package.json index c783a577..4bbb0957 100644 --- a/examples/wdio/package.json +++ b/examples/wdio/package.json @@ -6,6 +6,7 @@ "devDependencies": { "@wdio/cli": "9.28.0", "@wdio/cucumber-framework": "9.28.0", + "@wdio/mocha-framework": "9.28.0", "@wdio/devtools-service": "workspace:*", "@wdio/globals": "9.28.0", "@wdio/local-runner": "9.28.0", @@ -18,8 +19,10 @@ "typescript": "^6.0.3" }, "scripts": { - "wdio": "wdio run ./wdio.conf.ts", - "mobile": "wdio run ./wdio.mobile.conf.ts", - "trace": "wdio run ./wdio.trace.conf.ts" + "cucumber": "wdio run ./cucumber/wdio.conf.ts", + "mocha": "wdio run ./mocha/wdio.conf.ts", + "mobile": "wdio run ./cucumber/wdio.mobile.conf.ts", + "trace": "wdio run ./cucumber/wdio.trace.conf.ts", + "retention": "wdio run ./cucumber/wdio.retention.conf.ts" } } diff --git a/examples/wdio/features/pageobjects/login.page.ts b/examples/wdio/pageobjects/login.page.ts similarity index 100% rename from examples/wdio/features/pageobjects/login.page.ts rename to examples/wdio/pageobjects/login.page.ts diff --git a/examples/wdio/features/pageobjects/page.ts b/examples/wdio/pageobjects/page.ts similarity index 100% rename from examples/wdio/features/pageobjects/page.ts rename to examples/wdio/pageobjects/page.ts diff --git a/examples/wdio/features/pageobjects/secure.page.ts b/examples/wdio/pageobjects/secure.page.ts similarity index 100% rename from examples/wdio/features/pageobjects/secure.page.ts rename to examples/wdio/pageobjects/secure.page.ts diff --git a/examples/wdio/tsconfig.json b/examples/wdio/tsconfig.json index 8acd81f7..8b8a6786 100644 --- a/examples/wdio/tsconfig.json +++ b/examples/wdio/tsconfig.json @@ -11,7 +11,8 @@ "node", "@wdio/globals/types", "expect-webdriverio", - "@wdio/cucumber-framework" + "@wdio/cucumber-framework", + "@wdio/mocha-framework" ], "skipLibCheck": true, "noEmit": true, @@ -24,7 +25,8 @@ "noFallthroughCasesInSwitch": true }, "include": [ - "test", - "wdio.conf.ts" + "cucumber", + "mocha", + "pageobjects" ] } From 62d3a32a3948aa3709d2eb74e1334605cdcc02ee Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Wed, 8 Jul 2026 12:09:20 +0530 Subject: [PATCH 19/21] chore: Update path in readme and update pnpm-lock.yaml --- ARCHITECTURE.md | 2 +- README.md | 2 +- package.json | 3 +- pnpm-lock.yaml | 183 +++++++++++++++--------------------------------- 4 files changed, 60 insertions(+), 130 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6d4fecc0..1324f454 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -144,7 +144,7 @@ The execution environment is the browser, not Node, so this package cannot impor Per-framework demo projects used for manual verification. -- `examples/wdio/` — WebdriverIO with Mocha (default). Run via `pnpm demo:wdio`. +- `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/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:` runs the others. diff --git a/README.md b/README.md index 96e7adf8..fa87cc71 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,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' — ` / `'ios' — ` so the viewer labels frames correctly. -A reference WDIO config is at [examples/wdio/wdio.mobile.conf.ts](examples/wdio/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). Prereqs to run it 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/`). diff --git a/package.json b/package.json index e4037c25..65bf3171 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "type": "module", "scripts": { "build": "pnpm -r build", - "demo:wdio": "wdio run ./examples/wdio/wdio.conf.ts", + "demo:wdio": "wdio run ./examples/wdio/cucumber/wdio.conf.ts", + "demo:wdio:mocha": "wdio run ./examples/wdio/mocha/wdio.conf.ts", "demo:nightwatch": "pnpm --filter @wdio/nightwatch-devtools example", "demo:selenium": "pnpm --filter @wdio/selenium-devtools example", "dev": "pnpm --parallel dev", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b338e0b8..a2354ac3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,6 +141,9 @@ importers: '@wdio/local-runner': specifier: 9.28.0 version: 9.28.0(@wdio/globals@9.28.0)(webdriverio@9.28.0(puppeteer-core@21.11.0)) + '@wdio/mocha-framework': + specifier: 9.28.0 + version: 9.28.0 '@wdio/spec-reporter': specifier: 9.28.0 version: 9.28.0 @@ -365,7 +368,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.0.16 - version: 4.1.9(@types/node@25.9.3)(@vitest/coverage-v8@4.1.9)(happy-dom@20.10.4)(jsdom@24.1.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.9(@types/node@25.9.3)(@vitest/coverage-v8@4.1.9)(happy-dom@20.10.4)(jsdom@24.1.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/nightwatch-devtools: dependencies: @@ -1859,49 +1862,42 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@napi-rs/nice-linux-arm64-musl@1.1.1': resolution: {integrity: sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@napi-rs/nice-linux-ppc64-gnu@1.1.1': resolution: {integrity: sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==} engines: {node: '>= 10'} cpu: [ppc64] os: [linux] - libc: [glibc] '@napi-rs/nice-linux-riscv64-gnu@1.1.1': resolution: {integrity: sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@napi-rs/nice-linux-s390x-gnu@1.1.1': resolution: {integrity: sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==} engines: {node: '>= 10'} cpu: [s390x] os: [linux] - libc: [glibc] '@napi-rs/nice-linux-x64-gnu@1.1.1': resolution: {integrity: sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@napi-rs/nice-linux-x64-musl@1.1.1': resolution: {integrity: sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@napi-rs/nice-openharmony-arm64@1.1.1': resolution: {integrity: sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==} @@ -2027,42 +2023,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.3': resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.3': resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.3': resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.3': resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.3': resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.3': resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} @@ -2133,79 +2123,66 @@ packages: resolution: {integrity: sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.62.0': resolution: {integrity: sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.62.0': resolution: {integrity: sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.62.0': resolution: {integrity: sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.62.0': resolution: {integrity: sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.62.0': resolution: {integrity: sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.62.0': resolution: {integrity: sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.62.0': resolution: {integrity: sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.62.0': resolution: {integrity: sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.62.0': resolution: {integrity: sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.62.0': resolution: {integrity: sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.62.0': resolution: {integrity: sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.62.0': resolution: {integrity: sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.62.0': resolution: {integrity: sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==} @@ -2327,28 +2304,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.1': resolution: {integrity: sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.1': resolution: {integrity: sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.1': resolution: {integrity: sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.1': resolution: {integrity: sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==} @@ -2454,6 +2427,9 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/mocha@10.0.10': + resolution: {integrity: sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==} + '@types/node@25.9.3': resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} @@ -2606,61 +2582,51 @@ packages: resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.12.2': resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} cpu: [loong64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-loong64-musl@1.12.2': resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} cpu: [loong64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.12.2': resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.12.2': resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-openharmony-arm64@1.12.2': resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} @@ -2785,6 +2751,10 @@ packages: resolution: {integrity: sha512-HdzDrRs+ywAqbXGKqe1i/bLtCv47plz4TvsHFH3j729OooT5VH38ctFn5aLXgECmiAKDkmH/A6kOq2Zh5DIxww==} engines: {node: '>=18.20.0'} + '@wdio/mocha-framework@9.28.0': + resolution: {integrity: sha512-UdkgigdyxJu0Rpx2ZRSNuB8u8B5nmLBeRjn5JHYTXBp8Ubi+wgFR1+EdLg8k2z+CtuYe3bNHBw7vPpBYuQMLZw==} + engines: {node: '>=18.20.0'} + '@wdio/protocols@8.40.3': resolution: {integrity: sha512-wK7+eyrB3TAei8RwbdkcyoNk2dPu+mduMBOdPJjp8jf/mavd15nIUXLID1zA+w5m1Qt1DsT1NbvaeO9+aJQ33A==} @@ -5262,28 +5232,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -7644,7 +7610,7 @@ snapshots: '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@5.5.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -7803,7 +7769,7 @@ snapshots: '@babel/parser': 7.29.7 '@babel/template': 7.29.7 '@babel/types': 7.29.7 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -8484,7 +8450,7 @@ snapshots: '@eslint/config-array@0.23.5': dependencies: '@eslint/object-schema': 3.0.5 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@5.5.0) minimatch: 10.2.5 transitivePeerDependencies: - supports-color @@ -9168,7 +9134,7 @@ snapshots: '@puppeteer/browsers@2.13.2': dependencies: - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@5.5.0) extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 @@ -9514,6 +9480,8 @@ snapshots: '@types/json5@0.0.29': {} + '@types/mocha@10.0.10': {} + '@types/node@25.9.3': dependencies: undici-types: 7.24.6 @@ -9584,7 +9552,7 @@ snapshots: '@typescript-eslint/types': 8.61.1 '@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.61.1 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@5.5.0) eslint: 10.5.0(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: @@ -9594,7 +9562,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@6.0.3) '@typescript-eslint/types': 8.61.1 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@5.5.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -9613,7 +9581,7 @@ snapshots: '@typescript-eslint/types': 8.61.1 '@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3) '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@5.5.0) eslint: 10.5.0(jiti@2.7.0) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 @@ -9628,7 +9596,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@6.0.3) '@typescript-eslint/types': 8.61.1 '@typescript-eslint/visitor-keys': 8.61.1 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@5.5.0) minimatch: 10.2.5 semver: 7.8.4 tinyglobby: 0.2.17 @@ -9767,14 +9735,6 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': - dependencies: - '@vitest/spy': 4.1.9 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 8.0.16(@types/node@25.9.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - '@vitest/mocker@4.1.9(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.9 @@ -9958,6 +9918,20 @@ snapshots: safe-regex2: 5.1.1 strip-ansi: 7.2.0 + '@wdio/mocha-framework@9.28.0': + dependencies: + '@types/mocha': 10.0.10 + '@types/node': 25.9.3 + '@wdio/logger': 9.18.0 + '@wdio/types': 9.28.0 + '@wdio/utils': 9.28.0 + mocha: 10.8.2 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + '@wdio/protocols@8.40.3': {} '@wdio/protocols@9.28.0': {} @@ -10084,7 +10058,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -11515,7 +11489,7 @@ snapshots: '@types/estree': 1.0.9 ajv: 6.15.0 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@5.5.0) escape-string-regexp: 4.0.0 eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 @@ -11632,7 +11606,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@5.5.0) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -11963,7 +11937,7 @@ snapshots: dependencies: basic-ftp: 5.3.1 data-uri-to-buffer: 6.0.2 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -11971,7 +11945,7 @@ snapshots: dependencies: basic-ftp: 5.3.1 data-uri-to-buffer: 8.0.0 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -12167,35 +12141,35 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color http-proxy-agent@9.0.0: dependencies: agent-base: 9.0.0 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color https-proxy-agent@9.0.0: dependencies: agent-base: 9.0.0 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -12492,7 +12466,7 @@ snapshots: istanbul-lib-source-maps@5.0.6: dependencies: '@jridgewell/trace-mapping': 0.3.31 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@5.5.0) istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color @@ -13578,7 +13552,7 @@ snapshots: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.4 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@5.5.0) get-uri: 6.0.5 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -13590,7 +13564,7 @@ snapshots: pac-proxy-agent@9.0.1: dependencies: agent-base: 9.0.0 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@5.5.0) get-uri: 8.0.0 http-proxy-agent: 9.0.0 https-proxy-agent: 9.0.0 @@ -13880,7 +13854,7 @@ snapshots: proxy-agent@6.5.0: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@5.5.0) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -13893,7 +13867,7 @@ snapshots: proxy-agent@8.0.1: dependencies: agent-base: 9.0.0 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@5.5.0) http-proxy-agent: 9.0.0 https-proxy-agent: 9.0.0 lru-cache: 7.18.3 @@ -14413,7 +14387,7 @@ snapshots: socks-proxy-agent@10.0.0: dependencies: agent-base: 9.0.0 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@5.5.0) socks: 2.8.9 transitivePeerDependencies: - supports-color @@ -14421,7 +14395,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@5.5.0) socks: 2.8.9 transitivePeerDependencies: - supports-color @@ -14630,7 +14604,7 @@ snapshots: cosmiconfig: 9.0.2(typescript@6.0.3) css-functions-list: 3.3.3 css-tree: 3.2.1 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@5.5.0) fast-glob: 3.3.3 fastest-levenshtein: 1.0.16 file-entry-cache: 11.1.3 @@ -14893,7 +14867,7 @@ snapshots: cac: 6.7.14 chokidar: 4.0.3 consola: 3.4.2 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@5.5.0) esbuild: 0.27.7 fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 @@ -15026,7 +15000,7 @@ snapshots: '@rollup/pluginutils': 5.4.0(rollup@4.62.0) '@volar/typescript': 2.4.28 compare-versions: 6.1.1 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@5.5.0) kolorist: 1.8.0 local-pkg: 1.2.1 magic-string: 0.30.21 @@ -15169,21 +15143,6 @@ snapshots: optionalDependencies: rollup: 4.62.0 - vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0): - dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.15 - rolldown: 1.0.3 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 25.9.3 - esbuild: 0.27.7 - fsevents: 2.3.3 - jiti: 2.7.0 - tsx: 4.22.4 - yaml: 2.9.0 - vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 @@ -15199,36 +15158,6 @@ snapshots: tsx: 4.22.4 yaml: 2.9.0 - vitest@4.1.9(@types/node@25.9.3)(@vitest/coverage-v8@4.1.9)(happy-dom@20.10.4)(jsdom@24.1.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): - dependencies: - '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.9 - '@vitest/runner': 4.1.9 - '@vitest/snapshot': 4.1.9 - '@vitest/spy': 4.1.9 - '@vitest/utils': 4.1.9 - es-module-lexer: 2.1.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.3 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.1.0 - tinybench: 2.9.0 - tinyexec: 1.2.4 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@25.9.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 25.9.3 - '@vitest/coverage-v8': 4.1.9(@vitest/browser@4.1.9)(vitest@4.1.9) - happy-dom: 20.10.4 - jsdom: 24.1.3 - transitivePeerDependencies: - - msw - vitest@4.1.9(@types/node@25.9.3)(@vitest/coverage-v8@4.1.9)(happy-dom@20.10.4)(jsdom@24.1.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 @@ -15271,7 +15200,7 @@ snapshots: dependencies: chalk: 4.1.2 commander: 9.5.0 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color From fabd78265636b6b2c30161a889052d62fca900f0 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Wed, 8 Jul 2026 12:09:49 +0530 Subject: [PATCH 20/21] fix(core): drop dependency-internal assertions from the trace --- packages/core/src/assert-patcher.ts | 5 ++ packages/core/tests/assert-patcher.test.ts | 62 +++++++++++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/packages/core/src/assert-patcher.ts b/packages/core/src/assert-patcher.ts index ab30c1f7..8917a1ef 100644 --- a/packages/core/src/assert-patcher.ts +++ b/packages/core/src/assert-patcher.ts @@ -57,6 +57,11 @@ function makeAssertEmitters( onCommand: (cmd: CapturedAssert) => void ): { passed: () => void; failed: (err: unknown) => void } { const callInfo = getCallSourceFromStack() + // No user-code frame means the assert came from a dependency or framework + // internal, not the user's test — drop it so it never reaches the trace. + if (callInfo.filePath === undefined) { + return { passed: () => {}, failed: () => {} } + } const startedAt = Date.now() const sanitizedArgs = args.map(safeSerializeAssertArg) const emit = (result: 'passed' | undefined, error: Error | undefined) => diff --git a/packages/core/tests/assert-patcher.test.ts b/packages/core/tests/assert-patcher.test.ts index 641c2e59..86774967 100644 --- a/packages/core/tests/assert-patcher.test.ts +++ b/packages/core/tests/assert-patcher.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest' +import { describe, it, expect, beforeEach, vi } from 'vitest' import assert from 'node:assert' import { ASSERT_PATCHED_SYMBOL, @@ -8,6 +8,19 @@ import { safeSerializeAssertArg, type CapturedAssert } from '../src/assert-patcher.js' +import { getCallSourceFromStack } from '../src/stack.js' + +// Stub the call-source resolver so tests choose whether an assert looks like +// it originated in user code (a frame) or a dependency/internal (no frame). +vi.mock('../src/stack.js', () => ({ + getCallSourceFromStack: vi.fn() +})) + +const USER_FRAME = { + filePath: '/specs/login.ts', + callSource: '/specs/login.ts:12:3' +} +const INTERNAL_FRAME = { filePath: undefined, callSource: 'unknown:0' } // Snapshot real methods once so each test starts from a fresh assert. const ASSERT_MUT = assert as unknown as Record @@ -21,6 +34,8 @@ beforeEach(() => { for (const m of TRACKED_ASSERT_METHODS) { ASSERT_MUT[m] = originals[m] } + // Default: every assert looks like it came from user code so captures fire. + vi.mocked(getCallSourceFromStack).mockReturnValue(USER_FRAME) }) describe('safeSerializeAssertArg', () => { @@ -68,6 +83,51 @@ describe('patchNodeAssert', () => { expect(results).toEqual(['passed', undefined]) // first resolved, second rejected }) + // Only assertions that originate in user test code should reach the trace. + // Dependency/framework-internal asserts have no user-code frame on the + // stack (getCallSourceFromStack yields 'unknown:0') and must be dropped. + describe('user-origin filtering', () => { + it('drops a passing assert that has no user-code frame', () => { + vi.mocked(getCallSourceFromStack).mockReturnValue(INTERNAL_FRAME) + const captured: CapturedAssert[] = [] + patchNodeAssert((c) => captured.push(c)) + assert.equal(1, 1) + expect(captured).toHaveLength(0) + }) + + it('drops a failing internal assert but still re-throws', () => { + vi.mocked(getCallSourceFromStack).mockReturnValue(INTERNAL_FRAME) + const captured: CapturedAssert[] = [] + patchNodeAssert((c) => captured.push(c)) + expect(() => assert.equal(1, 2)).toThrow() + expect(captured).toHaveLength(0) + }) + + it('drops an internal async assert (rejects/doesNotReject)', async () => { + vi.mocked(getCallSourceFromStack).mockReturnValue(INTERNAL_FRAME) + const captured: CapturedAssert[] = [] + patchNodeAssert((c) => captured.push(c)) + await assert.doesNotReject(async () => 1) + await expect(assert.rejects(async () => 1)).rejects.toThrow() + expect(captured).toHaveLength(0) + }) + + it('keeps a user-origin assert and carries its callSource through', () => { + vi.mocked(getCallSourceFromStack).mockReturnValue(USER_FRAME) + const captured: CapturedAssert[] = [] + patchNodeAssert((c) => captured.push(c)) + assert.equal(1, 1) + expect(() => assert.equal(1, 2)).toThrow() + expect(captured).toHaveLength(2) + expect(captured[0]).toMatchObject({ + result: 'passed', + callSource: USER_FRAME.callSource + }) + expect(captured[1].result).toBeUndefined() + expect(captured[1].error).toBeInstanceOf(Error) + }) + }) + it('is idempotent — second patch is a no-op and sets the guard symbol', () => { const a: CapturedAssert[] = [] patchNodeAssert((c) => a.push(c)) From c7f3ca4560d45fc5adcc686e06b8f2fb39d00d20 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Wed, 8 Jul 2026 12:40:08 +0530 Subject: [PATCH 21/21] fix(service): give Cucumber scenario-outline rows distinct trace groups --- packages/service/src/action-snapshot.ts | 28 +++- packages/service/src/index.ts | 126 +++++++----------- packages/service/src/test-metadata.ts | 13 ++ packages/service/tests/trace-metadata.test.ts | 21 ++- 4 files changed, 108 insertions(+), 80 deletions(-) diff --git a/packages/service/src/action-snapshot.ts b/packages/service/src/action-snapshot.ts index 96cf60b4..12710114 100644 --- a/packages/service/src/action-snapshot.ts +++ b/packages/service/src/action-snapshot.ts @@ -8,9 +8,13 @@ // library. No external input reaches new Function() — the lint flag here is // a false positive given the closed input set. -import { captureActionSnapshot as coreCapture } from '@wdio/devtools-core' +import { + captureActionSnapshot as coreCapture, + mapCommandToAction +} from '@wdio/devtools-core' import type { ActionSnapshot } from '@wdio/devtools-shared' import { isNativeMobile, mobilePlatform } from './mobile.js' +import { INTERNAL_COMMANDS } from './constants.js' function reviveScript(src: string): () => unknown { // `src` from core/element-scripts is already a self-invoking IIFE @@ -55,6 +59,28 @@ export async function waitForActionResult( await browser.pause(250).catch(() => undefined) } +/** Post-action capture: settle the resulting page, screenshot it, and push the + * snapshot stamped at the latest logged action. No-op for internal/non-mapped + * commands. Skipped by the caller outside trace mode. */ +export async function captureActionResult( + browser: WebdriverIO.Browser, + command: string, + actionSnapshots: ActionSnapshot[], + stampTimestamp: () => number +): Promise { + if (!mapCommandToAction(command) || INTERNAL_COMMANDS.includes(command)) { + return + } + if (!isNativeMobile(browser)) { + await waitForActionResult(browser) + } + const snap = await captureActionSnapshot(browser, command) + if (snap) { + snap.timestamp = stampTimestamp() + actionSnapshots.push(snap) + } +} + export function captureActionSnapshot( browser: WebdriverIO.Browser, command: string diff --git a/packages/service/src/index.ts b/packages/service/src/index.ts index 44be2498..cf984f20 100644 --- a/packages/service/src/index.ts +++ b/packages/service/src/index.ts @@ -1,7 +1,6 @@ /// import logger from '@wdio/logger' import { - deterministicUid, errorMessage, finalizeScreencast, finalizeTraceExport, @@ -14,11 +13,15 @@ import { type TraceExportContext } from '@wdio/devtools-core' import { captureExpectFailure, wireAssertCapture } from './assert-capture.js' -import { stampTestState, testMetadataUid } from './test-metadata.js' +import { + cucumberScenarioUid, + stampTestState, + testMetadataUid +} from './test-metadata.js' import { resolveCallSourceFromFrame } from './call-source.js' import { - captureActionSnapshot, - waitForActionResult + captureActionResult, + captureActionSnapshot } from './action-snapshot.js' import { dedupeSnapshotsByTimestamp, @@ -128,6 +131,21 @@ export default class DevToolsHookService implements Services.ServiceInstance { ) } + /** Record a spec boundary (spec granularity) and flush the previous spec. */ + #recordBoundary(specFile: string | undefined): void { + if (!specFile) { + return + } + const prevRange = recordSpecBoundary( + this.#boundaryContext, + specFile, + this.#options.traceGranularity + ) + if (prevRange) { + this.#fireAndForgetFlush(prevRange) + } + } + /** Assemble the framework-agnostic trace-export context from this service's * state. Output dir ignores the spec range — WDIO writes next to config. */ #traceContext(browser: WebdriverIO.Browser): TraceExportContext { @@ -269,33 +287,24 @@ export default class DevToolsHookService implements Services.ServiceInstance { } } - /** - * Hook for Cucumber framework. - * Detects feature-file boundaries for per-spec tracing and tags commands - * with a stable testUid so tracingGroup spans render in the trace output. - * WDIO passes the Cucumber World as the first argument. - */ - beforeScenario(world?: { pickle?: { uri?: string; name?: string } }) { + /** Cucumber hook: records feature-file boundaries and tags commands with a stable testUid. */ + beforeScenario(world?: { + pickle?: { uri?: string; name?: string; astNodeIds?: readonly string[] } + }) { this.resetStack() const featureFile = world?.pickle?.uri const scenarioName = world?.pickle?.name - // ── Per-spec boundary detection (Cucumber) ── - if (featureFile) { - const prevRange = recordSpecBoundary( - this.#boundaryContext, - featureFile, - this.#options.traceGranularity - ) - if (prevRange) { - this.#fireAndForgetFlush(prevRange) - } - } + this.#recordBoundary(featureFile) // ── Test identity for command tagging ── if (featureFile && scenarioName) { - const uid = deterministicUid(featureFile, scenarioName) + const uid = cucumberScenarioUid( + featureFile, + scenarioName, + world?.pickle?.astNodeIds + ) this.#currentTestUid = uid this.#testMetadata.set(uid, { title: scenarioName, @@ -304,28 +313,11 @@ export default class DevToolsHookService implements Services.ServiceInstance { } } - /** - * Hook for Mocha/Jasmine frameworks. - * It does the exact same thing as beforeScenario. - */ + /** Mocha/Jasmine hook: the beforeScenario equivalent for file-based specs. */ beforeTest(test?: { file?: string; title?: string; fullTitle?: string }) { this.resetStack() - const newSpec = test?.file - - // ── Per-spec boundary detection ── - // Only tracked when traceGranularity is 'spec'. Records array index - // ranges so #flushSpecTrace can slice the accumulated data per spec. - if (newSpec) { - const prevRange = recordSpecBoundary( - this.#boundaryContext, - newSpec, - this.#options.traceGranularity - ) - if (prevRange) { - this.#fireAndForgetFlush(prevRange) - } - } + this.#recordBoundary(test?.file) // Track test identity for command tagging. Generate a stable UID // from file + title so commands can be partitioned across reruns. @@ -364,12 +356,15 @@ export default class DevToolsHookService implements Services.ServiceInstance { } async afterScenario( - world?: { pickle?: { uri?: string; name?: string } }, + world?: { + pickle?: { uri?: string; name?: string; astNodeIds?: readonly string[] } + }, result?: { error?: unknown; passed?: boolean; skipped?: boolean } ) { - const { uri, name } = world?.pickle ?? {} + const { uri, name, astNodeIds } = world?.pickle ?? {} if (uri && name) { - stampTestState(this.#testMetadata, deterministicUid(uri, name), result) + const uid = cucumberScenarioUid(uri, name, astNodeIds) + stampTestState(this.#testMetadata, uid, result) } await this.#finalizePerScenario() } @@ -417,38 +412,6 @@ export default class DevToolsHookService implements Services.ServiceInstance { return Date.now() } - // Post-action capture: the state RESULTING from the action just completed. - // The pre-action capture in beforeCommand only records the state before the - // NEXT mapped action — when intervening commands (assertions, reloadSession) - // change the page first, an action's result (e.g. the page a click navigated - // to) is never captured. - // - // readyState alone is unreliable: right after a click the OLD document still - // reports 'complete', so a naive wait snapshots a blank mid-navigation frame. - // Instead, beforeCommand tags the document; if the tag is gone the action - // navigated, so we wait for the NEW document to finish loading AND render - // content before screenshotting its destination. Stamped at this command's - // own end (the latest logged action). - async #captureActionResult(command: string): Promise { - if ( - this.#options.mode !== 'trace' || - !this.#browser || - !mapCommandToAction(command) || - INTERNAL_COMMANDS.includes(command) - ) { - return - } - const browser = this.#browser - if (!isNativeMobile(browser)) { - await waitForActionResult(browser) - } - const snap = await captureActionSnapshot(browser, command) - if (snap) { - snap.timestamp = this.#lastActionTimestamp() - this.#actionSnapshots.push(snap) - } - } - private resetStack() { this.#commandStack = [] this.#sessionCapturer.resetLastSelector() @@ -557,7 +520,14 @@ export default class DevToolsHookService implements Services.ServiceInstance { frame.startTimestamp, this.#currentTestUid ) - await this.#captureActionResult(command) + if (this.#options.mode === 'trace') { + await captureActionResult( + this.#browser, + command, + this.#actionSnapshots, + () => this.#lastActionTimestamp() + ) + } return captured } } diff --git a/packages/service/src/test-metadata.ts b/packages/service/src/test-metadata.ts index bf802762..339e1dc1 100644 --- a/packages/service/src/test-metadata.ts +++ b/packages/service/src/test-metadata.ts @@ -13,6 +13,19 @@ export function testMetadataUid( return file ? deterministicUid(file, title) : title } +/** Scenario key that separates scenario-outline example rows: they share a + * name, so the pickle's astNodeIds (distinct per row, stable across reruns) + * are folded in. beforeScenario and afterScenario derive it identically. */ +export function cucumberScenarioUid( + uri: string, + name: string, + astNodeIds?: readonly string[] +): string { + return astNodeIds?.length + ? deterministicUid(uri, name, astNodeIds.join(':')) + : deterministicUid(uri, name) +} + /** Canonical test state from a WDIO afterTest/afterScenario result. */ export function resultToState(result: { passed?: boolean diff --git a/packages/service/tests/trace-metadata.test.ts b/packages/service/tests/trace-metadata.test.ts index 044067c2..fe88dc1a 100644 --- a/packages/service/tests/trace-metadata.test.ts +++ b/packages/service/tests/trace-metadata.test.ts @@ -1,7 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import type * as DevtoolsCore from '@wdio/devtools-core' import { deterministicUid } from '@wdio/devtools-core' -import { resultToState, testMetadataUid } from '../src/test-metadata.js' +import { + cucumberScenarioUid, + resultToState, + testMetadataUid +} from '../src/test-metadata.js' // Captures the ctx handed to finalizeTraceExport so the test can inspect the // state stamped onto testMetadata and the policy that flowed in. @@ -36,6 +40,7 @@ vi.mock('../src/session.js', () => ({ // Keep the after* hooks from touching a real browser/CDP. vi.mock('../src/action-snapshot.js', () => ({ captureActionSnapshot: vi.fn().mockResolvedValue(null), + captureActionResult: vi.fn().mockResolvedValue(undefined), waitForActionResult: vi.fn().mockResolvedValue(undefined) })) @@ -66,6 +71,20 @@ describe('test-metadata helpers', () => { ) expect(testMetadataUid(undefined, 'renders')).toBe('renders') }) + + it('cucumberScenarioUid separates outline rows sharing a name by astNodeIds', () => { + const uri = '/proj/features/login.feature' + const row1 = cucumberScenarioUid(uri, 'log in', ['node-1']) + const row2 = cucumberScenarioUid(uri, 'log in', ['node-2']) + // Distinct example rows → distinct uids (so they render as separate groups). + expect(row1).not.toBe(row2) + // A rerun of the same row → same uid (retry-coalescing stays intact). + expect(cucumberScenarioUid(uri, 'log in', ['node-1'])).toBe(row1) + // No astNodeIds → plain name-based uid. + expect(cucumberScenarioUid(uri, 'log in')).toBe( + deterministicUid(uri, 'log in') + ) + }) }) describe('DevtoolsService - afterTest state stamping', () => {