Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hand-the-run-all-command-to-a-rerun.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@wdio/devtools-backend': patch
---

Pass the run-everything command to a rerun child. A child is spawned with one test named on its command line, so an adapter that derives its Run-all command from its own invocation republishes "run that one test" as the command for running everything — after which Run-all reruns only whatever was last reran. The child cannot reconstruct what it was narrowed from, so the spawner now hands the original down alongside the rest of the reuse handshake.
7 changes: 7 additions & 0 deletions .changeset/replay-a-checked-box-as-checked.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@wdio/devtools-app': patch
---

Replay a boolean attribute the page itself set. The DOM anchor captures markup, so a page's own `<input type="checkbox" checked>` arrives as `checked=""` — and Preact assigns these as properties, where `''` is falsy, so the box replayed unchecked while the screencast showed it ticked. Every boolean attribute was affected the same way: a control the page disabled replayed as usable, a selected option as unselected.

Captured markup now replays on presence alone, which is what HTML means: `checked="false"` in a page's own markup is a ticked box. That is deliberately NOT the mutation path's rule, where "false" is the collector reporting a cleared field — a signal that only ever arrives as a mutation record, never as markup.
8 changes: 8 additions & 0 deletions .changeset/ship-the-dashboard-without-its-toolchain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@wdio/devtools-app': patch
'@wdio/devtools-script': patch
---

Declare the build-time libraries as devDependencies, so installing the dashboard no longer installs the toolchain that built it. Both packages ship a bundle with everything already inlined — lit, preact, codemirror and the iconify set for the app; htm, parse5 and preact for the page script — yet listed them as runtime dependencies, and the script additionally listed a vite plugin, which pulled vite, rolldown and lightningcss onto every machine that installed the backend. The app also declared the WebdriverIO adapter it never imports.

Measured against the registry: installing `@wdio/devtools-backend` went from 338 packages and 264 MB to roughly 85 and 27 MB. That cost fell on every adapter, and hardest on the Python one, which fetches the backend at runtime.
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ No `any` crosses a package boundary. When a framework API forces a loosely-typed

Bundlers in use: **vite** for `app`, `service`, `script`; **tsup** for `backend`, `nightwatch-devtools`, `selenium-devtools`.

- **A published package that ships a BUNDLE declares its build libraries as `devDependencies`.** `app` and `script` each publish a vite build with everything inlined — the app's dist carries no bare import of lit, preact or codemirror, the script's none of htm, parse5 or preact — so listing those under `dependencies` installed a whole toolchain on every consumer that needed none of it. `script` also listed `vite-plugin-singlefile`, which pulls vite, rolldown and lightningcss; `app` listed `@wdio/devtools-service`, which it never imports and which pulls webdriverio. Measured against the registry: installing `@wdio/devtools-backend` cost **338 packages / 264 MB**, against **~85 / ~27 MB** once both were moved. Every adapter paid that; the Python one pays it hardest, since it fetches the backend at runtime. The test is the same grep as above — a bare import surviving in `dist/` means the dependency is real and belongs in `dependencies`; nothing surviving means it was build-time. Note this is the opposite default from the workspace-internal rule: there `devDependencies` is chosen so code is *inlined*, here it is chosen because the code *already* is.

### Separation of concerns within a file

Files own one concern:
Expand Down Expand Up @@ -283,7 +285,7 @@ Documented divergences from the conventions above. They exist today as debt to b
- A plain script's tree is one synthetic suite holding one synthetic test, and both denote the whole run, so its launch command doubles as its rerun template (no slot — the backend substitutes nothing) and all three controls are honest. Refusing the row-scoped ones instead would disable the button beside the only row the tree has.
- **Two unrelated events share the `clearExecutionData` scope, and the receiver cannot tell them apart from the uid.** A run STARTING (`backend/src/index.ts` `handleTestRun`, one per `POST /api/tests/run`) and ONE ENTRY resetting inside a run already in flight (`nightwatch-devtools/src/cucumber-lifecycle.ts`, which re-emits a scenario suite and must not wipe its siblings) arrive under the same scope with the same shape. The app inferred the difference by comparing the uid against `rerunState.activeRerunSuiteUid` — a latch that outlived its rerun, so the *next* run start at a different scope read as a child clear of the last one and **skipped its wipe entirely**: rerun a suite, then the file or Tests, and the Actions/Console/Network tabs kept the previous run's rows and grew with each rerun. `ClearExecutionDataWsPayload.runStart` now states it on the wire (it has to be on the wire, not local to the clicking window — popouts see only WS events), and the app clears both latches when it is set. A backend test asserts the flag actually ships: the app-side fix reads it, so dropping it would restore the bug with every app test still green.
- Still open, same class: `app/src/components/browser/snapshot.ts` `#videos` is only ever pushed to, so the screencast "Recording N" dropdown accumulates every session of every run for the life of the page (observed at 17). That component listens only to the `screencast-ready` window event and never learns a run started.
- **A rerun's process collects a SUBSET, so anything it derives from "this collection" is wrong for the tree it merges into.** Two bugs of that one shape, both found by rerunning a single pytest test: (a) `SuiteStats.order` — which `test-entry-state.ts` `orderedChildren` sorts a suite's tests and child suites by — was pytest's `enumerate(session.items)` index, so a rerun restamped its one test as position 0 and the row jumped above the class it was written below. It is now the item's **source line**, a property of the test rather than of the collection; within a module pytest collects in definition order, so the two agree wherever both are meaningful (a plugin that reorders collection is the exception, and there the line is the more stable answer anyway). (b) `suite-merge.ts` `resetStaleChildrenOnRerun` flipped every settled child *suite* to `pending` whenever an incoming suite arrived `pending` — but a single-test rerun re-emits the parent as `pending` carrying only the one test it collected, so a sibling class suite was set spinning and never reported again, keeping the spinner for the rest of the session with all of its own tests still green. `mergeTests` already froze sibling *tests* on `activeRerunTestUid`; that guard now covers child suites too. A suite on the path to the target is unaffected either way — it re-reports its own state.
- **A rerun's process collects a SUBSET, so anything it derives from "this collection" is wrong for the tree it merges into.** Three bugs of that one shape, all found by rerunning a single pytest test. The third is the one that shows the rule has a limit: (c) the **launch command** — what Run-all spawns — was built from the child's own invocation, which the backend had narrowed to a single nodeid, so one targeted rerun rescoped Run-all to that test permanently and the tree kept showing only what that child collected. Unlike (a) and (b) this is not recoverable inside the child: its arguments no longer mention what it was narrowed from. The original travels down instead, as `REUSE_ENV.LAUNCH_COMMAND` beside the rest of the reuse handshake; the *rerun template* stays locally derived, since only this process can say how its own interpreter selects a test. **Fixed in the Python adapter only** — `selenium-devtools/src/rerunManager.ts` still derives its launch command from `captureLaunchCommand()`, i.e. from the child's own argv, so a mocha/jest rerun carrying an inherited `--grep` republishes that as Run-all. It already strips those filters out of the *template* for this reason; the getter is what is left. A second consumer makes the inherit-or-derive resolution itself core's, with `captureLaunchCommand` staying adapter-local. The other two: (a) `SuiteStats.order` — which `test-entry-state.ts` `orderedChildren` sorts a suite's tests and child suites by — was pytest's `enumerate(session.items)` index, so a rerun restamped its one test as position 0 and the row jumped above the class it was written below. It is now the item's **source line**, a property of the test rather than of the collection; within a module pytest collects in definition order, so the two agree wherever both are meaningful (a plugin that reorders collection is the exception, and there the line is the more stable answer anyway). (b) `suite-merge.ts` `resetStaleChildrenOnRerun` flipped every settled child *suite* to `pending` whenever an incoming suite arrived `pending` — but a single-test rerun re-emits the parent as `pending` carrying only the one test it collected, so a sibling class suite was set spinning and never reported again, keeping the spinner for the rest of the session with all of its own tests still green. `mergeTests` already froze sibling *tests* on `activeRerunTestUid`; that guard now covers child suites too. A suite on the path to the target is unaffected either way — it re-reports its own state.
- **A trace archive is a full recording of the page, and there is no redaction policy anywhere in capture.** Whatever the run put on screen or typed is in the zip, usually several times over: measured on the Python login example, its demo credential appears ~103 times across six places — the page's own displayed text (90x, the-internet prints it), the DOM mutation stream, the `Element.fill` command args, the transcript, the captured test source, and `*-elements.json`. `shared/element-scripts.ts` blanks an `<input type="password">` value, which is worth having because nothing downstream reads that field, but it removes **2 of those ~103** and closes nothing on its own. `buildElementScripts` now projects a captured record down to what is actually read (`selector` + `boundingBox` + context), so `value` and `href` leave the archive entirely — justified as dead data, **not** as a redaction: the same archive still carries 15 hrefs in `trace.mutations` independent of `elements.json`, and 29 `value` attribute mutations recording a typed string keystroke by keystroke (`t`, `to`, `tom`, ...). `@wdio/elements` keeps returning the full `BrowserElementInfo` from its own live call, which is its documented API. A real policy has to act at the collector and the command-arg serializer — a masking-selector or `maskInputs` option — not at one resource. Until then, treat a trace zip as sensitive as the run that produced it.
- **An `ActionSnapshot` carries no session identity, in any adapter.** `shared`'s type has never had one and `core/action-snapshot.ts` records none, so per-action captures from two concurrently-driven sessions land in one list and are resolved purely by the command's completion timestamp. `claimAfter` is an exact keyed lookup, so the window is narrow — two commands completing in the **same millisecond**, where `trace-frame-snapshots.ts` breaks the tie by "keep the richest capture" (largest screenshot), which is session-blind — plus the documented `latestAtOrBefore` fallback for a command that took no capture of its own. Python is not worse than the JS adapters here and leans on that fallback less, since it stamps each snapshot with its own command's `row["timestamp"]`; reaching the failure at all needs threaded drivers in one process (pytest's function-scoped fixtures are sequential, and `-n` is multi-process). Fixing it is a shared-contract change: `sessionId` on the snapshot, and an index keyed by the pair.
- **Chrome discards all WebDriver-synthesized input to a tab after a breached credential is submitted.** The first time a test types a `(username, password)` pair that Chrome's password-leak check finds in a breach corpus into an `<input type="password">` and submits a form whose destination no longer shows that login form, Chrome queries `passwordsleakcheck-pa.googleapis.com` and ~0.3-0.9 s later stops delivering **all** synthesized input — mouse *and* keyboard — to that tab. chromedriver returns HTTP 200 for every subsequent Element Click / Send Keys; nothing reaches the page. Untrusted JS (`element.click()`) still works and direct CDP `Input.dispatchMouseEvent`/`dispatchKeyEvent` are equally dead, so this is Chrome, not chromedriver and not our capture. `tomsmith` / `SuperSecretPassword!` — the-internet's demo credential — triggers it; changing only the *username* does not, nor does a random password.
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,12 +299,15 @@ npm install @wdio/selenium-devtools
**Python (Selenium):**
```bash
pip install -e packages/selenium-devtools-py # or: pip install selenium-devtools-py (when published)
selenium-devtools install-backend # once — pip cannot install a Node package
```

The Python adapter needs Python 3.10+, selenium 4.44+, and **Node.js 18+ on your
PATH** — the backend that serves the page collector, carries the event stream
and builds the trace archive is a Node app, so Node is required in every mode,
not just for the dashboard window.
not just for the dashboard window. Without the install step a run fetches that
backend with `npx` on first use, which works but costs a registry round trip
every run.

> See the [Nightwatch Integration](#nightwatch-integration), [Selenium Integration](#selenium-integration) and [Python Integration](#python-integration) sections for configuration details.

Expand Down
21 changes: 9 additions & 12 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,34 +16,31 @@
"lint": "eslint .",
"prepublishOnly": "pnpm build"
},
"dependencies": {
"author": "Christian Bromann <mail@bromann.dev>",
"license": "MIT",
"devDependencies": {
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.0",
"@iconify-json/mdi": "^1.2.3",
"@lit/context": "^1.1.6",
"@wdio/devtools-service": "workspace:*",
"@wdio/protocols": "9.30.1",
"codemirror": "^6.0.2",
"lit": "^3.3.3",
"placeholder-loading": "^0.7.0",
"pointer-tracker": "^2.5.3",
"preact": "^10.29.2"
},
"author": "Christian Bromann <mail@bromann.dev>",
"license": "MIT",
"devDependencies": {
"@tailwindcss/postcss": "^4.3.0",
"@wdio/browser-runner": "^9.30.0",
"@wdio/devtools-shared": "workspace:^",
"@wdio/globals": "^9.29.1",
"@wdio/mocha-framework": "^9.30.0",
"@wdio/protocols": "9.30.1",
"@wdio/reporter": "9.30.1",
"autoprefixer": "^10.5.0",
"codemirror": "^6.0.2",
"expect": "30.4.1",
"lit": "^3.3.3",
"placeholder-loading": "^0.7.0",
"pointer-tracker": "^2.5.3",
"postcss": "^8.5.15",
"postcss-import": "^16.1.1",
"preact": "^10.29.2",
"rollup": "^4.61.0",
"stylelint": "^17.12.0",
"stylelint-config-recommended": "^18.0.0",
Expand Down
30 changes: 29 additions & 1 deletion packages/app/src/components/browser/vnode-transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

import { type VNode, h } from 'preact'

import { isBooleanAttribute } from './boolean-attribute.js'

interface SerializedVNode {
type?: string
props?: {
Expand Down Expand Up @@ -37,14 +39,40 @@ function withoutInlineHandlers(
return kept
}

/**
* A boolean attribute's state is its PRESENCE, and everything reaching here is
* MARKUP — the anchor and every added node are serialized with `outerHTML` —
* so a page's own `<input type="checkbox" checked>` arrives as `checked=""`.
* Preact assigns these as properties (`name in dom`), where `''` is falsy, so
* the box replayed unchecked while the screencast showed it ticked; `disabled`,
* `readonly`, `selected` and the rest replayed off the same way, rendering a
* disabled control as usable. Measured: `checked=""` → property `false`.
*
* Presence alone, never the value: `checked="false"` in markup is a CHECKED box
* (the browser reads the attribute, not what it says), so this deliberately
* does NOT share `booleanAttributeOn` with the mutation path. There "false" is
* the collector reporting a cleared field — a signal that only exists on that
* path, since `#handleAttributeMutation` is where those records land and they
* never come through here.
*/
function withBooleanAttributeState(
props: Record<string, unknown>
): Record<string, unknown> {
const resolved: Record<string, unknown> = {}
for (const [key, value] of Object.entries(props)) {
resolved[key] = isBooleanAttribute(key) ? true : value
}
return resolved
}

export function transform(node: TransformInput): VNode<{}> {
if (typeof node !== 'object' || node === null) {
// Plain string/number text node — return as-is for Preact to render as text.
return node as unknown as VNode<{}>
}

const { children, ...rawProps } = node.props ?? {}
const props = withoutInlineHandlers(rawProps)
const props = withBooleanAttributeState(withoutInlineHandlers(rawProps))
/**
* ToDo(Christian): fix way we collect data on added nodes in script
*/
Expand Down
53 changes: 53 additions & 0 deletions packages/app/tests/vnode-transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,59 @@ describe('transform', () => {
})
})

describe('boolean attributes', () => {
/** Renders through Preact, which is where the state is actually decided:
* these attributes are assigned as PROPERTIES, so a prop's truthiness —
* not its presence — is what the replayed page shows. */
const renderInto = (node: VNode<{}>): HTMLInputElement => {
const host = document.createElement('div')
document.body.appendChild(host)
render(node, host)
return host.querySelector('input') as HTMLInputElement
}

it('replays a box the PAGE checked, captured as a bare attribute', () => {
// the-internet's /checkboxes ships `<input type="checkbox" checked>`, and
// the anchor serializes markup — so this arrives as `checked=""`, which
// Preact assigns as the property `''`. It rendered unchecked while the
// screencast showed it ticked.
const box = renderInto(
transform(captureFragment('<input type="checkbox" checked>'))
)

expect(box.checked).toBe(true)
})

it('replays a box whose markup says checked="false"', () => {
// HTML reads the ATTRIBUTE, not what it says: a page that writes
// checked="false" renders a ticked box, and the replay must agree. This
// is why the mutation path's policy cannot be shared — there "false" is
// the collector reporting a cleared field, a signal that never reaches
// markup, and reusing it here replayed this page's box unticked.
const box = renderInto(
transform(captureFragment('<input type="checkbox" checked="false">'))
)

expect(box.checked).toBe(true)
})

it('keeps a disabled control disabled', () => {
// Same class, and worse when wrong: a control the page disabled replayed
// as usable, which reads as the capture having missed the state.
const field = renderInto(transform(captureFragment('<input disabled>')))

expect(field.disabled).toBe(true)
})

it('leaves a non-boolean attribute alone', () => {
const field = renderInto(
transform({ type: 'input', props: { type: 'text', value: 'tomsmith' } })
)

expect(field.value).toBe('tomsmith')
})
})

describe('props', () => {
it('spreads the captured attributes onto the rendered node', () => {
const node = transform({
Expand Down
8 changes: 8 additions & 0 deletions packages/backend/src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@ class TestRunner {
childEnv[REUSE_ENV.PORT] = String(payload.devtoolsPort)
childEnv[REUSE_ENV.REUSE] = '1'
}
// Deleted when this payload carries none: `childEnv` starts from our own
// environment, so a backend that inherited the variable would otherwise
// hand a stale command to every child it spawns.
if (payload.launchCommand) {
childEnv[REUSE_ENV.LAUNCH_COMMAND] = payload.launchCommand
} else {
delete childEnv[REUSE_ENV.LAUNCH_COMMAND]
}
return childEnv
}

Expand Down
Loading
Loading