Skip to content

Commit ae77e80

Browse files
authored
Merge pull request #523 from webdriverio/feat/501-open-whole-run
Open several trace zips or a whole run in the player
2 parents 6644ede + 0ca62df commit ae77e80

32 files changed

Lines changed: 2029 additions & 224 deletions

‎.changeset/open-whole-run.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@wdio/devtools-app': minor
3+
'@wdio/devtools-backend': minor
4+
---
5+
6+
The trace player opens several traces at once. `show-trace` accepts several zips or a folder (`npx show-trace test-results/`), and the hosted player takes several zips through its Open button, or zips and folders dropped on the page. The player then lists every trace beside the timeline, one row per test with its outcome, failures first and then in run order, and plays the one picked without reloading. With `traceGranularity: 'test'` a run is a folder of per-test zips, so the whole run opens in one go. A zip records no test state, so the outcome is read from the trace: failed when an action or step failed, passed when it holds actions and none failed, unknown when it holds none. Files in the folder that are not zips are skipped, and a zip that cannot be opened is listed with the reason without stopping the rest. Opening a single zip works exactly as before.

‎CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ They import from `shared` and `core`, never from each other. They aren't importe
105105

106106
### Backend and app are framework-agnostic
107107

108-
`backend` and `app` import from `shared` (for contracts) and from each other via the WS/HTTP boundary. Neither imports an adapter package. `backend` also imports `trace`, to write and read zips; `app` imports only `trace`'s reader, which the hosted player (`trace.webdriver.io`, built with `pnpm player:hosted`, deployed by Vercel from `vercel.json`) runs in the browser to open a dropped zip with no backend at all. It runs the reader in a module Web Worker loaded from the player's own origin (`app/src/controller/trace-parse.worker.ts`, which `script-src 'self'` already admits), so the tab stays responsive and the landing screen names the phase the open is on; the live build never references the worker. That build is `HOSTED_PLAYER` in `app/src/controller/constants.ts`; it never writes the trace to browser storage, and `vercel.json` is also what `vite preview` serves, so its CSP is checked locally before it ships.
108+
`backend` and `app` import from `shared` (for contracts) and from each other via the WS/HTTP boundary. Neither imports an adapter package. `backend` also imports `trace`, to write and read zips; `app` imports only `trace`'s reader, which the hosted player (`trace.webdriver.io`, built with `pnpm player:hosted`, deployed by Vercel from `vercel.json`) runs in the browser to open a dropped zip with no backend at all. It runs the reader in a module Web Worker loaded from the player's own origin (`app/src/controller/trace-parse.worker.ts`, which `script-src 'self'` already admits), so the tab stays responsive and the landing screen names the phase the open is on; the live build never references the worker. That build is `HOSTED_PLAYER` in `app/src/controller/constants.ts`; it never writes the trace to browser storage, and `vercel.json` is also what `vite preview` serves, so its CSP is checked locally before it ships. Several zips or a folder open as a run (`controller/trace-run.ts`), in the hosted player and in `show-trace`, which lists them at `TRACE_API.run` for the app to read: each zip is parsed once, one at a time, and its payload kept for when its row is picked. A zip records no test state, so a row's outcome is read from its rows (`utils/trace-summary.ts`: a failed action or step means failed, actions with none failed mean passed, no actions mean unknown); a jest `expect` that records no row can therefore fail a test whose trace reads as passed. Keeping every payload costs memory in proportion to the run; a reader that reads entries on demand would shrink each payload, which is why the run holds the reader's own payload rather than a cache of its own.
109109

110110
Framework-specific behavior in the backend is contained in two files: `runner.ts` and `framework-filters.ts`. Both branch on a typed `TestRunnerId` from shared, never on a magic string. The `framework-filters` dispatch is a `switch` over `TestRunnerId` (not a table lookup) so CodeQL's `unvalidated-dynamic-method-call` query trusts the call site.
111111

‎README.md‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,16 @@ The player exposes everything captured in the archive:
262262

263263
The `show-trace` bin is exposed by each JavaScript adapter (`@wdio/devtools-service`, `@wdio/nightwatch-devtools`, `@wdio/selenium-devtools`), so `pnpm show-trace <zip>` / `npx show-trace <zip>` work in any project that installs one — no extra dependency.
264264

265-
**Hosted player: [trace.webdriver.io](https://trace.webdriver.io).** The same player as a web page, for opening a trace without installing anything: drop a `.zip` on the page or pick one. The archive is parsed in the browser tab and never uploaded, which matters because a trace records everything the run showed or typed. Run controls are absent, as in `show-trace`. A replayed page still loads its own stylesheets and images from the site under test, as it does in `show-trace`; from an `https` page the browser blocks the `http` ones, so a trace of an `http` site can render unstyled there and fully in `show-trace`. To try the hosted build locally:
265+
**A whole run at once.** With `traceGranularity: 'test'` every test writes its own `trace.zip`, so a run is a folder of them. Give `show-trace` the folder, or several zips, and the player lists every trace beside the timeline, one row per test with its outcome, failures first and then in the order they ran; picking a row plays that trace without reloading (`↑`/`↓` move between rows, `Enter` opens one). The hosted player does the same for several zips picked with its one Open button, or for a folder dropped on the page (a browser's file dialog picks files or folders, never both).
266+
267+
```sh
268+
pnpm show-trace test-results/ # every zip under the folder
269+
pnpm show-trace a/trace.zip b/trace.zip # several zips
270+
```
271+
272+
Each zip is read once, as the list fills in, and kept for when it is picked. Anything in the folder that is not a `.zip` (screenshots, videos, the artifacts manifest) is skipped; a zip that cannot be opened stays in the list with the reason, and the rest open normally. A zip records no test state, so the outcome is read from the trace itself: **failed** when an action or step in it failed, **passed** when it holds actions and none failed, **unknown** when it holds none. An assertion library that records no row (a jest `expect`, chai) can fail a test whose trace still reads as passed. One zip opens exactly as before, with no list.
273+
274+
**Hosted player: [trace.webdriver.io](https://trace.webdriver.io).** The same player as a web page, for opening a trace without installing anything: pick one or several `.zip` files, or drop them or a folder of them on the page. The archive is parsed in the browser tab and never uploaded, which matters because a trace records everything the run showed or typed. Run controls are absent, as in `show-trace`. A replayed page still loads its own stylesheets and images from the site under test, as it does in `show-trace`; from an `https` page the browser blocks the `http` ones, so a trace of an `http` site can render unstyled there and fully in `show-trace`. To try the hosted build locally:
266275

267276
```sh
268277
pnpm player:hosted # builds it and serves it at http://localhost:4173 under the production headers
@@ -534,6 +543,8 @@ With `mode: 'trace'` no UI opens — the run writes a portable `trace.zip` under
534543
pnpm show-trace test-results/trace-<sessionId>.zip
535544
# or from a project that installs an adapter:
536545
npx show-trace <path-to-trace.zip>
546+
# a folder of per-test zips opens as a list of tests:
547+
npx show-trace test-results/
537548
```
538549

539550
<p align="center">

‎packages/app/src/app.ts‎

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ import { TraceType, type CommandLog } from '@wdio/devtools-shared'
66
import { Element } from '@core/element'
77
import { DataManagerController } from './controller/DataManager.js'
88
import { TraceOpenController } from './controller/trace-open.js'
9+
import { TraceRunController } from './controller/trace-run.js'
910
import { KeyboardController, KBD } from './controller/keyboard.js'
1011
import { DragController, Direction } from './utils/DragController.js'
1112
import {
1213
HOSTED_PLAYER,
14+
TRACE_RUN_SELECT_EVENT,
1315
SIDEBAR_MIN_WIDTH,
1416
SIDEBAR_DEFAULT_WIDTH
1517
} from './controller/constants.js'
@@ -26,6 +28,8 @@ import './components/sidebar.js'
2628
import './components/workbench.js'
2729
import './components/onboarding/start.js'
2830
import './components/onboarding/open-trace.js'
31+
import './components/trace-run-list.js'
32+
import './components/placeholder.js'
2933
import './components/workbench/compare.js'
3034
import './components/shortcuts-overlay.js'
3135

@@ -40,16 +44,25 @@ onThemeChange(applyDarkMode)
4044
@customElement('wdio-devtools')
4145
export class WebdriverIODevtoolsApplication extends Element {
4246
dataManager = new DataManagerController(this)
47+
#traceRun = new TraceRunController(this, (data) =>
48+
this.dataManager.loadPlayerData(data)
49+
)
4350
#traceOpen = HOSTED_PLAYER
44-
? new TraceOpenController(this, (file, onPhase) =>
45-
this.dataManager.openTraceArchive(file, onPhase)
46-
)
51+
? new TraceOpenController(this, {
52+
one: async (bytes, onPhase) => {
53+
await this.dataManager.openTraceArchive(bytes, onPhase)
54+
this.#traceRun.clear()
55+
},
56+
many: (sources) => void this.#traceRun.open(sources)
57+
})
4758
: undefined
4859

4960
@state() showShortcuts = false
5061

5162
constructor() {
5263
super()
64+
this.dataManager.onServedRun = (sources) =>
65+
void this.#traceRun.open(sources)
5366
// Global keyboard shortcuts — a side-effect controller (hooks window
5467
// keydown via addController); active in both the player and live dashboard.
5568
new KeyboardController(this, {
@@ -117,6 +130,7 @@ export class WebdriverIODevtoolsApplication extends Element {
117130
// or a popout — and the Compare tab, which keys on it, kept showing the
118131
// previously selected test's baseline after clicking a different test.
119132
this.addEventListener('app-test-select', this.#onTestSelect)
133+
this.addEventListener(TRACE_RUN_SELECT_EVENT, this.#onTraceRunSelect)
120134
window.addEventListener('show-command', this.#onShowCommand)
121135
window.addEventListener(KBD.step, this.#onKbdStep)
122136
window.addEventListener(KBD.jump, this.#onKbdJump)
@@ -134,6 +148,7 @@ export class WebdriverIODevtoolsApplication extends Element {
134148
disconnectedCallback(): void {
135149
super.disconnectedCallback()
136150
this.removeEventListener('app-test-select', this.#onTestSelect)
151+
this.removeEventListener(TRACE_RUN_SELECT_EVENT, this.#onTraceRunSelect)
137152
window.removeEventListener('show-command', this.#onShowCommand)
138153
window.removeEventListener(KBD.step, this.#onKbdStep)
139154
window.removeEventListener(KBD.jump, this.#onKbdJump)
@@ -143,6 +158,10 @@ export class WebdriverIODevtoolsApplication extends Element {
143158
this.dataManager.setSelectedTestUid((event as CustomEvent<string>).detail)
144159
}
145160

161+
#onTraceRunSelect = (event: CustomEvent<number>): void => {
162+
this.#traceRun.select(event.detail)
163+
}
164+
146165
#onShowCommand = (event: Event): void => {
147166
const command = (event as CustomEvent<{ command?: CommandLog }>).detail
148167
?.command
@@ -250,15 +269,61 @@ export class WebdriverIODevtoolsApplication extends Element {
250269
</div>`
251270
}
252271

272+
#runPane() {
273+
const run = this.#traceRun
274+
if (run.selectedId !== undefined) {
275+
return html`<wdio-devtools-workbench
276+
class="basis-auto"
277+
.playerMode="${true}"
278+
></wdio-devtools-workbench>`
279+
}
280+
if (run.loading) {
281+
return html`<wdio-devtools-placeholder
282+
class="flex-1"
283+
></wdio-devtools-placeholder>`
284+
}
285+
return html`<wdio-devtools-placeholder
286+
class="flex-1"
287+
heading="None of these traces could be opened"
288+
description="Each row says why."
289+
></wdio-devtools-placeholder>`
290+
}
291+
292+
// Several traces opened together: their list where a live run's tree would be.
293+
#runContent() {
294+
return html`
295+
<section
296+
data-resizer-window
297+
class="flex h-[calc(100%-40px)] w-full relative"
298+
>
299+
<wdio-devtools-trace-run-list
300+
style="${this.#drag?.getPosition()}"
301+
.entries="${this.#traceRun.ordered}"
302+
.selectedId="${this.#traceRun.selectedId}"
303+
></wdio-devtools-trace-run-list>
304+
${this.#drag.getSlider('z-10 h-full')} ${this.#runPane()}
305+
${this.#traceOpenBanner()}
306+
</section>
307+
`
308+
}
309+
253310
#mainContent() {
254311
const traceOpen = this.#traceOpen
255-
// Once a trace is loaded, a failed open keeps it and reports in a banner.
256-
if (traceOpen && (traceOpen.opening || !this.dataManager.hasConnection)) {
257-
return html`<wdio-devtools-open-trace
258-
.busy="${traceOpen.opening}"
259-
.phase="${traceOpen.phase}"
260-
.error="${traceOpen.error}"
312+
const landing = (busy: boolean) =>
313+
html`<wdio-devtools-open-trace
314+
.busy="${busy}"
315+
.phase="${traceOpen?.phase}"
316+
.error="${traceOpen?.error}"
261317
></wdio-devtools-open-trace>`
318+
if (traceOpen?.opening) {
319+
return landing(true)
320+
}
321+
if (this.#traceRun.active) {
322+
return this.#runContent()
323+
}
324+
// Once a trace is loaded, a failed open keeps it and reports in a banner.
325+
if (traceOpen && !this.dataManager.hasConnection) {
326+
return landing(false)
262327
}
263328
if (!this.dataManager.hasConnection) {
264329
return html`<wdio-devtools-start></wdio-devtools-start>`

‎packages/app/src/components/header.ts‎

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
} from '../controller/theme.js'
1616
import { HOSTED_PLAYER } from '../controller/constants.js'
1717
import { TRACE_FILE_ACCEPT } from '../controller/constants.js'
18-
import { announceTraceFile, takePickedFile } from '../utils/trace-file.js'
18+
import { announceTraceSources, takePickedSources } from '../utils/trace-file.js'
1919

2020
@customElement('wdio-devtools-header')
2121
export class DevtoolsHeader extends Element {
@@ -115,26 +115,24 @@ export class DevtoolsHeader extends Element {
115115
return html`
116116
<button
117117
class="p-2"
118-
title="Open another trace.zip"
119-
aria-label="Open another trace.zip"
118+
title="Open trace .zip files, or drop a folder of them on the page"
119+
aria-label="Open trace .zip files"
120120
@click="${() => this.traceInput?.click()}"
121121
>
122122
<icon-mdi-upload></icon-mdi-upload>
123123
</button>
124124
<input
125125
type="file"
126126
accept="${TRACE_FILE_ACCEPT}"
127+
multiple
127128
hidden
128-
@change="${this.#onTracePicked}"
129+
@change="${() => this.#onTracePicked(this.traceInput)}"
129130
/>
130131
`
131132
}
132133

133-
#onTracePicked() {
134-
const file = takePickedFile(this.traceInput)
135-
if (file) {
136-
announceTraceFile(this, file)
137-
}
134+
#onTracePicked(input?: HTMLInputElement) {
135+
announceTraceSources(this, takePickedSources(input))
138136
}
139137

140138
#switchMode() {

‎packages/app/src/components/onboarding/open-trace.ts‎

Lines changed: 34 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,13 @@ import {
77
TRACE_OPEN_PHASE_LABEL
88
} from '../../controller/constants.js'
99
import type { TraceOpenPhase } from '../../controller/trace-parse-protocol.js'
10-
import { announceTraceFile, takePickedFile } from '../../utils/trace-file.js'
10+
import {
11+
announceTraceSources,
12+
takePickedSources
13+
} from '../../utils/trace-file.js'
14+
import type { TraceSource } from '../../utils/trace-sources.js'
1115

12-
/** Landing screen of the hosted trace player: pick or drop a trace.zip. */
16+
/** Landing screen of the hosted trace player: pick or drop trace zips or a folder of them. */
1317
@customElement('wdio-devtools-open-trace')
1418
export class DevtoolsOpenTrace extends Element {
1519
@property({ type: Boolean }) busy = false
@@ -109,9 +113,9 @@ export class DevtoolsOpenTrace extends Element {
109113
`
110114
]
111115

112-
#open(file: File | undefined) {
113-
if (file && !this.busy) {
114-
announceTraceFile(this, file)
116+
#open(sources: readonly TraceSource[]) {
117+
if (!this.busy) {
118+
announceTraceSources(this, sources)
115119
}
116120
}
117121

@@ -128,25 +132,39 @@ export class DevtoolsOpenTrace extends Element {
128132
}
129133
}
130134

135+
// Left to bubble: the app opens a drop anywhere on the window, this one included.
131136
#onDrop(event: DragEvent) {
132137
event.preventDefault()
133-
// The app also opens files dropped anywhere on the window.
134-
event.stopPropagation()
135138
this.dragging = false
136-
this.#open(event.dataTransfer?.files[0])
137139
}
138140

139-
#onPick() {
140-
this.#open(takePickedFile(this.input))
141+
#onPick(input?: HTMLInputElement) {
142+
this.#open(takePickedSources(input))
141143
}
142144

143145
#status(): string {
144146
if (!this.busy) {
145-
return 'Drop a trace.zip here'
147+
return 'Drop trace .zip files or a folder of them here'
146148
}
147149
return this.phase ? TRACE_OPEN_PHASE_LABEL[this.phase] : 'Opening trace…'
148150
}
149151

152+
// A picker takes files or folders, never both, so folders come in by drop.
153+
#pickers() {
154+
return html`
155+
<button ?disabled="${this.busy}" @click="${() => this.input?.click()}">
156+
Open traces
157+
</button>
158+
<input
159+
type="file"
160+
accept="${TRACE_FILE_ACCEPT}"
161+
multiple
162+
hidden
163+
@change="${() => this.#onPick(this.input)}"
164+
/>
165+
`
166+
}
167+
150168
render() {
151169
return html`
152170
<div
@@ -160,29 +178,18 @@ export class DevtoolsOpenTrace extends Element {
160178
<div class="drop ${this.dragging ? 'dragging' : ''}">
161179
<p class="text-lg" role="status">${this.#status()}</p>
162180
${this.busy ? html`<div class="progress"></div>` : nothing}
163-
<button
164-
?disabled="${this.busy}"
165-
@click="${() => this.input?.click()}"
166-
>
167-
Select file
168-
</button>
169-
<input
170-
type="file"
171-
accept="${TRACE_FILE_ACCEPT}"
172-
hidden
173-
@change="${this.#onPick}"
174-
/>
181+
${this.#pickers()}
175182
</div>
176183
${
177184
this.error
178185
? html`<p class="error" role="alert">${this.error}</p>`
179186
: nothing
180187
}
181188
<p class="muted text-sm max-w-xl text-center">
182-
The trace is opened in this browser tab and never leaves your machine.
183-
To open one offline, run
184-
<code>npx show-trace trace.zip</code> in a project with WebdriverIO
185-
DevTools installed.
189+
Traces are opened in this browser tab and never leave your machine. To
190+
open them offline, run
191+
<code>npx show-trace trace.zip</code> (or a folder of traces) in a
192+
project with WebdriverIO DevTools installed.
186193
</p>
187194
</div>
188195
`
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { css, html, type TemplateResult } from 'lit'
2+
3+
import '~icons/mdi/check.js'
4+
import '~icons/mdi/close.js'
5+
import '~icons/mdi/debug-step-over.js'
6+
import '~icons/mdi/circle-outline.js'
7+
import '~icons/mdi/alert-circle-outline.js'
8+
9+
/** What a row's status icon can say; `unreadable` is a trace the player could not open. */
10+
export type StateIconKind =
11+
'running' | 'passed' | 'failed' | 'skipped' | 'idle' | 'unreadable'
12+
13+
/** For any host that renders a `running` icon. */
14+
export const STATE_ICON_STYLES = css`
15+
.run-dot {
16+
width: 8px;
17+
height: 8px;
18+
border-radius: 50%;
19+
background: var(--vscode-charts-blue);
20+
animation: run-pulse 1.5s ease-in-out infinite;
21+
}
22+
@keyframes run-pulse {
23+
0%,
24+
100% {
25+
opacity: 1;
26+
transform: scale(1);
27+
}
28+
50% {
29+
opacity: 0.4;
30+
transform: scale(0.7);
31+
}
32+
}
33+
`
34+
35+
const ICONS: Record<StateIconKind, TemplateResult> = {
36+
running: html`<span class="run-dot"></span>`,
37+
passed: html`<icon-mdi-check class="text-chartsGreen"></icon-mdi-check>`,
38+
failed: html`<icon-mdi-close class="text-chartsRed"></icon-mdi-close>`,
39+
skipped: html`<icon-mdi-debug-step-over
40+
class="text-chartsYellow"
41+
></icon-mdi-debug-step-over>`,
42+
idle: html`<icon-mdi-circle-outline
43+
class="text-disabledForeground"
44+
></icon-mdi-circle-outline>`,
45+
unreadable: html`<icon-mdi-alert-circle-outline
46+
class="text-chartsRed"
47+
></icon-mdi-alert-circle-outline>`
48+
}
49+
50+
/** Centred on the row's first line, so it lines up with the row's buttons. */
51+
export function stateIcon(kind: StateIconKind): TemplateResult {
52+
return html`<span
53+
class="w-4 h-[18px] shrink-0 self-center grid place-items-center"
54+
>${ICONS[kind]}</span
55+
>`
56+
}

0 commit comments

Comments
 (0)