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` +
+
+
+ ${this.frames.map( + (frame) => + 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