Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
8b55768
fix(core): timeout-guard every per-action snapshot probe
vishnuv688 Aug 5, 2026
f96e8a4
fix: stamp the DOM anchor with the document's birth time
vishnuv688 Aug 5, 2026
cf7c25d
fix(core): share a per-action capture instead of consuming it
vishnuv688 Aug 5, 2026
eaa4f4a
fix: break row-order ties with issue order
vishnuv688 Aug 5, 2026
15ed82f
fix(selenium): stamp command rows at completion, not invocation
vishnuv688 Aug 5, 2026
487caf6
fix(nightwatch): route capture probes and the drain over raw WebDrive…
vishnuv688 Aug 5, 2026
83099ec
fix(nightwatch): stamp rows at completion and surface async command f…
vishnuv688 Aug 5, 2026
ed91761
docs: record the command-stamp and DOM-anchor invariants
vishnuv688 Aug 5, 2026
9865520
fix: give Nightwatch's waits a row so their failures can surface
vishnuv688 Aug 6, 2026
b9dc591
feat(core): register the collector at document-start via BiDi preload
vishnuv688 Aug 6, 2026
d491987
fix(nightwatch): instrument every document, not just the ones we notice
vishnuv688 Aug 6, 2026
8f4e5cf
docs: record document-start injection and correct the click-drop attr…
vishnuv688 Aug 6, 2026
e68c761
refactor(core): move same-timestamp snapshot merging into core
vishnuv688 Aug 6, 2026
2a2e74f
fix: force a DOM anchor on the teardown and per-test drains
vishnuv688 Aug 6, 2026
f452d76
test(selenium): cover the injection guard and per-command snapshot th…
vishnuv688 Aug 6, 2026
56b1ebf
test(script): cover the collector's instance-scoped anchor
vishnuv688 Aug 6, 2026
f3c118b
fix(app): correct the a11y empty-state copy
vishnuv688 Aug 6, 2026
6067d4c
build: alias the script package's wire types
vishnuv688 Aug 6, 2026
0376709
docs(backend): note the mutation-stream timebase
vishnuv688 Aug 6, 2026
f8cefec
docs(readme): correct the per-action resource filenames
vishnuv688 Aug 6, 2026
b64e5d3
examples: exercise the failure and multi-page paths
vishnuv688 Aug 6, 2026
23e6b51
feat(core): let a caller attribute a DOM anchor causally
vishnuv688 Aug 6, 2026
8858344
feat(shared): classify the commands that dispatch trusted input
vishnuv688 Aug 7, 2026
70bd9ef
feat(core): gate screenshot polling on an input-dispatch window
vishnuv688 Aug 7, 2026
3e510fd
feat(core): generate the captured locator in the recording runner's d…
vishnuv688 Aug 7, 2026
bdbb6d5
feat(core): resolve an action's point and an assert's target by locator
vishnuv688 Aug 7, 2026
dbe80db
feat(selenium): carry locators onto element and assertion rows
vishnuv688 Aug 7, 2026
8efbb5c
fix(nightwatch): correlate cucumber native-assert outcomes
vishnuv688 Aug 7, 2026
71723db
feat(service): stamp the runner id and gate input-dispatching commands
vishnuv688 Aug 7, 2026
093d50e
feat(app): resolve XPath locators and name the call each runner needs
vishnuv688 Aug 7, 2026
5122ef9
docs: correct the click-activation attribution and record this round
vishnuv688 Aug 7, 2026
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
40 changes: 35 additions & 5 deletions CLAUDE.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,8 @@ The trace artifact contains:
- `trace.trace` — NDJSON `context-options` + `before`/`after` action events. When test hooks are available (Mocha's `it()` / Cucumber's `Scenario()`), each test becomes a `Tracing.tracingGroup` span — an open/close `before`/`after` pair with `method: "tracingGroup"` and `params.name` set to the test title. Child actions inside the group carry `parentId` pointing back to the group's `callId`, so timeline viewers render tests as labelled spans wrapping their commands.
- `trace.network` — HAR-style network entries derived from the existing capture
- `resources/page@<id>-<ts>.jpeg` — screenshot per user-facing action
- `resources/elements-page@<id>-<ts>.json` — flat interactable element list extracted by the page-injected scripts in `@wdio/devtools-core/element-scripts`
- `resources/snapshot-page@<id>-<ts>.txt` — depth-indented accessibility-tree snapshot (AI-friendly)
- `resources/page@<id>-<ts>-elements.json` — flat interactable element list extracted by the page-injected scripts in `@wdio/devtools-core/element-scripts`
- `resources/page@<id>-<ts>-snapshot.txt` — depth-indented accessibility-tree snapshot (AI-friendly)
- `transcript.md` — human/LLM-readable Markdown transcript of the captured actions, with timing, selectors, and value annotations

What counts as a user-facing action is filtered through an allow-list in `@wdio/devtools-core/action-mapping.ts` (`url`, `click`, `setValue`, `sendKeys`, `get`, etc.). Internal commands like `findElement`/`waitUntil`/`executeScript` don't produce trace entries.
Expand All @@ -158,7 +158,7 @@ npx show-trace path/to/trace.zip # in a project that installs an adapter
The player exposes everything captured in the archive:

- **DOM time-travel** — the browser pane replays the page from the captured DOM **mutation stream**, so scrubbing the playhead reconstructs the live DOM at any point, not just a screenshot.
- **A11y tab** — the accessibility tree (roles + accessible names) captured for the selected command; hover a row to outline the element in the snapshot, click to copy its locator.
- **A11y tab** — the accessibility tree (roles + accessible names) captured for the selected command; hover a row to outline the element in the snapshot, click to copy its locator. Locators are generated in the recording runner's own dialect — an element identified by its text is `a*=Logout` under WebdriverIO and `//a[contains(., "Logout")]` under Selenium/Nightwatch, where the panel also names the strategy that resolves it (`By.xpath()`, `useXpath()` / `locateStrategy: 'xpath'`). Every other locator is portable CSS.
- **Errors tab** — every failing `expect`/assertion and step failure collected in one place, each with a jump-to-source link to the command that threw.
- **Element overlay (pick-locator)** — labelled, click-to-copy boxes drawn over every element the test interacted with, cross-linked to the A11y rows.
- **Transcript tab + Copy-for-LLM** — the run's Markdown transcript with a one-click "copy prompt" that bundles it with any failing-command errors, paste-ready for an LLM.
Expand Down
28 changes: 16 additions & 12 deletions examples/nightwatch/cucumber/features/login.feature
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
Feature: Example site smoke test
Feature: the-internet login flow

Mirrors the Nightwatch BDD smoke-test: open a site, wait for the body to be
visible, and assert the page title. Runs as a Cucumber scenario so the
cross-adapter harness has a nightwatch-cucumber trace fixture.
Mirrors the Selenium cucumber example's feature so the cross-adapter harness
has a nightwatch-cucumber trace fixture over the same flow. Cucumber exposes
per-scenario hooks, so each scenario below is captured as its own test unit.

Scenario Outline: I can open a site and read its title
Given I navigate to "<url>"
When the page body becomes visible
Then the page title contains "<title>"
Scenario: logs in with valid credentials and lands on /secure
Given I am on the login page
When I enter username "tomsmith" and password "SuperSecretPassword!"
And I submit the login form
Then I should be on the secure page
And I should see a flash message matching "You logged into a secure area"

Examples:
| url | title |
| https://example.com | Example |
| https://example.org | Example |
Scenario: rejects invalid username with an error flash
Given I am on the login page
When I enter username "foobar" and password "barfoo"
And I submit the login form
Then I should see a flash message matching "Your username is invalid"
And I should still be on the login page
Original file line number Diff line number Diff line change
@@ -1,18 +1,42 @@
// Step definitions for the example-site smoke scenario.
// Step definitions for the the-internet login scenarios.
//
// Nightwatch's Cucumber integration injects the Nightwatch `browser` onto the
// World, so steps reach it via `this.browser` — hence regular (non-arrow)
// functions so `this` binds to the World.
const { Given, When, Then } = require('@cucumber/cucumber')

Given(/^I navigate to "([^"]*)"$/, async function (url) {
await this.browser.url(url)
const LOGIN_URL = 'https://the-internet.herokuapp.com/login'

Given(/^I am on the login page$/, async function () {
await this.browser.url(LOGIN_URL)
await this.browser.waitForElementVisible('#username', 5000)
})

When(/^the page body becomes visible$/, async function () {
await this.browser.waitForElementVisible('body', 5000)
When(
/^I enter username "([^"]*)" and password "([^"]*)"$/,
async function (username, password) {
await this.browser.setValue('#username', username)
await this.browser.setValue('#password', password)
}
)

When(/^I submit the login form$/, async function () {
await this.browser.click('button[type="submit"]')
})

Then(/^the page title contains "([^"]*)"$/, async function (title) {
await this.browser.assert.titleContains(title)
Then(/^I should be on the secure page$/, async function () {
await this.browser.waitForElementVisible('#flash', 5000)
await this.browser.assert.urlContains('/secure')
})

Then(
/^I should see a flash message matching "([^"]*)"$/,
async function (text) {
await this.browser.waitForElementVisible('#flash', 5000)
await this.browser.assert.textContains('#flash', text)
}
)

Then(/^I should still be on the login page$/, async function () {
await this.browser.assert.urlContains('/login')
})
64 changes: 45 additions & 19 deletions examples/nightwatch/tests/smoke-test.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
/**
* Example + config-sweep harness for @wdio/nightwatch-devtools.
*
* Walk the live/trace ladder by editing ONLY the mode/traceGranularity/
* tracePolicy block in ../nightwatch.conf.cjs. The suite carries a passing
* pair, an always-failing test (retain-on-failure target), and a flaky
* fail-then-pass test (on-first-retry / attempt-capture target).
* Exercises the same the-internet login flow the WDIO and Selenium examples
* use, so a trace captured here is comparable across adapters. Walk the
* live/trace ladder by editing ONLY the mode/traceGranularity/tracePolicy
* block in ../nightwatch.conf.cjs. The suite carries a passing pair (a login
* round-trip and a DOM-mutation test), an always-failing test
* (retain-on-failure target), and a flaky fail-then-pass test
* (on-first-retry / attempt-capture target).
*
* Native asserts (browser.assert.*) double as the assertion-capture check:
* the passing ones must render green ✓, the failing one red ✗.
Expand All @@ -14,35 +17,58 @@
* pnpm demo:nightwatch:retry (rung 5 — adds --retries 1)
*/

const BASE_URL = 'https://the-internet.herokuapp.com'

// Survives Nightwatch's testcase retry so the flaky test fails once, then passes.
let flakyAttempts = 0

describe('nightwatch-devtools smoke test', function () {
it('loads example.com and reads the heading', async function (browser) {
await browser.url('https://example.com')
await browser.waitForElementVisible('body', 5000)
browser.assert.titleContains('Example')
// Headless Chrome stops activating clicks after the first test in a session:
// a later submit dispatches, nothing navigates, and #flash never appears — so
// the always-failing test below timed out on its wait instead of reaching the
// assertion it exists to demonstrate.
afterEach(async function (browser) {
await browser.end()
})

it('navigates and reads the page title', async function (browser) {
await browser.url('https://example.org')
await browser.waitForElementVisible('body', 5000)
browser.assert.titleContains('Example')
it('logs into the secure area with valid credentials', async function (browser) {
console.log('[TEST] logging in with valid credentials')
await browser.url(`${BASE_URL}/login`)
await browser.waitForElementVisible('#username', 5000)
await browser.setValue('#username', 'tomsmith')
await browser.setValue('#password', 'SuperSecretPassword!')
await browser.click('button[type="submit"]')
await browser.waitForElementVisible('#flash', 5000)
browser.assert.urlContains('/secure')
browser.assert.textContains('#flash', 'You logged into a secure area')

await browser.waitForElementVisible('a.button', 5000)
await browser.click('a.button')
await browser.waitForElementVisible('#username', 5000)
browser.assert.urlContains('/login')
console.log('[TEST] logged back out')
})

it('fails on a wrong title (retain-on-failure target)', async function (browser) {
await browser.url('https://example.com')
await browser.waitForElementVisible('body', 5000)
browser.assert.titleContains('This Is Not The Title')
it('fails on a wrong flash message (retain-on-failure target)', async function (browser) {
console.log('[TEST] submitting invalid credentials')
await browser.url(`${BASE_URL}/login`)
await browser.waitForElementVisible('#username', 5000)

await browser.setValue('#username', 'foobar')
await browser.setValue('#password', 'barfoo')

await browser.click('button[type="submit"]')
await browser.waitForElementVisible('#flash', 5000)
browser.assert.textContains('#flash', 'You logged into a secure area')
})

it('flaky: fails the first attempt, then passes (retry target)', async function (browser) {
await browser.url('https://example.com')
await browser.waitForElementVisible('body', 5000)
await browser.url(`${BASE_URL}/login`)
await browser.waitForElementVisible('#username', 5000)
flakyAttempts += 1
if (flakyAttempts === 1) {
throw new Error('intentional first-attempt failure — should retry')
}
browser.assert.titleContains('Example')
browser.assert.titleContains('The Internet')
})
})
4 changes: 2 additions & 2 deletions examples/selenium/cucumber-test/features/support/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@

import { DevTools } from '@wdio/selenium-devtools'

// mode defaults to 'live' (the demo default); the verification harness sets
// DEVTOOLS_MODE=trace to produce a golden fixture from this same example.
// mode defaults to 'live' (the demo default); set DEVTOOLS_MODE=trace to
// produce a trace.zip from this same example.
DevTools.configure({
mode: process.env.DEVTOOLS_MODE === 'trace' ? 'trace' : 'live',
screencast: { enabled: true, quality: 70, maxWidth: 1280, maxHeight: 720 },
Expand Down
10 changes: 9 additions & 1 deletion examples/wdio/mocha/specs/login.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import { expect } from '@wdio/globals'
import { browser, expect } from '@wdio/globals'

import LoginPage from '../../pageobjects/login.page.js'
import SecurePage from '../../pageobjects/secure.page.js'

describe('Login', () => {
// Headless Chrome stops activating clicks after the first test in a session:
// the second test's submit dispatches, nothing navigates, and #flash never
// appears. Reproduces with the devtools service removed and survives
// deleteCookies(), so a fresh session is the only reliable reset.
beforeEach(async () => {
await browser.reloadSession()
})

it('logs into the secure area with valid credentials', async () => {
console.log('[TEST] logging in with valid credentials')
await LoginPage.open()
Expand Down
9 changes: 5 additions & 4 deletions examples/wdio/mocha/wdio.trace.conf.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// Trace-mode variant of wdio.conf.ts, used by the verification harness
// (`pnpm fixtures:regen`) to produce a deterministic golden trace.zip. Same
// Trace-mode variant of wdio.conf.ts, for manually verifying trace output. Same
// capabilities and specs as the live config; session granularity writes one zip
// per run. Kept separate so the demo config's live default stays untouched.
// per run. Every knob reads from the environment so one config covers the
// live→trace→per-test→retain→retry ladder. Kept separate so the demo config's
// live default stays untouched.
export const config: WebdriverIO.Config = {
runner: 'local',
specs: ['./specs/**/*.e2e.ts'],
Expand Down Expand Up @@ -46,7 +47,7 @@ export const config: WebdriverIO.Config = {
| 'on-first-retry'
| 'on-all-retries'
| 'retain-on-failure-and-retries',
// Always emit the manifest so scenario-parity can read the artifact set.
// Always emit the manifest so the artifact set is inspectable per run.
emitArtifactsManifest: true
}
]
Expand Down
45 changes: 39 additions & 6 deletions packages/app/src/components/browser/element-overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@
// boxes inherit the iframe's scale transform (no manual coordinate math). Kept
// out of snapshot.ts so that file stays focused on capture/replay.

import { isXPathLocator } from '@wdio/devtools-shared'

const OVERLAY_CLASS = '__wdio-el-overlay__'

export interface OverlayHandlers {
/** Click a box — copy its locator + jump to the A11y row (selector + name). */
onPick: (selector: string, label: string) => void
/** Hover a box — reveal the matching a11y-tree row (by selector, else by the
* element's accessible name for locators the serializer captured a different
* way, e.g. the test's `button[type=submit]` vs the tree's `button*=Login`). */
* way, e.g. the test's `button[type=submit]` vs the tree's
* `//button[contains(., "Login")]`). */
onHover?: (selector: string, label: string) => void
onLeave?: () => void
}
Expand All @@ -37,15 +40,45 @@ function elementLabel(el: Element): string {
return el.getAttribute('placeholder')?.trim() ?? ''
}

/** Resolve a test locator in the replayed document: native CSS first, then the
* WebdriverIO text-selectors querySelector can't parse (`tag=Exact`,
* `tag*=Contains`, and their tag-less forms). Returns the deepest text match
* so a container that merely encloses the text isn't boxed over the real
* element. Returns null when nothing matches (locator absent on this page). */
/** `XPathResult.FIRST_ORDERED_NODE_TYPE`, read as a literal so the resolver
* doesn't depend on the constant being reachable from this realm. */
const XPATH_FIRST_ORDERED_NODE = 9

/** First node an XPath locator matches, as the frameworks' own `By.xpath` /
* `useXpath` do. Element-only: an expression selecting an attribute or text
* node has nothing to draw a box over. */
function resolveXPath(doc: Document, expression: string): Element | null {
try {
const node = doc.evaluate(
expression,
doc,
null,
XPATH_FIRST_ORDERED_NODE,
null
).singleNodeValue
// nodeType, not `instanceof Element`: the replayed nodes belong to the
// iframe's realm, where the parent document's constructors don't match.
return node?.nodeType === 1 ? (node as Element) : null
} catch {
// Malformed expression, or a document with no XPath engine.
return null
}
}

/** Resolve a test locator in the replayed document: XPath first, then native
* CSS, then the WebdriverIO text-selectors querySelector can't parse
* (`tag=Exact`, `tag*=Contains`, and their tag-less forms) — those still arrive
* from hand-written WDIO tests and from traces recorded before the capture
* became portable. Returns the deepest text match so a container that merely
* encloses the text isn't boxed over the real element. Returns null when
* nothing matches (locator absent on this page). */
export function resolveTestSelector(
doc: Document,
selector: string
): Element | null {
if (isXPathLocator(selector)) {
return resolveXPath(doc, selector)
}
try {
const css = doc.querySelector(selector)
if (css) {
Expand Down
8 changes: 4 additions & 4 deletions packages/app/src/components/browser/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ import { booleanAttributeOn, isBooleanAttribute } from './boolean-attribute.js'
import { type ComponentChildren, h, render, type VNode } from 'preact'
import { customElement, query } from 'lit/decorators.js'
import { transform } from './vnode-transform.js'
import type { SimplifiedVNode } from '../../../../script/types'
import type { SimplifiedVNode } from '@wdio/devtools-script/types'
// Type-only, like the `script/types` import above: the collector owns the
// characterData wire shape (parent ref + child index), so the replay reads it
// from the same declaration that produces it.
import type { TextMutation } from '../../../../script/src/mutations.js'
import type { TextMutation } from '@wdio/devtools-script/mutations.js'
import type { CommandLog } from '@wdio/devtools-shared'

import {
Expand Down Expand Up @@ -637,8 +637,8 @@ export class DevtoolsBrowser extends Element {

/** Outline the element for an a11y-tree locator. Resolved through the same
* resolver the forward direction (the element overlay) uses, because the tree
* captures interactive elements as WDIO text locators (`button*=Login`) that
* querySelector cannot parse. */
* captures text-matched elements as XPath (`//button[contains(., "Login")]`)
* that querySelector cannot parse. */
#highlightBySelector = (ev: Event) => {
const detail = (ev as CustomEvent<{ selector?: string } | null>).detail
const docEl = this.iframe?.contentDocument
Expand Down
Loading
Loading