diff --git a/packages/app/src/app.ts b/packages/app/src/app.ts
index 43d7afc7..df362277 100644
--- a/packages/app/src/app.ts
+++ b/packages/app/src/app.ts
@@ -1,7 +1,7 @@
import './tailwind.css'
import { css, html, nothing } from 'lit'
import { customElement, query } from 'lit/decorators.js'
-import { TraceType, type TraceLog } from '@wdio/devtools-shared'
+import { TraceType } from '@wdio/devtools-shared'
import { Element } from '@core/element'
import { DataManagerController } from './controller/DataManager.js'
@@ -95,7 +95,6 @@ export class WebdriverIODevtoolsApplication extends Element {
connectedCallback(): void {
super.connectedCallback()
- window.addEventListener('load-trace', this.#loadTrace.bind(this))
this.addEventListener(
'clear-execution-data',
this.#clearExecutionData.bind(this)
@@ -120,11 +119,6 @@ export class WebdriverIODevtoolsApplication extends Element {
`
}
- #loadTrace({ detail }: { detail: TraceLog }) {
- this.dataManager.loadTraceFile(detail)
- this.requestUpdate()
- }
-
#clearExecutionData({
detail
}: {
@@ -152,7 +146,10 @@ export class WebdriverIODevtoolsApplication extends Element {
: nothing
}
${this.#drag.getSlider('z-10 h-full')}
-
+
`
}
diff --git a/packages/app/src/components/browser/trace-timeline-constants.ts b/packages/app/src/components/browser/trace-timeline-constants.ts
new file mode 100644
index 00000000..d88f1c61
--- /dev/null
+++ b/packages/app/src/components/browser/trace-timeline-constants.ts
@@ -0,0 +1,19 @@
+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'
+}
diff --git a/packages/app/src/components/browser/trace-timeline-utils.ts b/packages/app/src/components/browser/trace-timeline-utils.ts
new file mode 100644
index 00000000..1083ee65
--- /dev/null
+++ b/packages/app/src/components/browser/trace-timeline-utils.ts
@@ -0,0 +1,17 @@
+/** 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'
+}
+
+/** `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
+ const totalSeconds = Math.floor(safe / 1000)
+ const minutes = Math.floor(totalSeconds / 60)
+ const seconds = totalSeconds % 60
+ const centis = Math.floor((safe % 1000) / 10)
+ return `${minutes}:${seconds.toString().padStart(2, '0')}.${centis
+ .toString()
+ .padStart(2, '0')}`
+}
diff --git a/packages/app/src/components/browser/trace-timeline.ts b/packages/app/src/components/browser/trace-timeline.ts
new file mode 100644
index 00000000..24d71baa
--- /dev/null
+++ b/packages/app/src/components/browser/trace-timeline.ts
@@ -0,0 +1,492 @@
+import { Element } from '@core/element'
+import { html, css, nothing, 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 { activeTimestampAt } from '../workbench/active-entry.js'
+import {
+ CATEGORY_BG,
+ GUTTER,
+ INSET,
+ SPEEDS
+} from './trace-timeline-constants.js'
+import { formatTimecode, imageMime } from './trace-timeline-utils.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.
+ */
+@customElement(COMPONENT)
+export class TraceTimeline extends Element {
+ @consume({ context: commandContext, subscribe: true })
+ @state()
+ commands: CommandLog[] = []
+
+ @consume({ context: framesContext, subscribe: true })
+ @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
+ @state() speed = 1
+
+ #rafId?: number
+ #rafLast = 0
+ #activeTimestamp?: number
+ #started = false
+
+ @query('[data-lanes]') lanesEl?: HTMLElement
+
+ #dragging = false
+
+ static styles = [
+ ...Element.styles,
+ css`
+ :host {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ overflow: hidden;
+ 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;
+ }
+ `
+ ]
+
+ disconnectedCallback(): void {
+ super.disconnectedCallback()
+ this.#stopRaf()
+ window.removeEventListener('pointermove', this.#onPointerMove)
+ window.removeEventListener('pointerup', this.#onPointerUp)
+ }
+
+ // ─── window / geometry ────────────────────────────────────────────────────
+
+ get #start(): number {
+ const first = this.#sortedCommands[0]
+ const frameStart = this.frames[0]?.timestamp ?? Infinity
+ const cmdStart = first ? (first.startTime ?? first.timestamp) : Infinity
+ const min = Math.min(frameStart, cmdStart)
+ return Number.isFinite(min) ? min : 0
+ }
+
+ get #end(): number {
+ const lastFrame = this.frames[this.frames.length - 1]?.timestamp ?? 0
+ const lastCmd = this.#sortedCommands[this.#sortedCommands.length - 1]
+ const cmdEnd = lastCmd?.timestamp ?? 0
+ return Math.max(lastFrame, cmdEnd)
+ }
+
+ get #duration(): number {
+ return Math.max(1, this.#end - this.#start)
+ }
+
+ get #sortedCommands(): CommandLog[] {
+ return [...this.commands].sort(
+ (a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0)
+ )
+ }
+
+ #fraction(timestamp: number): number {
+ return Math.min(1, Math.max(0, (timestamp - this.#start) / this.#duration))
+ }
+
+ // ─── playback ─────────────────────────────────────────────────────────────
+
+ protected firstUpdated(): void {
+ // Defer one frame so the browser pane (which attaches its `show-command`
+ // listener after `await updateComplete`) is ready to receive the first frame.
+ requestAnimationFrame(() => this.#syncActiveCommand())
+ }
+
+ protected updated(): void {
+ // Commands may arrive after mount; emit the first frame once they do.
+ if (!this.#started && this.commands.length) {
+ this.#syncActiveCommand()
+ }
+ }
+
+ #stopRaf(): void {
+ if (this.#rafId !== undefined) {
+ cancelAnimationFrame(this.#rafId)
+ this.#rafId = undefined
+ }
+ }
+
+ #togglePlay(): void {
+ if (this.playing) {
+ this.playing = false
+ this.#stopRaf()
+ return
+ }
+ if (this.currentMs >= this.#duration) {
+ this.currentMs = 0
+ }
+ this.playing = true
+ this.#rafLast = performance.now()
+ this.#rafId = requestAnimationFrame(this.#tick)
+ }
+
+ #tick = (now: number): void => {
+ const delta = (now - this.#rafLast) * this.speed
+ this.#rafLast = now
+ const next = this.currentMs + delta
+ if (next >= this.#duration) {
+ this.currentMs = this.#duration
+ this.playing = false
+ this.#stopRaf()
+ this.#syncActiveCommand()
+ return
+ }
+ this.currentMs = next
+ this.#syncActiveCommand()
+ this.#rafId = requestAnimationFrame(this.#tick)
+ }
+
+ #seekToMs(ms: number): void {
+ this.currentMs = Math.min(this.#duration, Math.max(0, ms))
+ this.#syncActiveCommand()
+ }
+
+ #seekToTimestamp(timestamp: number): void {
+ this.#seekToMs(timestamp - this.#start)
+ }
+
+ #step(direction: -1 | 1): void {
+ const timestamps = this.#sortedCommands.map((c) => c.timestamp ?? 0)
+ const clock = this.#start + this.currentMs
+ if (direction === 1) {
+ const next = timestamps.find((ts) => ts > clock + 1)
+ if (next !== undefined) {
+ this.#seekToTimestamp(next)
+ }
+ return
+ }
+ const prev = [...timestamps].reverse().find((ts) => ts < clock - 1)
+ this.#seekToTimestamp(prev ?? this.#start)
+ }
+
+ #restart(): void {
+ this.playing = false
+ this.#stopRaf()
+ this.#seekToMs(0)
+ }
+
+ // Dispatch `show-command` for the action active at the current clock so the
+ // (reused) browser pane updates its screenshot.
+ #syncActiveCommand(): void {
+ const sorted = this.#sortedCommands
+ if (!sorted.length) {
+ return
+ }
+ const clock = this.#start + this.currentMs
+ const timestamps = sorted.map((c) => c.timestamp ?? 0)
+ const activeTs = activeTimestampAt(timestamps, clock) ?? timestamps[0]
+ if (this.#started && activeTs === this.#activeTimestamp) {
+ return
+ }
+ this.#started = true
+ this.#activeTimestamp = activeTs
+ const command = sorted.find((c) => (c.timestamp ?? 0) === activeTs)
+ if (command) {
+ window.dispatchEvent(
+ new CustomEvent('show-command', { detail: { command } })
+ )
+ }
+ }
+
+ #onSpeedChange(event: Event): void {
+ this.speed = Number((event.target as HTMLSelectElement).value)
+ }
+
+ // ─── scrubbing (free-flow playhead drag) ───────────────────────────────────
+
+ #fractionFromClientX(clientX: number): number {
+ const rect = this.lanesEl?.getBoundingClientRect()
+ if (!rect) {
+ 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))
+ }
+
+ #onPointerDown = (event: PointerEvent): void => {
+ if (event.button !== 0) {
+ return
+ }
+ event.preventDefault()
+ this.playing = false
+ this.#stopRaf()
+ this.#dragging = true
+ window.addEventListener('pointermove', this.#onPointerMove)
+ window.addEventListener('pointerup', this.#onPointerUp)
+ this.#seekToMs(this.#fractionFromClientX(event.clientX) * this.#duration)
+ }
+
+ #onPointerMove = (event: PointerEvent): void => {
+ if (!this.#dragging) {
+ return
+ }
+ this.#seekToMs(this.#fractionFromClientX(event.clientX) * this.#duration)
+ }
+
+ #onPointerUp = (): void => {
+ this.#dragging = false
+ window.removeEventListener('pointermove', this.#onPointerMove)
+ window.removeEventListener('pointerup', this.#onPointerUp)
+ }
+
+ // ─── 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
+ let best: number | undefined
+ let bestDelta = Infinity
+ for (const frame of this.frames) {
+ const delta = Math.abs(frame.timestamp - clock)
+ if (delta < bestDelta) {
+ bestDelta = delta
+ best = frame.timestamp
+ }
+ }
+ 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
+
`
+ }
+ const activeFrame = this.#activeFrameTimestamp
+ return html`
+
+ `
+ }
+
+ #renderTrack(
+ label: string,
+ body: TemplateResult | typeof nothing
+ ): TemplateResult {
+ return html`
+
+
+ ${label}
+
+
${body}
+
+ `
+ }
+
+ #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)
+ return html``
+ })}`
+ return this.#renderTrack('Network', body)
+ }
+
+ #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()}
+
+ `
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ [COMPONENT]: TraceTimeline
+ }
+}
diff --git a/packages/app/src/components/header.ts b/packages/app/src/components/header.ts
index f584f286..05b7f1f4 100644
--- a/packages/app/src/components/header.ts
+++ b/packages/app/src/components/header.ts
@@ -5,9 +5,7 @@ import { customElement } from 'lit/decorators.js'
import '~icons/custom/logo.svg'
import '~icons/mdi/white-balance-sunny.js'
import '~icons/mdi/moon-waning-crescent.js'
-import '~icons/mdi/file-upload-outline.js'
-import './inputs/traceLoader.js'
import { DARK_MODE_KEY } from '../controller/constants.js'
const darkModeInitValue = localStorage.getItem(DARK_MODE_KEY)
@@ -72,7 +70,6 @@ export class DevtoolsHeader extends Element {
WebdriverIO Devtools
Embed into Project
First install WebdriverIO Devtools via:
diff --git a/packages/app/src/components/workbench.ts b/packages/app/src/components/workbench.ts
index b3513b7b..5b87cd9d 100644
--- a/packages/app/src/components/workbench.ts
+++ b/packages/app/src/components/workbench.ts
@@ -1,6 +1,6 @@
import { Element } from '@core/element'
import { html, css, nothing } from 'lit'
-import { customElement, query, state } from 'lit/decorators.js'
+import { customElement, property, query, state } from 'lit/decorators.js'
import { consume } from '@lit/context'
import { DragController, Direction } from '../utils/DragController.js'
@@ -25,6 +25,7 @@ import './workbench/metadata.js'
import './workbench/network.js'
import './workbench/compare.js'
import './browser/snapshot.js'
+import './browser/trace-timeline.js'
import {
MIN_WORKBENCH_HEIGHT,
MIN_METATAB_WIDTH,
@@ -40,6 +41,11 @@ export class DevtoolsWorkbench extends Element {
#workbenchSidebarCollapsed =
localStorage.getItem('workbenchSidebar') === 'true'
+ // Trace-player mode (`pnpm show-trace`): hide the Metadata tab and swap the
+ // workbench tabs for the timeline player.
+ @property({ type: Boolean })
+ playerMode = false
+
@consume({ context: consoleLogContext, subscribe: true })
@state()
consoleLogs: ConsoleLogs[] | undefined = undefined
@@ -167,9 +173,11 @@ export class DevtoolsWorkbench extends Element {
-
-
-
+ ${this.playerMode
+ ? nothing
+ : html`
+
+ `}