Skip to content
Open
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/appium-reads-skip-capture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@wdio/devtools-service': patch
---

In trace mode the Appium reads `isElementDisplayed`, `getContexts`, `getCurrentContext`, `getCurrentActivity`, `getCurrentPackage`, `isKeyboardShown`, `isLocked`, `queryAppState`, `isAppInstalled`, `getSettings` and `getStatus` no longer count as commands that change the screen. When one of them follows a test's last action, the end-of-test capture is named after that action rather than after the read, and the next action's capture is stamped at that action's end.
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ The test for a new helper: *would the backend ever need this to build or read a

In the WDIO service the per-action trace snapshot is captured in `beforeCommand`, stamped at the previous action's end, so an action's result IS the next action's "before" and every row resolves to a state the driver was idle for. Its `afterCommand` captures nothing in trace mode. An eager post-action capture — added and removed twice — lands while the screen is still moving, which is the only reason `waitForActionResult` and the `__wdioSnapMark` document tag existed; both are deleted, and both should stay that way. The last action has no successor, so `#finalizePerScenario` supplies it and names its capture after that action — `lastRenderedScreenshot` skips a capture named `__final__`, which is reserved for a session that ran no action at all. What that one capture waits on is `settleAfterLastAction`, and the wait is **gated, not timed**: the drain that runs immediately before it (`captureTrace(browser, true)`) anchors each document once, so `SessionCapturer.replacedDocumentInLastDrain` says whether the last action navigated to a document the session had not seen. No → return at once (the app has been at rest since the last action, so the test's own teardown is the gap that let the paint land). Yes → `waitUntil(document.readyState === 'complete')`, which is only meaningful once you know the document being described is the incoming one: ungated, the OUTGOING document already reports 'complete' right after a click, so a blind poll returns instantly and captures the page the test just left, and the old `body.childElementCount > 0` clause that papered over that made a legitimately blank destination a guaranteed 8 s timeout per test. Native pauses 250 ms instead (no document to poll; a capture at a 0 s gap measures 359–476 KB against 1.87 MB settled on Appium). Nightwatch re-captures a command whose capture predates the destination document's `performance.timeOrigin` (a navigation that committed after it returned), and discards that re-capture if a newer page capture was queued meanwhile. Residual: a navigation that has not *committed* when the anchor is read still reads as "no navigation" — the same blind spot the deleted document tag had. Don't reintroduce a poll without a gate, or a tag.

Selenium, Nightwatch and Python skip a capture only for a command `skipsCapture` (`shared/trace-actions.ts`) knows is a read, wait or lookup, and capture after every other command, including unmapped ones that can navigate (`executeScript`, `actions`, Nightwatch `submitForm`). A test's first mapped command always takes one. Selenium and Nightwatch (selenium `commandPostActions.ts` `queueActionSnapshot`, nightwatch `SessionCapturer#queueActionSnapshot`) capture right after the command itself. Rows after a test's last capture mean that capture saw a state the test later left, so the test takes one more capture at its end, stamped at that last capture (`core/final-frame.ts` `finalFrameStamp`) and superseding that record field by field, only when it has a screenshot; this covers a click that returns before its navigation commits. A test ending on a capturing command pays nothing. WDIO applies the same gate, but only to commands it renders as action rows, and captures before the next command rather than after its own, so it takes no end-of-test frame. In the JS adapters and Python a read, wait or lookup takes no capture, and inherits the latest earlier one through `claimAfter`.
Selenium, Nightwatch and Python skip a capture only for a command `skipsCapture` (`shared/trace-actions.ts`) knows is a read, wait or lookup, and capture after every other command, including unmapped ones that can navigate (`executeScript`, `actions`, Nightwatch `submitForm`). A test's first mapped command always takes one. Selenium and Nightwatch (selenium `commandPostActions.ts` `queueActionSnapshot`, nightwatch `SessionCapturer#queueActionSnapshot`) capture right after the command itself. Rows after a test's last capture mean that capture saw a state the test later left, so the test takes one more capture at its end, stamped at that last capture (`core/final-frame.ts` `finalFrameStamp`) and superseding that record field by field, only when it has a screenshot; this covers a click that returns before its navigation commits. A test ending on a capturing command pays nothing. WDIO applies the same gate, but only to commands it renders as action rows, and captures before the next command rather than after its own, so it takes no end-of-test frame. In the JS adapters and Python a read, wait or lookup takes no capture, and inherits the latest earlier one through `claimAfter`. In Python the capture includes the command's screenshot, so trace mode takes no screenshot for a read at all (`instrumentation.py` `_takes_capture`, held to core's gate by the `captureGate` mirror-vectors section); on a native app the capture is that screenshot alone, recorded as an action snapshot so the end-of-test capture can supersede it. Live mode still screenshots every command row, which is the dashboard's only picture of a native app. Without a pushed CDP stream those screenshots are also Python's filmstrip (there is no poller), so a command that takes no capture still takes a filmstrip-only frame (`_filmstrip_frame_due`): every command a `WebDriverWait` issues that returns (a poll that raises takes none), and any other read once shared's `SCREENCAST_DEFAULTS.pollIntervalMs` (generated as `SCREENCAST_POLL_INTERVAL_MS`) has passed since the last frame; the end-of-test capture is a frame too.

### Adapters are thin and isolated

Expand Down Expand Up @@ -213,7 +213,7 @@ The venv's editable install of `selenium_devtools` points at the primary checkou

### Logic implemented in both TypeScript and Python

The Python adapter cannot import `core`, so some pure logic exists twice. `packages/shared/mirror-vectors.json` holds input/expected-output cases for it, one section per mirrored function; `packages/selenium-devtools/tests/mirror-vectors.test.ts` runs every section against the TypeScript side under `pnpm test`, and `packages/selenium-devtools-py/tests/test_mirror_vectors.py` runs the same file against the Python side in the Python CI job (whose path filter includes the file). Both runners refuse a section they do not handle. Covered today: assertion target resolution, native-session detection, run id, output directory, command row id range, wait poll keys, nested-wait identity, wait row naming (whose negated case the TypeScript runner skips, selenium-webdriver having no negated wait), performance payload shaping and the capture timing summary; `notCovered` in the file says why screencast thinning is not. Add a case whenever a change touches either side of a mirrored function, and a section when new logic is mirrored, provided its output is deterministic without a driver. A deliberate difference between the two goes in the section's `notes`, not around the case.
The Python adapter cannot import `core`, so some pure logic exists twice. `packages/shared/mirror-vectors.json` holds input/expected-output cases for it, one section per mirrored function; `packages/selenium-devtools/tests/mirror-vectors.test.ts` runs every section against the TypeScript side under `pnpm test`, and `packages/selenium-devtools-py/tests/test_mirror_vectors.py` runs the same file against the Python side in the Python CI job (whose path filter includes the file). Both runners refuse a section they do not handle. Covered today: assertion target resolution, native-session detection, run id, output directory, command row id range, wait poll keys, nested-wait identity, wait row naming (whose negated case the TypeScript runner skips, selenium-webdriver having no negated wait), performance payload shaping, the capture timing summary, the end-of-test capture stamp, the collector's mask prelude, resource URL keys and the per-action capture gate; `notCovered` in the file says why screencast thinning is not. Add a case whenever a change touches either side of a mirrored function, and a section when new logic is mirrored, provided its output is deterministic without a driver. A deliberate difference between the two goes in the section's `notes`, not around the case.

### Trace zips from older writers

Expand Down
22 changes: 12 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,9 @@ Want a portable artifact instead of a live UI (CI / agent diffing)? Switch to **
| Run, Rerun and Run all from the dashboard | ◐ ² | ◐ ² | ◐ ² | ✓ |
| Preserve & Rerun (compare a failed run with its rerun) | ✓ | ✓ | ✓ | ✓ |
| Dashboard window opens by itself in live mode, and can be turned off | ◐ ³ | ✓ ⁴ | ◐ ⁵ | ✓ ⁶ |
| Turn off per-command screenshots (`captureScreenshots`) | ✕ ⁷ | ✓ | ✕ ⁸ | ✕ ⁸ |
| Force the test browser headless (`headless`) | ✕ ⁹ | ◐ ¹⁰ | ✕ ⁹ | ✕ ⁹ |
| Safari kept frontmost so input lands (live mode) | ◐ ¹¹ | ✓ ¹² | ◐ ¹¹ | ◐ ¹¹ |
| Turn off per-command screenshots (`captureScreenshots`) | ✕ ⁷ | ✓ | ✕ ⁸ | ✕ ⁹ |
| Force the test browser headless (`headless`) | ✕ ¹⁰ | ◐ ¹¹ | ✕ ¹⁰ | ✕ ¹⁰ |
| Safari kept frontmost so input lands (live mode) | ◐ ¹² | ✓ ¹³ | ◐ ¹² | ◐ ¹² |

✓ supported. ◐ partial. ✕ not supported. A number points to its note below the table. Each supported cell is backed by the cross-adapter conformance suite, a named test or a manual check, listed in [the feature list](https://github.com/webdriverio/devtools/blob/main/packages/conformance/src/features.ts).

Expand All @@ -80,13 +80,15 @@ Want a portable artifact instead of a live UI (CI / agent diffing)? Switch to **

⁸ No option: every command row takes a screenshot.

⁹ No option: set headless in the browser capabilities, as for any run.
⁹ No option: in live mode every command row takes a screenshot; in trace mode every row of a page-changing command does.

¹⁰ The option adds `--headless=old` to `goog:chromeOptions` only, so other browsers stay headed; set their own headless capability.
¹⁰ No option: set headless in the browser capabilities, as for any run.

¹¹ Brings Safari to the front once after the session starts (live mode, local macOS), so a click issued after another window took the front can still land nowhere.
¹¹ The option adds `--headless=old` to `goog:chromeOptions` only, so other browsers stay headed; set their own headless capability.

¹² Brings Safari to the front before each element click, type, clear and submit.
¹² Brings Safari to the front once after the session starts (live mode, local macOS), so a click issued after another window took the front can still land nowhere.

¹³ Brings Safari to the front before each element click, type, clear and submit.

<!-- capability-matrix:readme-dashboard:end -->

Expand Down Expand Up @@ -220,7 +222,7 @@ The Python adapter (`selenium-devtools-py`) writes the same archive through the

⁶ Nightwatch exposes no stable CDP handle, so frames come from a `takeScreenshot` poll every 200 ms.

⁷ Chromium streams over CDP; other browsers take one frame per command.
⁷ Chromium streams over CDP; other browsers take a frame per page-changing command, plus wait polls and reads at most every 200 ms.

⁸ Nightwatch fires the global `beforeEach` / `afterEach` once per test module for `describe/it` and exports-object tests, so they collapse to one slice keyed to the first test; Cucumber scenarios get real per-test slices.

Expand Down Expand Up @@ -400,7 +402,7 @@ raw records: /out/capture-timing-1.ndjson
- **Columns.** The JS adapters issue every read of a capture at once and the driver serves them in order, so a read's time from issue to settle includes waiting behind the reads before it. `svc` subtracts that wait: it is the read's own cost. `process` is the in-process step that turns the reads into a snapshot (page-source parsing and locator generation on native). `capture` is the whole capture.
- **Busy and wall.** `busy` is the time at least one capture was running, overlapping captures counted once; `wall` is the time since the process started, or since the previous report in the same process.
- **Triggers.** `action` is a capture for a user command, `wait-poll` one for a command a wait issued, `matcher-poll` one taken while a WebdriverIO `expect` matcher was polling, and `final` the WebdriverIO capture of the last action's result. Matcher polls are told apart on WebdriverIO only, and only with `captureAssertions` on; Selenium and Nightwatch matchers expose no hook to tell them by.
- **Python.** It reads one thing at a time, so `svc` equals the read's duration; it records the screenshot and the two page reads, and has no url, title or `process` rows.
- **Python.** It reads one thing at a time, so `svc` equals the read's duration; it records the screenshot and the two page reads, and has no url, title or `process` rows. Like the JS adapters, it captures only for a command that can change the page and for each test's first mapped command, so a read takes no capture and no record; a filmstrip-only frame it may still take without a pushed screencast is not timed.

To measure each platform from the repo's examples (the desktop examples other than Selenium's drive Chrome only):

Expand Down Expand Up @@ -679,7 +681,7 @@ The player is the `show-trace` bin of the Node backend the adapter already needs

Capture is always opt-in, and there are three ways to say yes — `--devtools` / `--devtools-trace` for one run, `devtools` / `devtools_trace` under `[tool.pytest.ini_options]` for a project, `DEVTOOLS_ENABLE=1` (or `DEVTOOLS_PORT=<n>`, which also attaches to a running backend) for a shell. Highest wins, in that order. A plain script calls `devtools.enable(trace=True)` instead.

A Python trace carries the dense filmstrip, the A11y tree with its element overlay, DOM time-travel, console, network, per-command screenshots and command selectors. The transforms that build the zip stay in the backend rather than being ported ([#298](https://github.com/webdriverio/devtools/issues/298)), so trace mode starts the backend but opens no window.
A Python trace carries the dense filmstrip, the A11y tree with its element overlay, DOM time-travel, console, network, screenshots and command selectors. As in the JS adapters, only a command that can change the page takes a capture; a read, wait or lookup shows the capture before it. The transforms that build the zip stay in the backend rather than being ported ([#298](https://github.com/webdriverio/devtools/issues/298)), so trace mode starts the backend but opens no window.

Two settings shape the output, as flags, ini options, environment variables or `enable()` arguments: `traceGranularity` (`session`, the default, or `test` for one archive per test) and `tracePolicy` (`on`, or `retain-on-failure` to keep only what failed). Together, `test` + `retain-on-failure` writes one archive per failing test and nothing for a green run. The four retry-aware policies are accepted but behave exactly like `retain-on-failure`: nothing on the wire carries an attempt number. Per-test `screenshot`, `video` and inline Allure attach remain Node.js-only; it is the trace archive that is per-test. Password fields are masked in the replayed page by default here too: `mask_inputs` / `--devtools-no-mask-inputs` turns it off and `mask_selectors` / `--devtools-mask-selector` masks more (see [Password masking](#-password-masking)).

Expand Down
2 changes: 2 additions & 0 deletions packages/conformance/src/capability-reasons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export const SAFARI_FOCUS_ONCE =
'Brings Safari to the front once after the session starts (live mode, local macOS), so a click issued after another window took the front can still land nowhere.'
export const NO_SCREENSHOT_SWITCH =
'No option: every command row takes a screenshot.'
export const PYTHON_NO_SCREENSHOT_SWITCH =
'No option: in live mode every command row takes a screenshot; in trace mode every row of a page-changing command does.'
export const NO_TRACE_FORMAT_TEST =
"The writer is tested in `packages/trace/tests/trace-exporter.test.ts`; no test covers the adapter handing `traceFormat` on, so check by hand with `traceFormat: 'ndjson-directory'` on the example."
export const NO_HEADLESS_OPTION =
Expand Down
4 changes: 2 additions & 2 deletions packages/conformance/src/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export const FEATURES = [
),
'selenium-devtools-py': covered(
`${PYTHON}test_cdp_screencast.py`,
'Chromium streams over CDP; other browsers take one frame per command.'
'Chromium streams over CDP; other browsers take a frame per page-changing command, plus wait polls and reads at most every 200 ms.'
)
}
},
Expand Down Expand Up @@ -355,7 +355,7 @@ export const FEATURES = [
'No unit test asserts that `captureScreenshots: false` skips the row screenshot; check on the Selenium example.'
),
'nightwatch-devtools': unsupported(REASONS.NO_SCREENSHOT_SWITCH),
'selenium-devtools-py': unsupported(REASONS.NO_SCREENSHOT_SWITCH)
'selenium-devtools-py': unsupported(REASONS.PYTHON_NO_SCREENSHOT_SWITCH)
}
},
{
Expand Down
Loading
Loading