diff --git a/.changeset/native-page-source-capture.md b/.changeset/native-page-source-capture.md new file mode 100644 index 00000000..1fd42e88 --- /dev/null +++ b/.changeset/native-page-source-capture.md @@ -0,0 +1,6 @@ +--- +"@wdio/selenium-devtools": patch +"@wdio/nightwatch-devtools": patch +--- + +Capture the element list and accessibility tree per action on native Appium sessions. In trace mode each action of a native app now reads the app's page source beside its screenshot, as the WebdriverIO service does, so a native trace carries element data, locators and the A11y tab instead of a screenshot alone. The page source is the costly part of a native capture, most of it on iOS, and it shows as the `pageSource` probe in the `DEVTOOLS_CAPTURE_TIMING=1` report, which now marks these captures native. diff --git a/CLAUDE.md b/CLAUDE.md index 6de6eb7d..0b8e107f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -397,7 +397,8 @@ Documented divergences from the conventions above. They exist today as debt to b ### What a per-action trace capture costs - **Measure before proposing a change: `DEVTOOLS_CAPTURE_TIMING=1`.** Every adapter then times each probe of each capture (issue and settle, so a probe's own service time is separable from its wait behind the probes the driver served first) and, when the trace is written, prints a per-probe table and appends the raw records to `capture-timing-.ndjson` beside the trace. The JS recording is `core/capture-timing.ts`, wired once in `captureActionSnapshot` and reported from `finalizeTraceExport`, so no adapter carries more than the capture's trigger (action, wait poll, matcher poll, final); Python mirrors it in `capture_timing.py`, held to the same summary by `mirror-vectors.json`. Off, it costs a flag read per capture. The per-platform commands are in the README's "Measuring capture cost". -- **One capture per action means two driver round trips per action, and the platform decides which half is expensive.** Measured on Appium 3.7.0 / UiAutomator2 (emulator-5554, Android API 37, 1080×2424): `GET /screenshot` 1.18–2.22 s (steady ~1.20 s) at 1.86 MB, `GET /source` (page-source XML) 0.09–0.53 s at 40 KB, `window/rect` 0.015 s — the screenshot dominates by ~10× on Android. **iOS is unmeasured**; the issue claims the split inverts there, and the native example (`examples/wdio/mocha/wdio.native.conf.ts`) is what makes it measurable. Measured per action: two captures 2.41 s against one capture 1.19 s; end-to-end on the native example 19.1 s against 12.4 s, with live mode (no per-action capture) at 5.9 s. That is the case for the `beforeCommand` capture, and the reason an eager post-action capture is not worth re-adding: it cost a second capture per action and existed only to be patched by a `readyState` poll that could not tell a document that had not navigated yet from one that was loading. +- **One capture per action means two driver round trips per action, and the platform decides which half is expensive.** Measured on Appium 3.7.0 / UiAutomator2 (emulator-5554, Android API 37, 1080×2424): `GET /screenshot` 1.18–2.22 s (steady ~1.20 s) at 1.86 MB, `GET /source` (page-source XML) 0.09–0.53 s at 40 KB, `window/rect` 0.015 s; the screenshot dominates by ~10× on Android. A later timing run (v11.2.0, `DEVTOOLS_CAPTURE_TIMING=1`, a loaded machine, so rough) split an Android WebdriverIO capture about evenly between the two (384-471 ms per capture) and put the page source at **817 ms of a 938 ms iOS capture (87%)**: the split inverts on iOS. Measured per action: two captures 2.41 s against one capture 1.19 s; end-to-end on the native example 19.1 s against 12.4 s, with live mode (no per-action capture) at 5.9 s. That is the case for the `beforeCommand` capture, and the reason an eager post-action capture is not worth re-adding: it cost a second capture per action and existed only to be patched by a `readyState` poll that could not tell a document that had not navigated yet from one that was loading. +- **A native capture is the screenshot plus the page source, in every JS adapter.** A native app serves no page script, url or title, so core's `captureActionSnapshot` builds the element list, the accessibility tree and the locators from the page-source XML instead (`fromPageSource`), and an adapter only supplies the reader: the service `getPageSource` (or its direct probe), Selenium the unpatched original (`getDriverOriginals().getPageSource`, so the read is never a row of the user's), Nightwatch `GET /source` over the raw WebDriver transport. The Selenium and Nightwatch adapters ask core's `SessionCapturer.nativeApp` (`nativeAppOf`), which carries the platform the XML is read by (none for a Mac2 or tvOS app, read as Android). A reader passed is also what marks the capture `native` in the timing report and times its `pageSource` probe. The read is the costly half of a native capture (above), so trace mode on iOS pays for it on every action. Python takes the screenshot alone on a native app (`_action_capture_enabled` skips the page reads and reads no source), so its native traces carry no element list or accessibility tree. - **Locator generation from a native page source must stay linear, and iOS is where it did not.** Every native action snapshot builds element locators from the page-source XML (`core/locators`), and each uniqueness check was a `//*[@name="…"]` XPath run by the `xpath` library over the whole document, several per element. Measured on the iOS Settings page source tiled up: 38 KB 82 ms, 188 KB 1.4 s, 752 KB 23.5 s, which is what a reporter saw as trace mode adding 1.5 min to a 2 min 20 s iOS test while Android, with smaller flatter trees, added 10 s. `locators/xpath-index.ts` indexes the document once per `generateAllElementLocators` walk (`withXPathIndex`) and answers `//*`, `//Tag` and `//Tag[@a="x" and …]` from it: 15 ms, 49 ms, 268 ms for the same three, identical elements. It is scoped to the walk so a caller of the exported `evaluateXPath` that edits its document is never answered stale, and it declines a namespaced document, where XPath name tests resolve namespaces and the index does not. The page-source request itself remains: 0.75-1.2 s per action on XCUITest even for Settings, against 0.09-0.5 s on UiAutomator2. - A filmstrip poll against a native session stacks requests: `setInterval` never waits for its async handler, a native screenshot takes ~1.2 s against the 200 ms default, and a serialised driver serves that queue ahead of the test's own commands — measured, a **15 ms command took 4.5–7.8 s**. `ScreencastRecorderBase`'s `#pollInFlight` latch bounds it to one outstanding shot, and `#pollGeneration` stops a shot orphaned by `stop()` from appending after the stop or clearing its successor's latch. - **`start()` and `stop()` are serialised against each other, because unserialised neither owns what it arms.** A `stop()` landing mid-handshake returned at its `#isRecording` guard — still false, the handshake not having finished — so it never reached its own CDP teardown, and the `start()` it interrupted then returned on the generation mismatch without stopping what it had armed: the session and its frame listener stayed live after teardown. The frames themselves were moot — every adapter builds a fresh recorder per session (`screencast-lifecycle.ts` does so even across `reloadSession`), so the leaked session and its browser-side stream were the damage, not buffer pollution. The obvious repair — stopping the session on that mismatch path — is wrong, and it is the shape to keep out: it guards a field both CDP overrides write at handshake **start** (`#cdpSession` / `#cdp`) with a flag they set only at handshake **end** (`#cdpActive`), so a start arriving in that window is indistinguishable from no start at all and the stale path tears down the *newer* recording's session instead. Overlapping starts are the adapters' normal mode rather than a corner: Selenium's `driverPatcher` documents the second test's body racing an in-flight `screencast.start()`, and Nightwatch's session rotation is fire-and-forget from a command hook. Queued, a stop always runs against a start that has finished arming and owns what it armed, which makes the mismatch branch unreachable and deleted — the tick-phase generation checks in `#startPolling` are **not**, since a shot orphaned by a stop still lands afterwards. Two consequences: `stop()` now waits on an in-flight handshake, and every awaited driver primitive in that handshake is ceilinged at `SCREENCAST_HANDSHAKE_TIMEOUT_MS` (the service's `getPuppeteer()`/`pages()`/`createCDPSession()`/`Page.startScreencast` and the stop-side `Page.stopScreencast`, Selenium's `createCDPConnection`, the polling path's first screenshot) — a driver that wedges gives up after the ceiling, falls back to polling, or reports the screencast unavailable when polling was what wedged. Nothing is claimed until the handshake has answered, so a timed-out start leaves nothing armed — and a session or connection that completes after the ceiling is detached (or its socket closed) when it lands, so no orphan outlives the recording. And a stop landing during a polling session's first screenshot now records that shot before tearing the loop down, where it previously cancelled the start outright; the interval is still never left armed underneath the stop. diff --git a/README.md b/README.md index 398ce0a1..28ce50b4 100644 --- a/README.md +++ b/README.md @@ -380,7 +380,7 @@ services: [[DevToolsHookService, { #### Measuring capture cost -A trace-mode run is slower than a live one because, for every action, the adapter reads the page through the driver: url, title, accessibility tree, element list and a screenshot, plus the page source on a native app. Set `DEVTOOLS_CAPTURE_TIMING=1` on a trace-mode run to see where that time goes. It only measures; nothing is captured differently, and with it unset nothing is timed or written. +A trace-mode run is slower than a live one because, for every action, the adapter reads the page through the driver: url, title, accessibility tree, element list and a screenshot. A native app has none of the page reads: its capture is a screenshot plus the page source, from which the element list and accessibility tree are built, and the page source is the costly half (about half of an Android capture, and 87% of an iOS one, measured on WebdriverIO). Set `DEVTOOLS_CAPTURE_TIMING=1` on a trace-mode run to see where that time goes. It only measures; nothing is captured differently, and with it unset nothing is timed or written. When the trace is written, the run prints a table to stderr and appends the raw records, one line per capture, to `capture-timing-.ndjson` beside the trace (one file per worker process): @@ -400,7 +400,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. On a native app it reads the screenshot alone. To measure each platform from the repo's examples (the desktop examples other than Selenium's drive Chrome only): @@ -453,18 +453,21 @@ A hybrid app is the case capabilities alone cannot answer, because the answer ch | Mobile browser sessions (Chrome on Android, Safari on iOS) | ✓ | ✓ | ✓ | ✓ | | iOS (XCUITest): native apps and Safari | ✓ | ✓ | ✓ | ✓ | | Native session detected, page-side capture skipped | ✓ | ✓ | ✓ | ✓ | -| Follows a hybrid app into its webview | ◐ ² | ◐ ³ | ◐ ³ | ◐ ³ | -| Native app network through appium-interceptor (`appium:intercept`) | ✓ ⁴ | ✓ ⁴ | ✓ ⁴ | ✓ ⁴ | +| Native app element list and accessibility tree per action (trace mode) | ✓ | ✓ | ✓ | ✕ ² | +| Follows a hybrid app into its webview | ◐ ³ | ◐ ⁴ | ◐ ⁴ | ◐ ⁴ | +| Native app network through appium-interceptor (`appium:intercept`) | ✓ ⁵ | ✓ ⁵ | ✓ ⁵ | ✓ ⁵ | ✓ 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). ¹ Fetch and XHR calls carry headers and bodies; every other load comes from the page's Resource Timing buffer, with timing and size but no headers or body, and no status on Safari. -² Follows `switchContext('WEBVIEW_...')`; the object form (`{ title }` / `{ url }`) and `switchAppiumContext` leave capture on the native side. +² On a native app the Python adapter takes only a screenshot per action; it reads no page source, so the trace has no element list or accessibility tree. -³ Decides from the startup capabilities, so a hybrid app's webview half is captured as if it were still native. +³ Follows `switchContext('WEBVIEW_...')`; the object form (`{ title }` / `{ url }`) and `switchAppiumContext` leave capture on the native side. -⁴ Android only; appium-interceptor is an Android plugin. +⁴ Decides from the startup capabilities, so a hybrid app's webview half is captured as if it were still native. + +⁵ Android only; appium-interceptor is an Android plugin. @@ -472,7 +475,7 @@ A hybrid app is the case capabilities alone cannot answer, because the answer ch **What a mobile trace does not contain.** Per-action snapshots are issued from inside the command hook, and Appium serialises a probe behind the command it is observing — so where a session has a document to probe, the per-action snapshot is skipped. In practice that means **a hybrid app's webview actions carry no per-action element data, accessibility tree or settle screenshot**. Native sessions, mobile browsers and every desktop session are unaffected. Command rows and their screenshots, console and the archive itself are always captured; a mobile browser's network comes from the page's Resource Timing and fetch/XHR patch rather than from BiDi, and a native session has no network capture, because the driver exposes none. These absences are by design; they are not a capture failure. -On a native session, element data and the A11y panel come from the platform's XML tree rather than the DOM, so locators read as `android=new UiSelector()…` / `-ios predicate string:…` rather than CSS or XPath over HTML. +On a native session, element data and the A11y panel come from the platform's XML tree (its page source, read once per action in trace mode by the WebdriverIO, Selenium and Nightwatch adapters; the Python adapter captures a screenshot alone) rather than the DOM, so locators read as `android=new UiSelector()…` / `-ios predicate string:…` rather than CSS or XPath over HTML. A runnable example per adapter lives under [examples/](examples/) — `pnpm demo:wdio:mobile`, `:selenium:mobile`, `:nightwatch:mobile`, `:python:mobile`. Each drives the Clock app that ships with every Android system image, keying a duration on the timer keypad without ever starting a timer, so none needs an `.apk` (iOS drives Settings via `DEVTOOLS_MOBILE_PLATFORM=ios`); `DEVTOOLS_MOBILE=web` drives the device's own browser instead — Chrome on Android, Safari on iOS. [examples/MOBILE.md](examples/MOBILE.md) states the prerequisites and switches in one place. A single-adapter WDIO variant also lives at [examples/wdio/mocha/wdio.native.conf.ts](examples/wdio/mocha/wdio.native.conf.ts) (`pnpm demo:wdio:native`), which reads its Appium endpoint from `APPIUM_HOST` / `APPIUM_PORT` / `APPIUM_DEVICE`. In short, for a local emulator: diff --git a/packages/conformance/src/capability-reasons.ts b/packages/conformance/src/capability-reasons.ts index 5f599d1d..2609a276 100644 --- a/packages/conformance/src/capability-reasons.ts +++ b/packages/conformance/src/capability-reasons.ts @@ -38,3 +38,11 @@ export const NIGHTWATCH_METADATA_BY_HAND = 'No test asserts the capabilities in the metadata the adapter sends; check the Metadata tab with `pnpm demo:nightwatch`.' export const SERVICE_MANIFEST_BY_HAND = 'No test covers the service turning the manifest on when an Allure reporter is configured; check by hand with one configured.' +export const PYTHON_NATIVE_SCREENSHOT = + 'On a native app the Python adapter takes only a screenshot per action; it reads no page source, so the trace has no element list or accessibility tree.' +export const PYTHON_NO_MANIFEST = + 'The Python adapter writes no artifacts manifest.' +export const SERVICE_SCREENSHOT_ALWAYS = + 'No option: every command row takes a screenshot, except on Appium, where only native sessions in live mode do.' +export const SELENIUM_SCREENSHOT_SWITCH_BY_HAND = + 'No unit test asserts that `captureScreenshots: false` skips the row screenshot; check on the Selenium example.' diff --git a/packages/conformance/src/features.ts b/packages/conformance/src/features.ts index 0a17177a..d0a4fe3a 100644 --- a/packages/conformance/src/features.ts +++ b/packages/conformance/src/features.ts @@ -25,6 +25,10 @@ const WDIO = ADAPTER_TESTS['devtools-service'] const SELENIUM = ADAPTER_TESTS['selenium-devtools'] const NIGHTWATCH = ADAPTER_TESTS['nightwatch-devtools'] const PYTHON = ADAPTER_TESTS['selenium-devtools-py'] +const NO_STEP_RERUN = partial( + REASONS.NO_CUCUMBER_STEP_RERUN, + 'no Cucumber step rerun' +) export const FEATURES = [ { @@ -298,18 +302,9 @@ export const FEATURES = [ name: 'Run, Rerun and Run all from the dashboard', tables: ['readme-dashboard', 'cross-framework'], cells: { - 'devtools-service': partial( - REASONS.NO_CUCUMBER_STEP_RERUN, - 'no Cucumber step rerun' - ), - 'selenium-devtools': partial( - REASONS.NO_CUCUMBER_STEP_RERUN, - 'no Cucumber step rerun' - ), - 'nightwatch-devtools': partial( - REASONS.NO_CUCUMBER_STEP_RERUN, - 'no Cucumber step rerun' - ), + 'devtools-service': NO_STEP_RERUN, + 'selenium-devtools': NO_STEP_RERUN, + 'nightwatch-devtools': NO_STEP_RERUN, // pytest has no Gherkin steps to rerun. 'selenium-devtools-py': covered(`${PYTHON}test_rerun.py`) } @@ -348,12 +343,8 @@ export const FEATURES = [ name: 'Turn off per-command screenshots (`captureScreenshots`)', tables: ['readme-dashboard', 'cross-framework'], cells: { - 'devtools-service': unsupported( - 'No option: every command row takes a screenshot, except on Appium, where only native sessions in live mode do.' - ), - 'selenium-devtools': byHand( - 'No unit test asserts that `captureScreenshots: false` skips the row screenshot; check on the Selenium example.' - ), + 'devtools-service': unsupported(REASONS.SERVICE_SCREENSHOT_ALWAYS), + 'selenium-devtools': byHand(REASONS.SELENIUM_SCREENSHOT_SWITCH_BY_HAND), 'nightwatch-devtools': unsupported(REASONS.NO_SCREENSHOT_SWITCH), 'selenium-devtools-py': unsupported(REASONS.NO_SCREENSHOT_SWITCH) } @@ -411,9 +402,7 @@ export const FEATURES = [ 'Nightwatch has no live Allure signal to turn the manifest on by itself, so it stays opt-in.', 'opt-in' ), - 'selenium-devtools-py': unsupported( - 'The Python adapter writes no artifacts manifest.' - ) + 'selenium-devtools-py': unsupported(REASONS.PYTHON_NO_MANIFEST) } }, { @@ -474,6 +463,17 @@ export const FEATURES = [ 'selenium-devtools-py': covered(`${PYTHON}test_device.py`) } }, + { + id: 'native-element-tree', + name: 'Native app element list and accessibility tree per action (trace mode)', + tables: ['readme-mobile', 'mobile'], + cells: { + 'devtools-service': covered(`${WDIO}action-snapshot.test.ts`), + 'selenium-devtools': covered(`${SELENIUM}action-snapshot.test.ts`), + 'nightwatch-devtools': covered(`${NIGHTWATCH}native-session.test.ts`), + 'selenium-devtools-py': unsupported(REASONS.PYTHON_NATIVE_SCREENSHOT) + } + }, { id: 'hybrid-webview', name: 'Follows a hybrid app into its webview', diff --git a/packages/core/src/action-snapshot.ts b/packages/core/src/action-snapshot.ts index 1cba3161..a4ed53f8 100644 --- a/packages/core/src/action-snapshot.ts +++ b/packages/core/src/action-snapshot.ts @@ -21,10 +21,13 @@ import { } from './with-timeout.js' import { startCaptureTimer, type CaptureTimer } from './capture-timing.js' import { + deviceFromCapabilities, hasScreenshot, + isNativeAppSession, type ActionSnapshot, type CaptureProbe, type CaptureTrigger, + type NativePlatform, type TestRunnerId } from '@wdio/devtools-shared' @@ -42,9 +45,9 @@ export interface CaptureActionSnapshotInput { getUrl?: () => Promise getTitle?: () => Promise /** Page-source XML fetcher for native mobile — used instead of runScript. */ - getPageSource?: () => Promise + getPageSource?: () => Promise /** Platform identifier for mobile snapshot formatting ('android' | 'ios'). */ - platform?: 'android' | 'ios' + platform?: NativePlatform /** Runner this capture belongs to — decides which text-locator dialect the * injected scripts emit. Omitted → the portable XPath form. */ runner?: TestRunnerId @@ -52,6 +55,18 @@ export interface CaptureActionSnapshotInput { trigger?: CaptureTrigger } +/** A session with no document, whose captures read the app's page source. */ +export interface NativeApp { + platform?: NativePlatform +} + +/** The native app a session drove, or undefined for a browser session. */ +export function nativeAppOf(capabilities: unknown): NativeApp | undefined { + return isNativeAppSession(capabilities) + ? { platform: deviceFromCapabilities(capabilities)?.platform } + : undefined +} + /** `pending` unchanged; with a timer and a probe that will really be issued, also timed. */ function timed( timer: CaptureTimer | undefined, @@ -112,6 +127,11 @@ function probe( ).catch(() => undefined) } +// A hybrid app in a webview answers /source with the page's HTML, which an XML parse turns into noise. +function isHtmlSource(pageSource: string): boolean { + return /^\s*<(!doctype\s+html|html[\s>])/i.test(pageSource) +} + /** Native-mobile snapshot text + locators, derived from the page-source XML. */ function fromPageSource( pageSource: string, @@ -213,7 +233,7 @@ export async function captureActionSnapshot( const processedFrom = timer ? performance.now() : 0 const mobile = - isNativeMobile && pageSource + isNativeMobile && pageSource && !isHtmlSource(pageSource) ? fromPageSource(pageSource, input.platform ?? 'android') : undefined const snapshotText = diff --git a/packages/core/src/session-capturer.ts b/packages/core/src/session-capturer.ts index 6450a2e8..43e51e6b 100644 --- a/packages/core/src/session-capturer.ts +++ b/packages/core/src/session-capturer.ts @@ -28,6 +28,7 @@ import { import { resolveRunId } from './run-id.js' import { reattributeDomAnchors } from '@wdio/devtools-trace/trace-mutations' import { TestCapture } from './test-capture.js' +import { nativeAppOf, type NativeApp } from './action-snapshot.js' import { PageResourceStore } from './page-resource-store.js' import { CONSOLE_METHODS, @@ -139,6 +140,12 @@ export abstract class SessionCapturerBase { return isNativeAppSession(this.metadata?.capabilities) } + /** The app a per-action capture reads through its page source, or undefined + * for a browser session. A Mac2 or tvOS app names no platform. */ + get nativeApp(): NativeApp | undefined { + return nativeAppOf(this.metadata?.capabilities) + } + /** The Appium context last observed, fed by the adapter's command hook. The * switch command carries the new context in its own arguments, so following * it costs no round trip — undefined means none was ever seen, which for a diff --git a/packages/core/tests/action-snapshot.test.ts b/packages/core/tests/action-snapshot.test.ts index 3d722d9b..ccb6b1c1 100644 --- a/packages/core/tests/action-snapshot.test.ts +++ b/packages/core/tests/action-snapshot.test.ts @@ -40,6 +40,25 @@ describe('captureActionSnapshot timestamp', () => { expect(snap?.timestamp).toBe(777) }) + it('keeps a webview HTML source out of the native element tree', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + const snap = await captureActionSnapshot({ + command: 'click', + timestamp: 5, + getPageSource: () => + Promise.resolve( + ' ' + ), + takeScreenshot: () => Promise.resolve('AA'), + platform: 'ios' + }) + expect(snap?.snapshotText).not.toContain('[ios]') + expect(snap?.elements).toEqual([]) + expect(snap?.screenshot).toBe('AA') + expect(errors).not.toHaveBeenCalled() + errors.mockRestore() + }) + it('stamps a zero timestamp rather than treating it as absent', async () => { const snap = await captureActionSnapshot({ command: 'click', diff --git a/packages/core/tests/session-capturer-base.test.ts b/packages/core/tests/session-capturer-base.test.ts index a026bd0e..fcb954fa 100644 --- a/packages/core/tests/session-capturer-base.test.ts +++ b/packages/core/tests/session-capturer-base.test.ts @@ -422,6 +422,43 @@ describe('SessionCapturerBase.isNativeAppSession', () => { }) }) +/** The per-action capture reads a native app's page source by its platform. */ +describe('SessionCapturerBase.nativeApp', () => { + const withCapabilities = (capabilities: unknown) => { + const capturer = new TestSessionCapturer() + capturer.metadata = { capabilities } as never + return capturer + } + + it('names the platform of a native app', () => { + expect( + withCapabilities({ platformName: 'iOS', 'appium:app': '/a.app' }) + .nativeApp + ).toEqual({ platform: 'ios' }) + expect( + withCapabilities({ platformName: 'Android', 'appium:app': '/a.apk' }) + .nativeApp + ).toEqual({ platform: 'android' }) + }) + + it('names no platform for an app on a platform with no device frame', () => { + expect( + withCapabilities({ + platformName: 'mac', + 'appium:automationName': 'Mac2' + }).nativeApp + ).toEqual({ platform: undefined }) + }) + + it('is undefined for a browser session', () => { + expect( + withCapabilities({ platformName: 'Android', browserName: 'Chrome' }) + .nativeApp + ).toBeUndefined() + expect(new TestSessionCapturer().nativeApp).toBeUndefined() + }) +}) + describe('processTracePayload network ids', () => { it('replaces a row re-sent under the same id instead of listing it twice', () => { class Capturer extends SessionCapturerBase { diff --git a/packages/nightwatch-devtools/README.md b/packages/nightwatch-devtools/README.md index c19d0dfe..2deae744 100644 --- a/packages/nightwatch-devtools/README.md +++ b/packages/nightwatch-devtools/README.md @@ -294,7 +294,7 @@ module.exports = { } ``` -Native sessions record commands, screenshots and element data from the platform's XML tree, with no DOM. A hybrid app's webview is captured as if it were still native. From the root of a built checkout of this repo (`pnpm install && pnpm build`), try it with `pnpm demo:nightwatch:mobile`; prerequisites are in [`examples/MOBILE.md`](../../examples/MOBILE.md), and the full reference is the [Mobile Testing page](https://webdriver.io/docs/devtools/mobile). +Native sessions record commands, screenshots and element data from the platform's XML tree, with no DOM: in trace mode each action's element list and accessibility tree are built from the app's page source, read beside its screenshot, which on iOS is most of the capture's cost. A hybrid app's webview is captured as if it were still native. From the root of a built checkout of this repo (`pnpm install && pnpm build`), try it with `pnpm demo:nightwatch:mobile`; prerequisites are in [`examples/MOBILE.md`](../../examples/MOBILE.md), and the full reference is the [Mobile Testing page](https://webdriver.io/docs/devtools/mobile). ### Native app network diff --git a/packages/nightwatch-devtools/src/action-snapshot.ts b/packages/nightwatch-devtools/src/action-snapshot.ts index 61874d29..717f93f3 100644 --- a/packages/nightwatch-devtools/src/action-snapshot.ts +++ b/packages/nightwatch-devtools/src/action-snapshot.ts @@ -7,7 +7,10 @@ // hung the capture outright; the queued `execute` merely timed out, leaving an // empty a11y tree and no element rects. -import { captureActionSnapshot as coreCapture } from '@wdio/devtools-core' +import { + captureActionSnapshot as coreCapture, + type NativeApp +} from '@wdio/devtools-core' import type { ActionSnapshot, CaptureTrigger } from '@wdio/devtools-shared' import { webdriverExecute, webdriverGet } from './helpers/webdriverHttp.js' import type { NightwatchBrowser, TestRunnerId } from './types.js' @@ -17,13 +20,11 @@ export function captureActionSnapshot( command: string, timestamp?: number, runner?: TestRunnerId, - native = false, + native?: NativeApp, trigger?: CaptureTrigger ): Promise { - // The screenshot is the only one of these a native app can serve. The rest - // are page reads — an injected script plus url and title — and they fire on - // EVERY action, so they are the densest source of round trips that can only - // fail on a session with no document. + // A native app serves no page script, url or title; its page source is what + // core builds the element list and accessibility tree from. return coreCapture({ command, timestamp, @@ -31,7 +32,10 @@ export function captureActionSnapshot( trigger, takeScreenshot: () => webdriverGet(browser, 'screenshot'), ...(native - ? {} + ? { + getPageSource: () => webdriverGet(browser, 'source'), + platform: native.platform + } : { runScript: (src: string) => webdriverExecute(browser, `return (${src})`), diff --git a/packages/nightwatch-devtools/src/navigation-recapture.ts b/packages/nightwatch-devtools/src/navigation-recapture.ts index 89d130fd..2e00f38e 100644 --- a/packages/nightwatch-devtools/src/navigation-recapture.ts +++ b/packages/nightwatch-devtools/src/navigation-recapture.ts @@ -20,7 +20,6 @@ export interface QueuedPageCapture { export interface NavigationRecapture { browser: NightwatchBrowser runner: TestRunnerId - native: boolean snapshots: ActionSnapshot[] queued: QueuedPageCapture /** False once a newer page capture has been queued; its own capture then reads the newer state. */ @@ -31,11 +30,11 @@ export interface NavigationRecapture { * A click resolves before its navigation commits, so the capture it queued can * read the page it left. The replacement is taken after that capture settles, * because a late upsert would otherwise merge the stale screenshot back over it. + * Always a web capture: only a session with a document is polled for navigation. */ export async function recaptureAfterNavigation({ browser, runner, - native, snapshots, queued, isLatest @@ -46,8 +45,7 @@ export async function recaptureAfterNavigation({ browser, queued.command, queued.timestamp, - runner, - native + runner ) if (snap && capturedPageState(snap) && isLatest()) { supersedeSnapshotAt(snapshots, snap) diff --git a/packages/nightwatch-devtools/src/session.ts b/packages/nightwatch-devtools/src/session.ts index 478007a3..a8085d97 100644 --- a/packages/nightwatch-devtools/src/session.ts +++ b/packages/nightwatch-devtools/src/session.ts @@ -195,7 +195,7 @@ export class SessionCapturer extends SessionCapturerBase { command, timestamp, this.runner, - this.isNativeAppSession, + this.nativeApp, parentId === undefined ? 'action' : 'wait-poll' ) ) @@ -483,7 +483,6 @@ export class SessionCapturer extends SessionCapturerBase { recapture = recaptureAfterNavigation({ browser, runner: this.runner, - native: this.isNativeAppSession, snapshots: this.actionSnapshots, queued, isLatest: () => this.#lastPageCapture === queued @@ -679,7 +678,7 @@ export class SessionCapturer extends SessionCapturerBase { stamp.command, stamp.timestamp, this.runner, - this.isNativeAppSession, + this.nativeApp, 'final' ) }, diff --git a/packages/nightwatch-devtools/tests/native-session.test.ts b/packages/nightwatch-devtools/tests/native-session.test.ts index 160568f8..39dd76ee 100644 --- a/packages/nightwatch-devtools/tests/native-session.test.ts +++ b/packages/nightwatch-devtools/tests/native-session.test.ts @@ -1,4 +1,12 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' import { describe, it, expect, vi, beforeEach } from 'vitest' +import { reportCaptureTiming } from '@wdio/devtools-core' +import { + CAPTURE_TIMING_ENV, + type CaptureTimingRecord +} from '@wdio/devtools-shared' import { captureActionSnapshot } from '../src/action-snapshot.js' import { SessionCapturer } from '../src/session.js' @@ -98,11 +106,20 @@ describe('nightwatch SessionCapturer on a native session', () => { }) /** - * The per-action snapshot is the densest page-script path there is: an injected - * script plus url and title, on every action. The screenshot is the one probe a - * native app still serves. + * A native app serves no page script, url or title, so the screenshot and the + * page-source XML are the only probes; core builds the element list and the + * accessibility tree from the XML. */ describe('nightwatch per-action snapshot on a native session', () => { + const ANDROID_SOURCE = ` + + + +` + const IOS_SOURCE = ` + +` + const browser = () => ({ sessionId: 'sess-1', @@ -110,25 +127,109 @@ describe('nightwatch per-action snapshot on a native session', () => { options: { webdriver: { host: 'localhost', port: 4444 } } }) as unknown as NightwatchBrowser + const serving = (source: string) => + vi + .mocked(webdriverGet) + .mockImplementation(((_browser: NightwatchBrowser, path: string) => + Promise.resolve( + path === 'source' ? source : path === 'screenshot' ? 'AA' : null + )) as unknown as typeof webdriverGet) + beforeEach(() => { vi.mocked(webdriverExecute).mockClear() - vi.mocked(webdriverGet).mockClear() + vi.mocked(webdriverGet).mockReset() + vi.mocked(webdriverGet).mockResolvedValue(null) }) - it('takes the screenshot and makes no page read', async () => { - await captureActionSnapshot(browser(), 'click', 1, undefined, true) + it('reads the page source and the screenshot, and makes no page read', async () => { + await captureActionSnapshot(browser(), 'click', 1, undefined, { + platform: 'android' + }) expect(webdriverExecute).not.toHaveBeenCalled() const paths = vi.mocked(webdriverGet).mock.calls.map(([, path]) => path) - expect(paths).toEqual(['screenshot']) + expect(paths).toEqual(['source', 'screenshot']) + }) + + it('builds the element list and accessibility tree from an Android page source', async () => { + serving(ANDROID_SOURCE) + const snap = await captureActionSnapshot(browser(), 'click', 1, undefined, { + platform: 'android' + }) + + expect(snap?.screenshot).toBe('AA') + expect(snap?.snapshotText).toContain('[android]') + expect(snap?.snapshotText).toContain('button "Start"') + expect(snap?.elements?.length).toBeGreaterThan(0) + }) + + it('reads an iOS page source as iOS', async () => { + serving(IOS_SOURCE) + const snap = await captureActionSnapshot(browser(), 'click', 1, undefined, { + platform: 'ios' + }) + + expect(snap?.snapshotText).toContain('[ios]') + expect(snap?.snapshotText).toContain('General') + expect(snap?.elements?.length).toBeGreaterThan(0) + }) + + it('passes the session platform from the capturer to the capture', async () => { + serving(IOS_SOURCE) + const cap = new SessionCapturer({}, browser()) + try { + cap.traceMode = 'trace' + cap.metadata = { + capabilities: { + platformName: 'iOS', + 'appium:automationName': 'XCUITest', + 'appium:app': '/Settings.app' + } + } as never + await cap.captureCommand('click', [], null, undefined, { timestamp: 7 }) + await Promise.all(cap.snapshotCaptures) + + expect(cap.actionSnapshots[0]?.snapshotText).toContain('[ios]') + expect(cap.actionSnapshots[0]?.elements?.length).toBeGreaterThan(0) + } finally { + cap.cleanup() + } + }) + + it('reports the capture as native, with the page-source probe timed', async () => { + serving(ANDROID_SOURCE) + vi.stubEnv(CAPTURE_TIMING_ENV, '1') + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nw-native-timing-')) + try { + await captureActionSnapshot(browser(), 'click', 1, undefined, { + platform: 'android' + }) + const file = await reportCaptureTiming(outDir, () => undefined) + const [record] = fs + .readFileSync(file!, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line) as CaptureTimingRecord) + + expect(record?.native).toBe(true) + expect(record?.probes.map((p) => p.probe)).toEqual([ + 'pageSource', + 'screenshot', + 'process' + ]) + } finally { + vi.unstubAllEnvs() + fs.rmSync(outDir, { recursive: true, force: true }) + } }) it('reads the page for a session that has one', async () => { - await captureActionSnapshot(browser(), 'click', 1, undefined, false) + await captureActionSnapshot(browser(), 'click', 1, undefined) expect(webdriverExecute).toHaveBeenCalled() const paths = vi.mocked(webdriverGet).mock.calls.map(([, path]) => path) expect(paths).toContain('url') expect(paths).toContain('title') + expect(paths).not.toContain('source') }) }) diff --git a/packages/selenium-devtools-py/README.md b/packages/selenium-devtools-py/README.md index e2f9911e..8061418f 100644 --- a/packages/selenium-devtools-py/README.md +++ b/packages/selenium-devtools-py/README.md @@ -456,7 +456,7 @@ bugs: ## Mobile (Appium) -Appium sessions are captured in live and trace mode. A mobile-browser session is captured like a desktop one; a native app gets commands, screenshots and element data from the platform's XML tree, with no DOM. This adapter decides from the startup capabilities, so a hybrid app's webview half is captured as if it were still native (only the WebdriverIO service follows context switches). +Appium sessions are captured in live and trace mode. A mobile-browser session is captured like a desktop one; a native app gets commands and screenshots, with no DOM; this adapter reads no page source, so a native trace has no element list or accessibility tree. This adapter decides from the startup capabilities, so a hybrid app's webview half is captured as if it were still native (only the WebdriverIO service follows context switches). The demo runs bare `python3`, so from the root of a built checkout of this repo (`pnpm install && pnpm build`) install the adapter into that interpreter, plus the Appium client the desktop examples don't need: diff --git a/packages/selenium-devtools/README.md b/packages/selenium-devtools/README.md index 07b00615..0747e211 100644 --- a/packages/selenium-devtools/README.md +++ b/packages/selenium-devtools/README.md @@ -462,7 +462,7 @@ pnpm example:mocha ## Mobile (Appium) -Appium sessions are captured in live and trace mode. A mobile-browser session is captured like a desktop one; a native app gets commands, screenshots and element data from the platform's XML tree, with no DOM. This adapter decides from the startup capabilities, so a hybrid app's webview half is captured as if it were still native (only the WebdriverIO service follows context switches). +Appium sessions are captured in live and trace mode. A mobile-browser session is captured like a desktop one; a native app gets commands, screenshots and element data from the platform's XML tree, with no DOM: in trace mode each action's element list and accessibility tree are built from the app's page source, read beside its screenshot, which on iOS is most of the capture's cost. This adapter decides from the startup capabilities, so a hybrid app's webview half is captured as if it were still native (only the WebdriverIO service follows context switches). From the root of a built checkout of this repo (`pnpm install && pnpm build`), try it with `pnpm demo:selenium:mobile`, which drives the Android Clock app; `DEVTOOLS_MOBILE_PLATFORM=ios` runs the iOS spec against Settings, and `DEVTOOLS_MOBILE=web` drives the device's own browser. Prerequisites and the `DEVTOOLS_MOBILE` / `APPIUM_APP` switches are in [`examples/MOBILE.md`](../../examples/MOBILE.md); the full reference is the [Mobile Testing page](https://webdriver.io/docs/devtools/mobile). diff --git a/packages/selenium-devtools/src/action-snapshot.ts b/packages/selenium-devtools/src/action-snapshot.ts index 1c1a4270..62cb19e4 100644 --- a/packages/selenium-devtools/src/action-snapshot.ts +++ b/packages/selenium-devtools/src/action-snapshot.ts @@ -1,11 +1,15 @@ // Selenium adapter: wires SeleniumDriverLike into core's captureActionSnapshot. -// URL/title/screenshot/script are read through the UNPATCHED driver originals -// (getDriverOriginals) so the snapshot's own reads don't record as commands. +// URL/title/screenshot/script/page source are read through the UNPATCHED driver +// originals (getDriverOriginals) so the snapshot's own reads don't record as +// commands. // getCurrentUrl/getTitle map to page.* actions, so capturing them would make // every snapshot trigger another snapshot — a feedback loop that bloats the // trace (observed: thousands of getUrl/getTitle actions in one run). -import { captureActionSnapshot as coreCapture } from '@wdio/devtools-core' +import { + captureActionSnapshot as coreCapture, + type NativeApp +} from '@wdio/devtools-core' import type { ActionSnapshot, CaptureTrigger } from '@wdio/devtools-shared' import { SELENIUM_RUNNER_ID } from './constants.js' import { getDriverOriginals } from './driverPatcher.js' @@ -15,14 +19,12 @@ export function captureActionSnapshot( driver: SeleniumDriverLike, command: string, timestamp?: number, - native = false, + native?: NativeApp, trigger?: CaptureTrigger ): Promise { const orig = getDriverOriginals() - // The screenshot is the only one of these a native app can serve. The other - // three are page reads — two injected scripts plus url and title — and they - // fire on EVERY action, so they are the densest source of round trips that - // can only fail on a session with no document. + // A native app serves no page script, url or title; its page source is what + // core builds the element list and accessibility tree from. return coreCapture({ command, timestamp, @@ -33,7 +35,13 @@ export function captureActionSnapshot( ? orig.takeScreenshot(driver).catch(() => undefined) : Promise.resolve(undefined), ...(native - ? {} + ? { + getPageSource: () => + orig.getPageSource + ? orig.getPageSource(driver).catch(() => undefined) + : Promise.resolve(undefined), + platform: native.platform + } : { runScript: (src: string) => orig.executeScript diff --git a/packages/selenium-devtools/src/driverPatcher.ts b/packages/selenium-devtools/src/driverPatcher.ts index cf89c3a1..f8d7ce4e 100644 --- a/packages/selenium-devtools/src/driverPatcher.ts +++ b/packages/selenium-devtools/src/driverPatcher.ts @@ -347,6 +347,11 @@ function stashDriverOriginals(driverProto: Patchable): void { const orig = title as (this: unknown) => unknown originals.getTitle = (driver) => orig.call(driver) as Promise } + const source = driverProto.getPageSource + if (typeof source === 'function') { + const orig = source as (this: unknown) => unknown + originals.getPageSource = (driver) => orig.call(driver) as Promise + } const handle = driverProto.getWindowHandle if (typeof handle === 'function') { const orig = handle as (this: unknown) => unknown diff --git a/packages/selenium-devtools/src/helpers/commandPostActions.ts b/packages/selenium-devtools/src/helpers/commandPostActions.ts index df5acae3..4db5619f 100644 --- a/packages/selenium-devtools/src/helpers/commandPostActions.ts +++ b/packages/selenium-devtools/src/helpers/commandPostActions.ts @@ -400,7 +400,7 @@ function queueActionSnapshot( driver, command, timestamp, - capturer.isNativeAppSession, + capturer.nativeApp, trigger ) ) diff --git a/packages/selenium-devtools/src/helpers/finalFrame.ts b/packages/selenium-devtools/src/helpers/finalFrame.ts index 19aa7874..5e5d9406 100644 --- a/packages/selenium-devtools/src/helpers/finalFrame.ts +++ b/packages/selenium-devtools/src/helpers/finalFrame.ts @@ -29,7 +29,7 @@ export async function captureFinalFrame( ctx.driver, stamp.command, stamp.timestamp, - capturer.isNativeAppSession, + capturer.nativeApp, 'final' ) : Promise.resolve(null), diff --git a/packages/selenium-devtools/src/types.ts b/packages/selenium-devtools/src/types.ts index e57912f8..3192033e 100644 --- a/packages/selenium-devtools/src/types.ts +++ b/packages/selenium-devtools/src/types.ts @@ -176,6 +176,7 @@ export interface DriverOriginals { manage?: (driver: SeleniumDriverLike) => unknown getCurrentUrl?: (driver: SeleniumDriverLike) => Promise getTitle?: (driver: SeleniumDriverLike) => Promise + getPageSource?: (driver: SeleniumDriverLike) => Promise getWindowHandle?: (driver: SeleniumDriverLike) => Promise } diff --git a/packages/selenium-devtools/tests/action-snapshot.test.ts b/packages/selenium-devtools/tests/action-snapshot.test.ts index 60cda6d1..c8a640d9 100644 --- a/packages/selenium-devtools/tests/action-snapshot.test.ts +++ b/packages/selenium-devtools/tests/action-snapshot.test.ts @@ -1,3 +1,6 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' import type * as DevtoolsCore from '@wdio/devtools-core' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -17,9 +20,14 @@ import type { SessionLifecycleCtx } from '../src/session-lifecycle.js' import { getDriverOriginals } from '../src/driverPatcher.js' import { RetryTracker, - captureActionSnapshot as coreCapture + captureActionSnapshot as coreCapture, + reportCaptureTiming } from '@wdio/devtools-core' -import type { ActionSnapshot } from '@wdio/devtools-shared' +import { + CAPTURE_TIMING_ENV, + type ActionSnapshot, + type CaptureTimingRecord +} from '@wdio/devtools-shared' import type { CapturedCommand, SeleniumDriverLike } from '../src/types.js' vi.mock('@wdio/devtools-core', async (importOriginal) => { @@ -357,24 +365,33 @@ describe('selenium action-snapshot locator dialect', () => { }) /** - * The per-action snapshot is the densest page-script path there is: two - * injected scripts plus url and title, on every action. On a session with no - * document all four can only fail, and the screenshot is the one probe a - * native app still serves. + * A native app serves no page script, url or title, so the screenshot and the + * page-source XML are the only probes; core builds the element list and the + * accessibility tree from the XML. * * Stubs the patcher's `originals` bag, because that is the path the adapter - * takes whenever anything has been patched — a plain fake driver's own methods - * are only reached when the bag is empty. + * takes whenever anything has been patched; the source read has to go through + * it, or it would be recorded as a `getPageSource` row of the user's own. */ describe('the per-action snapshot on a native session', () => { - const probes = () => { + const ANDROID_SOURCE = ` + + + +` + const IOS_SOURCE = ` + +` + + const probes = (source = ANDROID_SOURCE) => { const originals = getDriverOriginals() const before = { ...originals } const calls = { executeScript: vi.fn().mockResolvedValue([]), takeScreenshot: vi.fn().mockResolvedValue('AA'), getCurrentUrl: vi.fn().mockResolvedValue('http://x/'), - getTitle: vi.fn().mockResolvedValue('X') + getTitle: vi.fn().mockResolvedValue('X'), + getPageSource: vi.fn().mockResolvedValue(source) } Object.assign(originals, calls) return { @@ -388,12 +405,16 @@ describe('the per-action snapshot on a native session', () => { } } - it('takes the screenshot and makes no page read', async () => { + it('reads the page source and the screenshot, and makes no page read', async () => { const { calls, restore } = probes() try { - const snap = await captureActionSnapshot(fakeDriver(), 'click', 1, true) + const driver = fakeDriver() + const snap = await captureActionSnapshot(driver, 'click', 1, { + platform: 'android' + }) expect(calls.takeScreenshot).toHaveBeenCalled() + expect(calls.getPageSource).toHaveBeenCalledWith(driver) expect(calls.executeScript).not.toHaveBeenCalled() expect(calls.getCurrentUrl).not.toHaveBeenCalled() expect(calls.getTitle).not.toHaveBeenCalled() @@ -403,13 +424,94 @@ describe('the per-action snapshot on a native session', () => { } }) + it('builds the element list and accessibility tree from an Android page source', async () => { + const { restore } = probes() + try { + const snap = await captureActionSnapshot(fakeDriver(), 'click', 1, { + platform: 'android' + }) + + expect(snap?.snapshotText).toContain('[android]') + expect(snap?.snapshotText).toContain('button "Start"') + expect(snap?.elements?.length).toBeGreaterThan(0) + } finally { + restore() + } + }) + + it('reads an iOS page source as iOS', async () => { + const { restore } = probes(IOS_SOURCE) + try { + const snap = await captureActionSnapshot(fakeDriver(), 'click', 1, { + platform: 'ios' + }) + + expect(snap?.snapshotText).toContain('[ios]') + expect(snap?.snapshotText).toContain('General') + expect(snap?.elements?.length).toBeGreaterThan(0) + } finally { + restore() + } + }) + + it('passes the session platform from the capturer to the capture', async () => { + const { calls, restore } = probes(IOS_SOURCE) + try { + const bag = makeCtx(fakeDriver()) + bag.capturer.metadata = { + capabilities: { + platformName: 'iOS', + 'appium:automationName': 'XCUITest', + 'appium:app': '/Settings.app' + } + } as never + await handleOnCommand(bag.ctx, cmd({ command: 'click', timestamp: 7 })) + await Promise.all(bag.snapshotCaptures) + + expect(calls.executeScript).not.toHaveBeenCalled() + expect(bag.actionSnapshots[0]?.snapshotText).toContain('[ios]') + expect(bag.actionSnapshots[0]?.elements?.length).toBeGreaterThan(0) + } finally { + restore() + } + }) + + it('reports the capture as native, with the page-source probe timed', async () => { + const { restore } = probes() + vi.stubEnv(CAPTURE_TIMING_ENV, '1') + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'se-native-timing-')) + try { + await captureActionSnapshot(fakeDriver(), 'click', 1, { + platform: 'android' + }) + const file = await reportCaptureTiming(outDir, () => undefined) + const [record] = fs + .readFileSync(file!, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line) as CaptureTimingRecord) + + expect(record?.native).toBe(true) + expect(record?.probes.map((p) => p.probe)).toEqual([ + 'pageSource', + 'screenshot', + 'process' + ]) + } finally { + vi.unstubAllEnvs() + fs.rmSync(outDir, { recursive: true, force: true }) + restore() + } + }) + it('reads the page for a session that has one', async () => { const { calls, restore } = probes() try { - await captureActionSnapshot(fakeDriver(), 'click', 1, false) + await captureActionSnapshot(fakeDriver(), 'click', 1) expect(calls.executeScript).toHaveBeenCalled() expect(calls.getCurrentUrl).toHaveBeenCalled() + expect(calls.getPageSource).not.toHaveBeenCalled() } finally { restore() } diff --git a/packages/selenium-devtools/tests/index.test.ts b/packages/selenium-devtools/tests/index.test.ts index 5f6c48f2..5046f52d 100644 --- a/packages/selenium-devtools/tests/index.test.ts +++ b/packages/selenium-devtools/tests/index.test.ts @@ -499,6 +499,7 @@ describe('driverPatcher', () => { expect(typeof originals.takeScreenshot).toBe('function') expect(typeof originals.executeScript).toBe('function') expect(typeof originals.manage).toBe('function') + expect(typeof originals.getPageSource).toBe('function') const elOriginals = getElementOriginals() expect(typeof elOriginals.getText).toBe('function') expect(typeof elOriginals.getTagName).toBe('function') @@ -512,6 +513,25 @@ describe('driverPatcher', () => { } }) + // The native per-action capture reads the page source on every action; the + // patched method would record each read as a row of the user's own. + it('reads the page source through an original that records no command', async () => { + const onCommand = vi.fn() + patchSelenium({ onDriverCreated: vi.fn(), onCommand }) + const fakeDriver = Object.create(driverProto) + ;(fakeDriver as any).execute = vi.fn(async () => '') + + await expect(getDriverOriginals().getPageSource!(fakeDriver)).resolves.toBe( + '' + ) + await new Promise((r) => setImmediate(r)) + expect(onCommand).not.toHaveBeenCalled() + + await (fakeDriver as any).getPageSource() + await new Promise((r) => setImmediate(r)) + expect(onCommand).toHaveBeenCalled() + }) + // A screenshot poll landing inside chromedriver's click, between computing the // element's coordinates and dispatching at them, makes the click report success // while activating nothing. `settle` runs on every path, so the window it closes