From 2d93623e4c13cb757e84c55b00ca02ee9fde1370 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Tue, 10 Nov 2026 17:04:58 +0530 Subject: [PATCH 1/8] Name Appium's element and session reads as reads that skip a capture The Python adapter reaches Appium's is_displayed() as the isElementDisplayed wire command, and the context, activity, keyboard, lock and app-state queries under their own names. None of them is in READ_ONLY_COMMANDS, so the deny-list gate captured after each one as if it could have changed the screen. They join the list, and the wire schema and Python contract are regenerated from it. Co-Authored-By: Claude Opus 5.5 --- .../src/selenium_devtools/_contract.py | 2 +- packages/shared/src/trace-actions.ts | 13 ++++++++++++- packages/shared/wire-schema.json | 11 +++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/selenium-devtools-py/src/selenium_devtools/_contract.py b/packages/selenium-devtools-py/src/selenium_devtools/_contract.py index 7cd7c7ec..7d94b28e 100644 --- a/packages/selenium-devtools-py/src/selenium_devtools/_contract.py +++ b/packages/selenium-devtools-py/src/selenium_devtools/_contract.py @@ -17,7 +17,7 @@ SCOPE_REPLACE_COMMAND = "replaceCommand" DATA_SCOPES = frozenset(['actionSnapshots', 'commands', 'config', 'consoleLogs', 'logs', 'metadata', 'mutations', 'networkRequests', 'pageResources', 'resourceReReads', 'screencast', 'screencastFrames', 'sources', 'suites']) -READ_ONLY_COMMANDS = frozenset(['$', '$$', 'element', 'elementScreenshot', 'elements', 'findChildElement', 'findChildElements', 'findElement', 'findElementFromShadowRoot', 'findElements', 'findElementsFromShadowRoot', 'getAccessibleName', 'getActiveElement', 'getAlertText', 'getAllCookies', 'getAllWindowHandles', 'getAriaRole', 'getAttribute', 'getAvailableLogTypes', 'getCSSProperty', 'getCapabilities', 'getComputedLabel', 'getComputedRole', 'getContextHandles', 'getCookie', 'getCookies', 'getCredentials', 'getCssProperty', 'getCssValue', 'getCurrentContextHandle', 'getCurrentUrl', 'getDomAttribute', 'getDownloadableFiles', 'getElementAriaLabel', 'getElementAriaRole', 'getElementAttribute', 'getElementProperty', 'getElementRect', 'getElementShadowRoot', 'getElementSize', 'getElementTagName', 'getElementText', 'getElementValueOfCssProperty', 'getFirstElementChild', 'getHTML', 'getId', 'getLastElementChild', 'getLocation', 'getLog', 'getLogTypes', 'getNetworkConnection', 'getNextSibling', 'getPageSource', 'getPreviousSibling', 'getProperty', 'getRect', 'getScreenOrientation', 'getSession', 'getShadowRoot', 'getSize', 'getTagName', 'getText', 'getTimeouts', 'getTitle', 'getUrl', 'getValue', 'getWindowHandle', 'getWindowHandles', 'getWindowPosition', 'getWindowRect', 'getWindowSize', 'hasDescendants', 'isClickable', 'isDisplayed', 'isElementEnabled', 'isElementSelected', 'isEnabled', 'isExisting', 'isFocused', 'isLogAvailable', 'isPresent', 'isSelected', 'isVisible', 'printPage', 'screenshot', 'source', 'status', 'takeScreenshot', 'title', 'w3cGetActiveElement', 'w3cGetAlertText', 'w3cGetCurrentWindowHandle', 'w3cGetWindowHandles', 'wait', 'waitForClickable', 'waitForDisplayed', 'waitForElementLocated', 'waitForElementNotPresent', 'waitForElementNotVisible', 'waitForElementPresent', 'waitForElementVisible', 'waitForEnabled', 'waitForExist', 'waitUntil', 'windowHandle', 'windowHandles']) +READ_ONLY_COMMANDS = frozenset(['$', '$$', 'element', 'elementScreenshot', 'elements', 'findChildElement', 'findChildElements', 'findElement', 'findElementFromShadowRoot', 'findElements', 'findElementsFromShadowRoot', 'getAccessibleName', 'getActiveElement', 'getAlertText', 'getAllCookies', 'getAllWindowHandles', 'getAriaRole', 'getAttribute', 'getAvailableLogTypes', 'getCSSProperty', 'getCapabilities', 'getComputedLabel', 'getComputedRole', 'getContextHandles', 'getContexts', 'getCookie', 'getCookies', 'getCredentials', 'getCssProperty', 'getCssValue', 'getCurrentActivity', 'getCurrentContext', 'getCurrentContextHandle', 'getCurrentPackage', 'getCurrentUrl', 'getDomAttribute', 'getDownloadableFiles', 'getElementAriaLabel', 'getElementAriaRole', 'getElementAttribute', 'getElementProperty', 'getElementRect', 'getElementShadowRoot', 'getElementSize', 'getElementTagName', 'getElementText', 'getElementValueOfCssProperty', 'getFirstElementChild', 'getHTML', 'getId', 'getLastElementChild', 'getLocation', 'getLog', 'getLogTypes', 'getNetworkConnection', 'getNextSibling', 'getPageSource', 'getPreviousSibling', 'getProperty', 'getRect', 'getScreenOrientation', 'getSession', 'getSettings', 'getShadowRoot', 'getSize', 'getStatus', 'getTagName', 'getText', 'getTimeouts', 'getTitle', 'getUrl', 'getValue', 'getWindowHandle', 'getWindowHandles', 'getWindowPosition', 'getWindowRect', 'getWindowSize', 'hasDescendants', 'isAppInstalled', 'isClickable', 'isDisplayed', 'isElementDisplayed', 'isElementEnabled', 'isElementSelected', 'isEnabled', 'isExisting', 'isFocused', 'isKeyboardShown', 'isLocked', 'isLogAvailable', 'isPresent', 'isSelected', 'isVisible', 'printPage', 'queryAppState', 'screenshot', 'source', 'status', 'takeScreenshot', 'title', 'w3cGetActiveElement', 'w3cGetAlertText', 'w3cGetCurrentWindowHandle', 'w3cGetWindowHandles', 'wait', 'waitForClickable', 'waitForDisplayed', 'waitForElementLocated', 'waitForElementNotPresent', 'waitForElementNotVisible', 'waitForElementPresent', 'waitForElementVisible', 'waitForEnabled', 'waitForExist', 'waitUntil', 'windowHandle', 'windowHandles']) MAPPED_COMMANDS = frozenset(['addValue', 'back', 'clear', 'clearElement', 'click', 'clickElement', 'doubleClick', 'dragAndDrop', 'execute', 'executeAsync', 'forward', 'get', 'getAttribute', 'getCSSProperty', 'getCssValue', 'getCurrentUrl', 'getElementAttribute', 'getElementProperty', 'getElementRect', 'getElementTagName', 'getElementText', 'getElementValueOfCssProperty', 'getLocation', 'getPageSource', 'getProperty', 'getRect', 'getSize', 'getTagName', 'getText', 'getTitle', 'getUrl', 'getValue', 'goBack', 'goForward', 'isClickable', 'isDisplayed', 'isElementEnabled', 'isElementSelected', 'isEnabled', 'isExisting', 'isFocused', 'isSelected', 'keys', 'moveTo', 'navigateTo', 'newWindow', 'refresh', 'scrollIntoView', 'selectByVisibleText', 'sendKeys', 'sendKeysToElement', 'setValue', 'submit', 'switchToFrame', 'switchToParentFrame', 'switchToWindow', 'to', 'touchAction', 'url', 'wait', 'waitForClickable', 'waitForDisplayed', 'waitForElementLocated', 'waitForElementNotPresent', 'waitForElementNotVisible', 'waitForElementPresent', 'waitForElementVisible', 'waitForEnabled', 'waitForExist', 'waitUntil']) CONTROL_SCOPES = frozenset(['clearCommands', 'clearExecutionData', 'clientConnected', 'clientDisconnected', 'config', 'replaceCommand', 'testStopped']) diff --git a/packages/shared/src/trace-actions.ts b/packages/shared/src/trace-actions.ts index a60ce0ed..5ad917b5 100644 --- a/packages/shared/src/trace-actions.ts +++ b/packages/shared/src/trace-actions.ts @@ -338,7 +338,18 @@ export const READ_ONLY_COMMANDS: ReadonlySet = new Set([ 'getDomAttribute', 'getId', 'getCapabilities', - 'getSession' + 'getSession', + 'isElementDisplayed', + 'getContexts', + 'getCurrentContext', + 'getCurrentActivity', + 'getCurrentPackage', + 'isKeyboardShown', + 'isLocked', + 'queryAppState', + 'isAppInstalled', + 'getSettings', + 'getStatus' ]) /** Whether a command is known not to change the page, so its capture can be diff --git a/packages/shared/wire-schema.json b/packages/shared/wire-schema.json index f0d6846b..af27d18a 100644 --- a/packages/shared/wire-schema.json +++ b/packages/shared/wire-schema.json @@ -1605,12 +1605,16 @@ "getComputedLabel", "getComputedRole", "getContextHandles", + "getContexts", "getCookie", "getCookies", "getCredentials", "getCssProperty", "getCssValue", + "getCurrentActivity", + "getCurrentContext", "getCurrentContextHandle", + "getCurrentPackage", "getCurrentUrl", "getDomAttribute", "getDownloadableFiles", @@ -1639,8 +1643,10 @@ "getRect", "getScreenOrientation", "getSession", + "getSettings", "getShadowRoot", "getSize", + "getStatus", "getTagName", "getText", "getTimeouts", @@ -1653,18 +1659,23 @@ "getWindowRect", "getWindowSize", "hasDescendants", + "isAppInstalled", "isClickable", "isDisplayed", + "isElementDisplayed", "isElementEnabled", "isElementSelected", "isEnabled", "isExisting", "isFocused", + "isKeyboardShown", + "isLocked", "isLogAvailable", "isPresent", "isSelected", "isVisible", "printPage", + "queryAppState", "screenshot", "source", "status", From e87aba411532d5802ed5af6a793ee2d11d9e0f44 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Tue, 10 Nov 2026 17:04:58 +0530 Subject: [PATCH 2/8] perf(selenium-devtools-py): take no screenshot for a read in trace mode Only the element-tree read followed the JS adapters' page-changing gate; the screenshot was still taken after every command, so reads, waits and lookups were most of a Python trace's capture cost (42 captures on the Android Clock flow where WebdriverIO takes 8, 44% of wall time). Trace mode now takes the screenshot only when the command takes the capture, and a read's row shows the capture before it, as in the JS adapters. Live mode keeps a screenshot per row, the dashboard's only picture of a native app. A native app's capture is now recorded as a screenshot-only action snapshot, so the end-of-test capture can supersede a stale one there too; before, the reads after a click carried their own screenshots and hid that gap. The gate is mirrored by a new captureGate section in mirror-vectors.json, run against core's TestCapture.queue and shared's skipsCapture on one side and the Python hook's classification, atom scripts included, on the other. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +- README.md | 42 +++--- .../conformance/src/capability-reasons.ts | 2 + packages/conformance/src/features.ts | 9 +- packages/selenium-devtools-py/README.md | 30 ++-- .../read-commands-take-no-screenshot.md | 5 + .../src/selenium_devtools/instrumentation.py | 73 ++++++---- .../tests/test_a11y_elements.py | 37 +++-- .../tests/test_capture_gate.py | 134 ++++++++++++++++++ .../tests/test_capture_timing.py | 8 +- .../tests/test_mirror_vectors.py | 18 +++ .../tests/mirror-vectors.test.ts | 28 +++- packages/shared/mirror-vectors.json | 49 +++++++ 13 files changed, 358 insertions(+), 81 deletions(-) create mode 100644 packages/selenium-devtools-py/changes/read-commands-take-no-screenshot.md create mode 100644 packages/selenium-devtools-py/tests/test_capture_gate.py diff --git a/CLAUDE.md b/CLAUDE.md index 6de6eb7d..437ff448 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. ### Adapters are thin and isolated @@ -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 diff --git a/README.md b/README.md index 398ce0a1..4acbd1c4 100644 --- a/README.md +++ b/README.md @@ -49,44 +49,48 @@ Want a portable artifact instead of a live UI (CI / agent diffing)? Switch to ** |---|---|---|---|---| | One row per command in the Actions list | ✓ | ✓ | ✓ | ✓ | | Waits show as one row with what they issued nested under it | ✓ | ✓ | ◐ ¹ | ✓ | -| Screenshot on every command row | ✓ | ✓ | ✓ | ✓ | +| Screenshot on every command row | ✓ | ✓ | ✓ | ✓ ² | | Browser console logs | ✓ | ✓ | ✓ | ✓ | | Source tab opens the test line that ran the command | ✓ | ✓ | ✓ | ✓ | | Metadata tab (session, capabilities, device) | ✓ | ✓ | ✓ | ✓ | | Performance timings on navigation rows | ✓ | ✓ | ✓ | ✓ | | Failed command in the Errors tab, with the browser's message | ✓ | ✓ | ✓ | ✓ | -| Run, Rerun and Run all from the dashboard | ◐ ² | ◐ ² | ◐ ² | ✓ | +| 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) | ◐ ¹¹ | ✓ ¹² | ◐ ¹¹ | ◐ ¹¹ | +| 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) | ◐ ¹³ | ✓ ¹⁴ | ◐ ¹³ | ◐ ¹³ | ✓ 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). ¹ A `waitUntil` row holds the commands its condition issued; a `waitForElement*` row has nothing under it, because Nightwatch polls for the element through its own transport rather than `browser`. -² Under Cucumber a Gherkin step has no CLI filter of its own, so the dashboard reruns scenarios and features, not single steps. +² Every command row takes a screenshot in live mode; in trace mode a read, wait or lookup takes none and its row shows the capture before it. -³ The service always opens the dashboard in live mode; `devtoolsCapabilities` picks its browser but cannot skip it. +³ Under Cucumber a Gherkin step has no CLI filter of its own, so the dashboard reruns scenarios and features, not single steps. -⁴ Turn it off with `openUi: false`. +⁴ The service always opens the dashboard in live mode; `devtoolsCapabilities` picks its browser but cannot skip it. -⁵ Nightwatch always opens the dashboard in live mode. +⁵ Turn it off with `openUi: false`. -⁶ Turn it off with `DEVTOOLS_OPEN=0`. +⁶ Nightwatch always opens the dashboard in live mode. -⁷ No option: every command row takes a screenshot, except on Appium, where only native sessions in live mode do. +⁷ Turn it off with `DEVTOOLS_OPEN=0`. -⁸ No option: every command row takes a screenshot. +⁸ No option: every command row takes a screenshot, except on Appium, where only native sessions in live mode do. -⁹ No option: set headless in the browser capabilities, as for any run. +⁹ No option: every command row takes a screenshot. -¹⁰ The option adds `--headless=old` to `goog:chromeOptions` only, so other browsers stay headed; set their own headless capability. +¹⁰ No option. Every command row takes a screenshot in live mode; in trace mode a read, wait or lookup takes none and its row shows the capture before it. -¹¹ 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. +¹¹ No option: set headless in the browser capabilities, as for any run. -¹² Brings Safari to the front before each element click, type, clear and submit. +¹² The option adds `--headless=old` to `goog:chromeOptions` only, so other browsers stay headed; set their own headless capability. + +¹³ 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. @@ -400,7 +404,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 screenshot and no record. To measure each platform from the repo's examples (the desktop examples other than Selenium's drive Chrome only): @@ -679,7 +683,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=`, 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)). diff --git a/packages/conformance/src/capability-reasons.ts b/packages/conformance/src/capability-reasons.ts index 5f599d1d..5165ee77 100644 --- a/packages/conformance/src/capability-reasons.ts +++ b/packages/conformance/src/capability-reasons.ts @@ -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_TRACE_READS_NO_SCREENSHOT = + 'Every command row takes a screenshot in live mode; in trace mode a read, wait or lookup takes none and its row shows the capture before it.' 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 = diff --git a/packages/conformance/src/features.ts b/packages/conformance/src/features.ts index 0a17177a..19295666 100644 --- a/packages/conformance/src/features.ts +++ b/packages/conformance/src/features.ts @@ -250,7 +250,10 @@ export const FEATURES = [ 'devtools-service': covered(`${WDIO}session.test.ts`), 'selenium-devtools': covered(`${SELENIUM}teardown-settle.test.ts`), 'nightwatch-devtools': byHand(REASONS.NIGHTWATCH_ROW_SCREENSHOT_BY_HAND), - 'selenium-devtools-py': covered(`${PYTHON}test_instrumentation.py`) + 'selenium-devtools-py': covered( + `${PYTHON}test_capture_gate.py`, + REASONS.PYTHON_TRACE_READS_NO_SCREENSHOT + ) } }, { @@ -355,7 +358,9 @@ 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( + `No option. ${REASONS.PYTHON_TRACE_READS_NO_SCREENSHOT}` + ) } }, { diff --git a/packages/selenium-devtools-py/README.md b/packages/selenium-devtools-py/README.md index e2f9911e..1c7d3af4 100644 --- a/packages/selenium-devtools-py/README.md +++ b/packages/selenium-devtools-py/README.md @@ -8,7 +8,7 @@ the **same backend and UI**, unchanged, over the language-neutral **Both modes work.** **Live** streams to a dashboard window that auto-opens and tears down with the run; **trace** writes the same portable `trace.zip` the JavaScript adapters do and opens no window. Command capture and the test tree, -browser console & network via BiDi, assertion rows, per-command screenshots and +browser console & network via BiDi, assertion rows, screenshots and selectors, DOM replay, and screencast video are common to both. Verified against real headless Chrome. See [Trace mode](#trace-mode) and [Roadmap](#roadmap). @@ -177,7 +177,7 @@ The first row that applies wins. A policy or granularity left without trace mode does nothing, and the run logs a warning saying so. `DEVTOOLS_CAPTURE_TIMING=1` on a trace-mode run times the screenshot and the -two page reads taken for each command. When the trace is exported, the run +two page reads taken for each capture. When the trace is exported, the run prints a per-read table to stderr and appends the raw records to `capture-timing-.ndjson` beside the trace, in the format the JS adapters write. It only measures; see "Measuring capture cost" in the repo README. @@ -252,7 +252,7 @@ is on `PATH`; otherwise keep it current (`brew upgrade chromedriver`). | Data | How | Scope | Mode | |---|---|---|---| | Commands (driver + element) | wrap `WebDriver.execute()` — the single chokepoint all commands flow through | `commands` | both | -| Command screenshot + selector | one screenshot per command, and the locator the element handle was found by | `commands` | both | +| Command screenshot + selector | one screenshot per command in live mode, per captured command in trace mode (below), and the locator the element handle was found by | `commands` | both | | Session metadata | read `session_id` + `caps` on the first ready command | `metadata` | both | | Test / suite tree | pytest plugin (`pytest_runtest_logreport` / `sessionfinish`) | `suites` | both | | Browser console + JS errors | Selenium **BiDi** (`driver.script` handlers) | `consoleLogs` | both | @@ -261,9 +261,9 @@ is on `PATH`; otherwise keep it current (`brew upgrade chromedriver`). | Assertions | pytest hooks under pytest; line tracing for a plain script | `commands` | both | | DOM snapshot / time-travel | register `packages/script` as a BiDi document-start preload, drain mutations with a forced document anchor (per-document `