Skip to content

Commit 0fd97cf

Browse files
vishnuv688claude
andcommitted
Host the trace player as a static page with no backend
Opening a trace zip needed the DevTools package installed and `show-trace` run against it, which is a lot to ask of whoever the zip is sent to. The app now builds a hosted mode (`vite build --mode hosted`) that opens a zip the viewer drops or picks and parses it in the browser tab, and `vercel.json` deploys it the way benchmark.webdriver.io is deployed. A trace records everything the run showed or typed, so the zip never leaves the tab: no upload, the trace is not written to browser storage, and the CSP allows network calls only to the player's own origin. It does allow a cross-origin `<base>`, styles, fonts and images, which the DOM replay needs to load the recorded site's own stylesheets. The reader had to become browser-safe: the disk read moved to `read-trace-zip.ts`, source paths are hashed with a pure SHA-1 (native `sha1Hex` stays for resource bytes, ~30x faster), and screenshots are base64-encoded without `Buffer`. A test runs the reader on a recorded zip with `Buffer` removed, since an import walk cannot see a global. The ESLint rule barring the app from `trace` now allows `trace-reader` alone. Refs #494 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 0883c25 commit 0fd97cf

35 files changed

Lines changed: 1066 additions & 26 deletions

‎.changeset/hosted-trace-player.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@wdio/devtools-app': minor
3+
---
4+
5+
The trace player can now run as a static web page with no backend: built with `--mode hosted`, it opens a trace `.zip` the viewer drops on the page or picks, parsing it in the browser tab without uploading it or writing it to browser storage. This is the build trace.webdriver.io serves. The live dashboard is unchanged; the zip reader it does not use is loaded only when a file is opened.

‎.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ lerna-debug.log*
1010
node_modules
1111
dist
1212
dist-ssr
13+
dist-hosted
1314
.tsup
1415
*.local
1516

@@ -35,6 +36,7 @@ __pycache__/
3536
/*.json
3637
!/vitest.config.ts
3738
!/tsconfig.dts.json
39+
!/vercel.json
3840
*.tgz
3941
examples/wdio/wdio-*.json
4042
examples/wdio/wdio-*.webm

‎ARCHITECTURE.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,9 @@ Contains:
6767
- `buildImageFrameSnapshots` / `upsertRichestSnapshot` / `buildDenseScreencast` / `thinScreencastFrames` — filmstrip and per-action frame snapshots.
6868
- `buildMutationsNdjson` / `reattributeDomAnchors` — the DOM mutation stream and its anchor repositioning.
6969
- `buildConsoleEvents`, `networkRequestToHar`, `buildSourceResources` / `sourceResourceName` / `callSourceToStack`, `generateTranscript`, `sha1Hex`.
70-
- `parseTraceZip` / `readTraceZip` (`trace-reader.ts` and its `trace-reader-*` siblings) — the reverse direction: a `trace.zip`, ours or a foreign one, back into the `TracePlayerData` the player renders. Imported by subpath (`@wdio/devtools-trace/trace-reader`) and kept out of the package index, so adapters that reach `trace` through `core` never inline it.
70+
- `parseTraceZip` (`trace-reader.ts` and its `trace-reader-*` siblings) — the reverse direction: a `trace.zip`, ours or a foreign one, back into the `TracePlayerData` the player renders. Imported by subpath (`@wdio/devtools-trace/trace-reader`) and kept out of the package index, so adapters that reach `trace` through `core` never inline it. It reaches no Node built-in, because the hosted player runs it in the browser (`tests/trace-reader-browser-safe.test.ts` walks its imports); the disk read is `readTraceZip` in `read-trace-zip.ts`, and source paths are hashed with the pure `portableSha1Hex` so the reader needs no `node:crypto`; resource bytes keep native `sha1Hex`, ~30x faster.
7171

72-
Imports from: `shared`. Imported by: `core` (which re-exports it, so adapters reach it unchanged) and `backend`.
72+
Imports from: `shared`. Imported by: `core` (which re-exports it, so adapters reach it unchanged), `backend`, and `app`, which may import `trace-reader` alone (ESLint enforces it).
7373

7474
### `packages/core`
7575

‎CLAUDE.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Run from repo root unless noted.
3131
| `pnpm dev` | Run all packages in parallel dev mode. |
3232
| `pnpm smoke:fixtures [--port N]` | Serve the local fixture site the desktop examples drive (a copy of the-internet's login flow: `/`, `/login`, `/secure`, `/logout`; default `http://127.0.0.1:4567`). Every desktop login example reads `DEVTOOLS_EXAMPLE_BASE_URL` (trailing slash ignored); unset, they drive the public site. |
3333
| `pnpm smoke:assert --expect <name> [--exit-code N] [--log file] <zip-or-dir>...` | Check trace zips against `packages/conformance/smoke/expectations/<name>.json`: the listed rows under each test's own tracing group (or the whole run for an entry with `scope: "run"` and a `reason`), an error row on each deliberately failing test, a retried test's attempt count with its other checks read from its final attempt alone (`attempts`), title fragments such as a failed wait's condition, a call site on every row of a per-test entry (at least one for a run-scoped entry) and every call site inside the example's own `sourceFiles`, the run's exit code, and no wait timeout, in any adapter's wording (`TIMEOUT_PATTERNS` in `smoke/constants.ts`), in the trace's stdout or the run log other than the lines of a test marked `allowTimeoutError`. |
34+
| `pnpm player:hosted` | Build the hosted trace player (the app with `--mode hosted`, no backend) and serve it at `http://localhost:4173` under the headers `vercel.json` sends from trace.webdriver.io. |
3435
| `pnpm docs:capabilities` | Render the per-adapter capability tables from `packages/conformance/src/features.ts` into `README.md`; `--website <webdriverio checkout>` also renders the docs-site tables, `--check` fails instead of writing, and `--parity` lists every feature some adapter lacks plus the evidence counts. |
3536
| `python3 packages/selenium-devtools-py/scripts/changes.py next-version` | The version a Python-adapter release would publish, from the fragments pending in `changes/`. `check --base <ref>` is the CI gate; `apply` is what the release runs. |
3637

@@ -103,7 +104,7 @@ They import from `shared` and `core`, never from each other. They aren't importe
103104

104105
### Backend and app are framework-agnostic
105106

106-
`backend` and `app` import from `shared` only (for contracts) and from each other via the WS/HTTP boundary. Neither imports an adapter package.
107+
`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. 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.
107108

108109
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.
109110

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,12 @@ 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:
266+
267+
```sh
268+
pnpm player:hosted # builds it and serves it at http://localhost:4173 under the production headers
269+
```
270+
265271
**Other viewers.** The trace uses a portable NDJSON schema, so the same `.zip` also opens in other compatible standalone trace viewers that read the format, and — because it shares that on-disk format — is what an Allure report's **embedded trace viewer** (Allure ≥ 2.35) reads. See the [backend README](./packages/backend/README.md#trace-serving--show-trace) for the reader details.
266272

267273
#### Options

‎eslint.config.cjs‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ module.exports = [
1212
ignores: [
1313
'node_modules/**',
1414
'**/dist/**',
15+
'**/dist-hosted/**',
1516
'**/.tsup/**',
1617
'**/allure-report/**',
1718
'**/allure-results/**'
@@ -298,6 +299,13 @@ module.exports = [
298299
'no-restricted-imports': [
299300
'error',
300301
{
302+
paths: [
303+
{
304+
name: '@wdio/devtools-trace',
305+
message:
306+
'App may import only trace-reader from trace (CLAUDE.md §2.2), which the hosted player runs in the browser. The writers need Node; the backend writes zips with them.'
307+
}
308+
],
301309
patterns: [
302310
{
303311
group: ['@wdio/devtools-service', '@wdio/devtools-service/*'],
@@ -328,9 +336,13 @@ module.exports = [
328336
'App must not import from core (CLAUDE.md §2.2). core is framework-agnostic adapter logic; the app receives normalized events over WS.'
329337
},
330338
{
331-
group: ['@wdio/devtools-trace', '@wdio/devtools-trace/*'],
339+
// The bare name is in `paths`: here it would exclude the directory, which `!` cannot undo.
340+
group: [
341+
'@wdio/devtools-trace/*',
342+
'!@wdio/devtools-trace/trace-reader'
343+
],
332344
message:
333-
'App must not import from trace (CLAUDE.md §2.2). The backend writes and parses zips with it; the app receives a parsed trace over HTTP.'
345+
'App may import only trace-reader from trace (CLAUDE.md §2.2), which the hosted player runs in the browser. The writers need Node; the backend writes zips with them.'
334346
}
335347
]
336348
}

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"scripts": {
77
"build": "pnpm -r build",
88
"show-trace": "node packages/backend/dist/show-trace.js",
9+
"player:hosted": "pnpm --filter @wdio/devtools-app build:hosted && pnpm --filter @wdio/devtools-app preview:hosted",
910
"backend": "node packages/backend/dist/server.js",
1011
"demo:wdio": "wdio run ./examples/wdio/cucumber/wdio.conf.ts",
1112
"demo:wdio:mobile": "wdio run ./examples/wdio/mobile/wdio.conf.ts",

‎packages/app/hosting-headers.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { readFileSync } from 'node:fs'
2+
import path from 'node:path'
3+
import url from 'node:url'
4+
5+
interface HostingConfig {
6+
headers: { headers: { key: string; value: string }[] }[]
7+
}
8+
9+
const VERCEL_JSON = path.resolve(
10+
path.dirname(url.fileURLToPath(import.meta.url)),
11+
'../../vercel.json'
12+
)
13+
14+
/** The response headers trace.webdriver.io sends, as `vercel.json` declares them. */
15+
export function hostingHeaders(): Record<string, string> {
16+
const config = JSON.parse(readFileSync(VERCEL_JSON, 'utf8')) as HostingConfig
17+
return Object.fromEntries(
18+
config.headers.flatMap(({ headers }) =>
19+
headers.map(({ key, value }) => [key, value])
20+
)
21+
)
22+
}

‎packages/app/package.json‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
"build": "tsc && vite build",
1515
"preview": "vite preview",
1616
"lint": "eslint .",
17-
"prepublishOnly": "pnpm build"
17+
"prepublishOnly": "pnpm build",
18+
"build:hosted": "tsc && vite build --mode hosted --outDir dist-hosted",
19+
"preview:hosted": "vite preview --mode hosted --outDir dist-hosted"
1820
},
1921
"author": "Christian Bromann <mail@bromann.dev>",
2022
"license": "MIT",
@@ -28,6 +30,7 @@
2830
"@tailwindcss/postcss": "^4.3.3",
2931
"@wdio/browser-runner": "^10.0.2",
3032
"@wdio/devtools-shared": "workspace:^",
33+
"@wdio/devtools-trace": "workspace:^",
3134
"@wdio/globals": "^10.0.2",
3235
"@wdio/mocha-framework": "^10.0.1",
3336
"@wdio/protocols": "10.0.2",

‎packages/app/src/app.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import { TraceType, type CommandLog } from '@wdio/devtools-shared'
55

66
import { Element } from '@core/element'
77
import { DataManagerController } from './controller/DataManager.js'
8+
import { TraceOpenController } from './controller/trace-open.js'
89
import { KeyboardController, KBD } from './controller/keyboard.js'
910
import { DragController, Direction } from './utils/DragController.js'
1011
import {
12+
HOSTED_PLAYER,
1113
SIDEBAR_MIN_WIDTH,
1214
SIDEBAR_DEFAULT_WIDTH
1315
} from './controller/constants.js'
@@ -23,6 +25,7 @@ import './components/header.js'
2325
import './components/sidebar.js'
2426
import './components/workbench.js'
2527
import './components/onboarding/start.js'
28+
import './components/onboarding/open-trace.js'
2629
import './components/workbench/compare.js'
2730
import './components/shortcuts-overlay.js'
2831

@@ -37,6 +40,11 @@ onThemeChange(applyDarkMode)
3740
@customElement('wdio-devtools')
3841
export class WebdriverIODevtoolsApplication extends Element {
3942
dataManager = new DataManagerController(this)
43+
#traceOpen = HOSTED_PLAYER
44+
? new TraceOpenController(this, (file) =>
45+
this.dataManager.openTraceArchive(file)
46+
)
47+
: undefined
4048

4149
@state() showShortcuts = false
4250

@@ -112,6 +120,9 @@ export class WebdriverIODevtoolsApplication extends Element {
112120
window.addEventListener('show-command', this.#onShowCommand)
113121
window.addEventListener(KBD.step, this.#onKbdStep)
114122
window.addEventListener(KBD.jump, this.#onKbdJump)
123+
if (HOSTED_PLAYER) {
124+
document.title = 'WebdriverIO Trace Viewer'
125+
}
115126
// In popout mode, the URL carries the test uid the parent window was
116127
// viewing. Push it into the context so the Compare component finds the
117128
// matching baseline as soon as the WS reconnects in this new window.
@@ -219,7 +230,35 @@ export class WebdriverIODevtoolsApplication extends Element {
219230
this.dataManager.clearExecutionData(detail?.uid, detail?.entryType)
220231
}
221232

233+
#traceOpenBanner() {
234+
const error = this.#traceOpen?.error
235+
if (!error) {
236+
return nothing
237+
}
238+
return html`<div
239+
role="alert"
240+
class="absolute top-2 left-1/2 -translate-x-1/2 z-30 flex items-center gap-3 px-3 py-2 rounded border shadow"
241+
style="background: var(--vscode-editor-background); border-color: var(--vscode-charts-red); color: var(--vscode-charts-red);"
242+
>
243+
<span>${error}</span>
244+
<button
245+
aria-label="Dismiss"
246+
@click="${() => this.#traceOpen?.dismissError()}"
247+
>
248+
✕
249+
</button>
250+
</div>`
251+
}
252+
222253
#mainContent() {
254+
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+
.error="${traceOpen.error}"
260+
></wdio-devtools-open-trace>`
261+
}
223262
if (!this.dataManager.hasConnection) {
224263
return html`<wdio-devtools-start></wdio-devtools-start>`
225264
}
@@ -245,6 +284,7 @@ export class WebdriverIODevtoolsApplication extends Element {
245284
class="basis-auto"
246285
.playerMode="${this.dataManager.playerMode}"
247286
></wdio-devtools-workbench>
287+
${this.#traceOpenBanner()}
248288
</section>
249289
`
250290
}

0 commit comments

Comments
 (0)