diff --git a/.changeset/no-page-drain-on-a-native-session.md b/.changeset/no-page-drain-on-a-native-session.md new file mode 100644 index 00000000..1aa0ccf8 --- /dev/null +++ b/.changeset/no-page-drain-on-a-native-session.md @@ -0,0 +1,20 @@ +--- +"@wdio/devtools-service": patch +--- + +Stop draining a page that does not exist, and stop treating a phone's browser as one. `SessionCapturer.captureTrace` reads the page-side collector through `browser.execute`, and a native Appium session has no document to run it in — but only two of its four call sites asked whether the session was native. The other two, the drain before a page-transition command and the final drain at teardown, went to the device anyway. Measured on a Pixel 7: five failed round trips per run, each printing `Failed to capture trace: WebDriverError: Method is not implemented` at ERROR in the user's output. + +The check now lives inside `captureTrace`, where the assumption it protects lives, so a call site cannot forget it — and the live-command drain's own copy is gone, leaving that predicate to decide only which commands warrant a drain. + +It also had to be a **narrower** check than the one the service had. The existing predicate is true for any Appium session, mobile browser included, because it ORs `isMobile` with `isAndroid` and `isIOS` — and only the first of those excludes a `chrome`/`safari`/`gecko`/`chromium` automationName, so the OR overrides WDIO's own mobile-web exclusion (measured on Appium Chrome capabilities: `isMobile` false, `isAndroid` true). An Appium session driving Chrome or Safari has a real page, so the two questions are split: `isAppiumSession` (the old predicate, renamed for what it actually answers) for anything needing WebDriver BiDi, which Appium does not serve, and `isNativeAppSession` for anything needing a document. The second keys on whether the capabilities name a browser at all, vendor bags included — WDIO reads `bstack:options.browserName` in the same function, so bags carrying it only there exist. + +Four page-side call sites move to the narrower predicate, and three of them were wrong for a mobile browser session before this change rather than because of it: + +- the drain itself, plus the drain-and-performance-read after a page-transition command. Its recovery injection is the only collector such a session ever gets, since the BiDi preload is skipped for every Appium session — so gating it on being mobile would have left it with no DOM capture at all. +- the `__wdioSnapMark` document tag and the post-action settle that reads it. These have to move together: split across the two predicates, a session tags a document nothing settles on, and its post-action screenshot comes from the page it navigated away from. +- the per-action snapshot strategy, which fed a chromedriver session's HTML through the page-source XML parser and produced a snapshot with no elements, no a11y tree, no url and no title. +- the viewport read. Documented as metadata-only, but the player sizes the DOM-replay iframe from it, so it is load-bearing wherever there is DOM to replay — and the driver window it was reading includes browser chrome and carries a hardcoded scale of 1. + +Deliberately left on the broader predicate: the BiDi preload injection, which is the right question there, and the per-command and per-assertion screenshots, which a mobile browser session also does not get. Those cost no failed round trips and print no errors, so they are a separate gap rather than part of this one. + +Residual: a hybrid app switched into a webview context does have a document, and no capability can say so — that is a runtime fact only `getContext()` knows. diff --git a/packages/service/src/action-snapshot.ts b/packages/service/src/action-snapshot.ts index d30d7e5c..9391c916 100644 --- a/packages/service/src/action-snapshot.ts +++ b/packages/service/src/action-snapshot.ts @@ -14,7 +14,7 @@ import { upsertRichestSnapshot } from '@wdio/devtools-core' import type { ActionSnapshot } from '@wdio/devtools-shared' -import { isNativeMobile, mobilePlatform } from './mobile.js' +import { isNativeAppSession, mobilePlatform } from './mobile.js' import { INTERNAL_COMMANDS } from './constants.js' import { wdioRunnerId } from './wdio-runner-id.js' @@ -73,7 +73,9 @@ export async function captureActionResult( if (!mapCommandToAction(command) || INTERNAL_COMMANDS.includes(command)) { return } - if (!isNativeMobile(browser)) { + // Keyed on having a document, matching `#markDocument`, which writes the tag + // this reads — split, a session tags a document nothing settles on. + if (!isNativeAppSession(browser)) { await waitForActionResult(browser) } // Stamped before the capture, not after: a snapshot probe can never enter @@ -108,7 +110,9 @@ export function captureActionSnapshot( command: string, timestamp?: number ): Promise { - const native = isNativeMobile(browser) + // A mobile BROWSER session takes the web path below: it has a document, and + // the native path would read its HTML through the page-source XML parser. + const native = isNativeAppSession(browser) return coreCapture({ command, timestamp, diff --git a/packages/service/src/assertion-tracker.ts b/packages/service/src/assertion-tracker.ts index 4275b33f..240d308e 100644 --- a/packages/service/src/assertion-tracker.ts +++ b/packages/service/src/assertion-tracker.ts @@ -12,7 +12,7 @@ import { type ExpectAssertion } from './assert-capture.js' import { pushActionSnapshotAt } from './action-snapshot.js' -import { isNativeMobile } from './mobile.js' +import { isAppiumSession } from './mobile.js' import type { SessionCapturer } from './session.js' import type { ServiceOptions } from './types.js' @@ -119,7 +119,7 @@ export class AssertionTracker { // No matcher read to fold into (a value matcher like toBe(x), or the read // hard-threw): emit a fresh row with its own screenshot + trace snapshot. const browser = this.#ctx.getBrowser() - if (browser && !isNativeMobile(browser)) { + if (browser && !isAppiumSession(browser)) { try { entry.screenshot = await browser.takeScreenshot() } catch (err) { diff --git a/packages/service/src/index.ts b/packages/service/src/index.ts index 68a89508..107bda92 100644 --- a/packages/service/src/index.ts +++ b/packages/service/src/index.ts @@ -56,7 +56,7 @@ import { LOCATOR_COMMANDS, PAGE_TRANSITION_COMMANDS } from './constants.js' -import { isNativeMobile } from './mobile.js' +import { isAppiumSession, isNativeAppSession } from './mobile.js' import { resolveSessionMetadata } from './session-metadata.js' import { stampRunnerMetadata } from './wdio-runner-id.js' import { detectInvocationConfigPath } from './standalone.js' @@ -240,7 +240,7 @@ export default class DevToolsHookService implements Services.ServiceInstance { * Skip on native mobile — Appium sessions don't support WebDriver BiDi * and the injection always fails with SevereServiceError. */ - if (!isNativeMobile(browser)) { + if (!isAppiumSession(browser)) { try { await this.#injectScriptSync(browser) } catch (err) { @@ -671,7 +671,9 @@ export default class DevToolsHookService implements Services.ServiceInstance { } #markDocument(): Promise { - if (!this.#browser || isNativeMobile(this.#browser)) { + // Keyed on having a document: `waitForActionResult` reads this tag on the + // same condition, so the pair must not be split across the two predicates. + if (!this.#browser || isNativeAppSession(this.#browser)) { return Promise.resolve() } return this.#browser @@ -753,9 +755,10 @@ export default class DevToolsHookService implements Services.ServiceInstance { * to the commands that can actually move the page (which matters: capture * traffic is what triggers the Chrome 150 headless input regression). */ async #drainAfterLiveCommand(command: keyof WebDriverCommands) { + // No native check: `captureTrace` owns that now, so this only decides + // which COMMANDS warrant a drain. if ( !this.#browser || - isNativeMobile(this.#browser) || PAGE_TRANSITION_COMMANDS.includes(command) || LOCATOR_COMMANDS.includes(command) ) { diff --git a/packages/service/src/mobile.ts b/packages/service/src/mobile.ts index ec436953..fc49595d 100644 --- a/packages/service/src/mobile.ts +++ b/packages/service/src/mobile.ts @@ -9,11 +9,50 @@ type MobileBrowser = WebdriverIO.Browser & { isIOS?: unknown } -export function isNativeMobile(browser: WebdriverIO.Browser): boolean { +/** An Appium session, native app or mobile browser alike — the right question + * for anything needing WebDriver BiDi, which Appium does not serve. */ +export function isAppiumSession(browser: WebdriverIO.Browser): boolean { const b = browser as MobileBrowser return Boolean(b.isMobile || b.isAndroid || b.isIOS) } +/** A browser named anywhere in the capabilities, vendor bags included: WDIO's + * own `isMobile` reads `bstack:options.browserName`, so bags that carry it + * only there exist, and one level of scan needs no vendor list to maintain. */ +function namesABrowser(capabilities: WebdriverIO.Capabilities): boolean { + const named = (value: unknown) => + typeof (value as { browserName?: unknown })?.browserName === 'string' && + Boolean((value as { browserName: string }).browserName.trim()) + return ( + named(capabilities) || Object.values(capabilities).some((v) => named(v)) + ) +} + +/** + * A session with no web document to run page script in — an Appium session + * driving an app rather than a browser. + * + * Narrower than `isAppiumSession`, and not cosmetically: an Appium session + * driving Chrome or Safari has a real page, but the flags claim it as mobile + * anyway. WDIO's `isMobile` excludes a chrome/safari/gecko/chromium + * automationName for exactly this reason — `isAndroid` and `isIOS` carry no + * such exclusion, so ORing the three overrides it (measured on Appium Chrome + * capabilities: `isMobile` false, `isAndroid` true). + * + * Reads the MATCHED capabilities, which is the same object WDIO derived those + * flags from, so the two answers cannot be drawn from different sessions. + * + * Residual: a hybrid app switched into a webview context does have a document, + * and no capability can say so — only `getContext()` knows that. + */ +export function isNativeAppSession(browser: WebdriverIO.Browser): boolean { + if (!isAppiumSession(browser)) { + return false + } + const capabilities = browser.capabilities + return !capabilities || !namesABrowser(capabilities) +} + export function mobilePlatform( browser: WebdriverIO.Browser ): 'android' | 'ios' | undefined { diff --git a/packages/service/src/session-metadata.ts b/packages/service/src/session-metadata.ts index c95843c6..9cdd2e7f 100644 --- a/packages/service/src/session-metadata.ts +++ b/packages/service/src/session-metadata.ts @@ -11,7 +11,7 @@ import { } from '@wdio/devtools-shared' import type { Capabilities } from '@wdio/types' -import { isNativeMobile } from './mobile.js' +import { isNativeAppSession } from './mobile.js' const log = logger('@wdio/devtools-service') @@ -21,15 +21,18 @@ const log = logger('@wdio/devtools-service') * — measured at 1080x2219 on a Pixel 7, which is the window minus the * navigation bar. * - * Metadata only, in both cases: neither number matches the screenshot's own + * Metadata only for a native app: neither number matches the screenshot's own * pixels (that Pixel 7 shot is 1080x2400, and iOS reports points rather than - * pixels), so anything sizing a captured image measures the image instead. + * pixels), so anything sizing a captured image measures the image instead. It + * is load-bearing wherever there IS a DOM to replay — the player sizes the + * replay iframe from it — so a mobile BROWSER session must reach the page read + * below, which alone carries the real scale and offsets. */ async function resolveViewport( browser: WebdriverIO.Browser ): Promise { try { - if (isNativeMobile(browser)) { + if (isNativeAppSession(browser)) { const size = await browser.getWindowSize() return size ? { diff --git a/packages/service/src/session.ts b/packages/service/src/session.ts index 27b17c11..5c978ef9 100644 --- a/packages/service/src/session.ts +++ b/packages/service/src/session.ts @@ -15,7 +15,7 @@ import { rememberElementSelector, selectorForCommand } from './command-selectors.js' -import { isNativeMobile } from './mobile.js' +import { isAppiumSession, isNativeAppSession } from './mobile.js' import { CAPTURE_PERFORMANCE_SCRIPT, LOG_SOURCES, @@ -162,7 +162,7 @@ export class SessionCapturer extends SessionCapturerBase { testUid, stepUid } - if (!isNativeMobile(browser)) { + if (!isAppiumSession(browser)) { try { commandLogEntry.screenshot = await browser.takeScreenshot() } catch (screenshotError) { @@ -183,9 +183,10 @@ export class SessionCapturer extends SessionCapturerBase { this.#captureOrReplace(commandLogEntry) // Capture trace + perf on commands that could trigger a page transition. - // Skip on native mobile — scripts can't execute in a native app context. + // Skipped when there is no document to run either script in; a mobile + // BROWSER session has one, so it keeps both. if ( - !isNativeMobile(browser) && + !isNativeAppSession(browser) && PAGE_TRANSITION_COMMANDS.includes(command) ) { await Promise.all([ @@ -380,6 +381,13 @@ export class SessionCapturer extends SessionCapturerBase { * command, capturing the outgoing page's field edits (value/checked * mutations fire no page transition) before its collector is discarded. */ async captureTrace(browser: WebdriverIO.Browser, forceAnchor = false) { + // A native app has no document to drain, so the collector probe, the + // recovery injection and the url read are all round trips that can only + // fail. Guarded here rather than at each call site, because two of the four + // asked and two did not. + if (isNativeAppSession(browser)) { + return + } // No `#isScriptInjected` gate: that flag tracks the preload REGISTRATION, // and the two cases worth capturing are exactly the ones where it lies — a // registration that failed (guard never closed) and a document that loaded diff --git a/packages/service/tests/action-snapshot.test.ts b/packages/service/tests/action-snapshot.test.ts index e4be8d8c..6c38c9b2 100644 --- a/packages/service/tests/action-snapshot.test.ts +++ b/packages/service/tests/action-snapshot.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, vi } from 'vitest' import type { ActionSnapshot } from '@wdio/devtools-shared' -import { pushActionSnapshotAt } from '../src/action-snapshot.js' +import { + captureActionResult, + pushActionSnapshotAt +} from '../src/action-snapshot.js' const mockBrowser = () => ({ @@ -46,3 +49,112 @@ describe('service action-snapshot locator dialect', () => { } }) }) + +/** + * A mobile BROWSER session has a document, so it takes the web path. Gated on + * being mobile it took the native one: the page-source reader parses Appium XML + * and a chromedriver-backed session answers HTML, so the trace reached the + * player with no elements, no a11y tree, no url and no title. + */ +describe('an Appium session driving a browser', () => { + const mobileWeb = () => + Object.assign(mockBrowser(), { + isMobile: false, + isAndroid: true, + capabilities: { + platformName: 'Android', + browserName: 'Chrome', + 'appium:automationName': 'Chrome' + }, + getPageSource: vi.fn().mockResolvedValue('') + }) as unknown as WebdriverIO.Browser + + const nativeApp = () => + Object.assign(mockBrowser(), { + isMobile: true, + isAndroid: true, + capabilities: { + platformName: 'Android', + 'appium:automationName': 'UiAutomator2', + 'appium:app': '/app.apk' + }, + getPageSource: vi.fn().mockResolvedValue('') + }) as unknown as WebdriverIO.Browser + + it('reads the page with script, not as page-source XML', async () => { + const browser = mobileWeb() + + await pushActionSnapshotAt(browser, 'click', 1, []) + + expect(browser.execute).toHaveBeenCalled() + expect(browser.getPageSource).not.toHaveBeenCalled() + }) + + it('reports its url and title, which a browser session has', async () => { + const browser = mobileWeb() + + await pushActionSnapshotAt(browser, 'click', 1, []) + + expect(browser.getUrl).toHaveBeenCalled() + expect(browser.getTitle).toHaveBeenCalled() + }) + + it('leaves a native app on the page-source path', async () => { + const browser = nativeApp() + + await pushActionSnapshotAt(browser, 'click', 1, []) + + expect(browser.getPageSource).toHaveBeenCalled() + expect(browser.execute).not.toHaveBeenCalled() + expect(browser.getUrl).not.toHaveBeenCalled() + }) +}) + +/** + * The settle waits on the `__wdioSnapMark` tag that `#markDocument` writes, and + * both key on having a document — split across the two predicates, a session + * tags a document nothing ever settles on, and its post-action screenshot comes + * from the page it navigated away from. + */ +describe('the post-action settle', () => { + const settleable = (flags: Record) => + Object.assign(mockBrowser(), flags, { + execute: vi.fn().mockResolvedValue(true), + waitUntil: vi.fn().mockResolvedValue(undefined), + pause: vi.fn().mockResolvedValue(undefined) + }) as unknown as WebdriverIO.Browser + + it('runs for an Appium session driving a browser', async () => { + const browser = settleable({ + isMobile: false, + isAndroid: true, + capabilities: { platformName: 'Android', browserName: 'Chrome' } + }) + + await captureActionResult(browser, 'click', [], () => 1) + + // The mark probe is the settle's first act, so its body identifies it. + const bodies = vi + .mocked(browser.execute) + .mock.calls.map(([fn]) => String(fn)) + expect(bodies.some((body) => body.includes('__wdioSnapMark'))).toBe(true) + }) + + it('does not for a native app, which has no document to settle', async () => { + const browser = settleable({ + isMobile: true, + isAndroid: true, + capabilities: { + platformName: 'Android', + 'appium:app': '/app.apk' + } + }) + + await captureActionResult(browser, 'click', [], () => 1) + + const bodies = vi + .mocked(browser.execute) + .mock.calls.map(([fn]) => String(fn)) + expect(bodies.some((body) => body.includes('__wdioSnapMark'))).toBe(false) + }) +}) diff --git a/packages/service/tests/mobile.test.ts b/packages/service/tests/mobile.test.ts new file mode 100644 index 00000000..42b2df60 --- /dev/null +++ b/packages/service/tests/mobile.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest' + +import { isAppiumSession, isNativeAppSession } from '../src/mobile.js' + +/** Flags are what WDIO's own `capabilitiesEnvironmentDetector` returns for each + * bag — measured, because the interesting rows are where they disagree. */ +const session = ( + flags: { isMobile?: boolean; isAndroid?: boolean; isIOS?: boolean }, + capabilities: Record +) => ({ ...flags, capabilities }) as never + +const NATIVE_ANDROID = session( + { isMobile: true, isAndroid: true }, + { + platformName: 'Android', + 'appium:automationName': 'UiAutomator2', + 'appium:app': '/app.apk' + } +) + +const NATIVE_IOS = session( + { isMobile: true, isIOS: true }, + { + platformName: 'iOS', + 'appium:automationName': 'XCUITest', + 'appium:app': '/app.app' + } +) + +// WDIO reports isMobile FALSE here — its own `isMobile` excludes a +// chrome/safari/gecko/chromium automationName — while `isAndroid`, which has +// no such exclusion, reports true. That disagreement is the whole bug. +const MOBILE_WEB_ANDROID = session( + { isMobile: false, isAndroid: true }, + { + platformName: 'Android', + browserName: 'Chrome', + 'appium:automationName': 'Chrome' + } +) + +const MOBILE_WEB_ANDROID_UIAUTOMATOR = session( + { isMobile: true, isAndroid: true }, + { + platformName: 'Android', + browserName: 'Chrome', + 'appium:automationName': 'UiAutomator2' + } +) + +const MOBILE_WEB_IOS = session( + { isMobile: true, isIOS: true }, + { + platformName: 'iOS', + browserName: 'Safari', + 'appium:automationName': 'XCUITest' + } +) + +const DESKTOP = session({}, { browserName: 'chrome' }) + +describe('isAppiumSession', () => { + it('is true for every Appium session, browser or app', () => { + // The right question for anything needing BiDi, which Appium never serves. + expect(isAppiumSession(NATIVE_ANDROID)).toBe(true) + expect(isAppiumSession(NATIVE_IOS)).toBe(true) + expect(isAppiumSession(MOBILE_WEB_ANDROID)).toBe(true) + expect(isAppiumSession(MOBILE_WEB_IOS)).toBe(true) + }) + + it('is false for a desktop session', () => { + expect(isAppiumSession(DESKTOP)).toBe(false) + }) +}) + +describe('isNativeAppSession', () => { + it('is true for a session that asked for an app', () => { + expect(isNativeAppSession(NATIVE_ANDROID)).toBe(true) + expect(isNativeAppSession(NATIVE_IOS)).toBe(true) + }) + + it('is false for a mobile BROWSER session, which has a real page', () => { + expect(isNativeAppSession(MOBILE_WEB_ANDROID)).toBe(false) + expect(isNativeAppSession(MOBILE_WEB_ANDROID_UIAUTOMATOR)).toBe(false) + expect(isNativeAppSession(MOBILE_WEB_IOS)).toBe(false) + }) + + it('is false for a desktop session', () => { + expect(isNativeAppSession(DESKTOP)).toBe(false) + }) + + it('is false when only a vendor bag names the browser', () => { + // WDIO's own `isMobile` reads `bstack:options.browserName`, so bags that + // carry it only there exist — and `isAndroid` fires on that bag's + // `deviceName` alone, so this session has no top-level evidence at all. + expect( + isNativeAppSession( + session( + { isMobile: true, isAndroid: true }, + { + 'bstack:options': { + deviceName: 'Google Pixel 7', + platformName: 'Android', + browserName: 'Chrome' + } + } + ) + ) + ).toBe(false) + }) + + it('reads a blank browserName as an app', () => { + // WDIO's own `isMobile` treats `browserName: ''` as a native signal, and a + // driver that echoes the key rather than omitting it must not read as web. + expect( + isNativeAppSession( + session( + { isMobile: true, isAndroid: true }, + { platformName: 'Android', browserName: ' ' } + ) + ) + ).toBe(true) + }) + + it('survives a session reporting no capabilities at all', () => { + expect(isNativeAppSession({ isMobile: true } as never)).toBe(true) + }) +}) diff --git a/packages/service/tests/session-metadata.test.ts b/packages/service/tests/session-metadata.test.ts index 60aa8057..76db4085 100644 --- a/packages/service/tests/session-metadata.test.ts +++ b/packages/service/tests/session-metadata.test.ts @@ -5,7 +5,7 @@ import { TraceType } from '../src/types.js' /** * A native session answers `getWindowSize` and nothing DOM-shaped; a desktop - * one answers `execute`. The flags are what `isNativeMobile` narrows on. + * one answers `execute`. The flags are what `isAppiumSession` narrows on. */ function browserDouble(overrides: Record = {}) { return { @@ -74,6 +74,35 @@ describe('resolveSessionMetadata', () => { expect(browser.execute).not.toHaveBeenCalled() }) + /** + * A mobile BROWSER session has a page, and the player sizes the DOM-replay + * iframe from this viewport — so reading the driver window here would frame + * the replay at the window including browser chrome, at a hardcoded scale of + * 1. `window.visualViewport` is the only source carrying the real scale. + */ + it("reads the page's own viewport on an Appium browser session", async () => { + const browser = browserDouble({ + isMobile: false, + isAndroid: true, + capabilities: { + platformName: 'Android', + browserName: 'Chrome', + 'appium:automationName': 'Chrome' + } + }) + + const metadata = await resolveSessionMetadata(browser, TraceType.Testrunner) + + expect(metadata.viewport).toEqual({ + width: 1280, + height: 720, + offsetLeft: 0, + offsetTop: 0, + scale: 1 + }) + expect(browser.getWindowSize).not.toHaveBeenCalled() + }) + it('states the device a native session reports', async () => { const browser = browserDouble({ isMobile: true, diff --git a/packages/service/tests/session.test.ts b/packages/service/tests/session.test.ts index 7f28f04f..1f02af03 100644 --- a/packages/service/tests/session.test.ts +++ b/packages/service/tests/session.test.ts @@ -975,3 +975,140 @@ describe('SessionCapturer', () => { }) }) }) + +/** + * A native app has no document to drain: the collector probe, the recovery + * injection and the url read are all page-side, so each is a round trip to the + * device that can only fail. Measured on a Pixel 7 before this guard: 5 failed + * round trips per run, each printing `Method is not implemented` at ERROR. + * + * Guarded inside `captureTrace` because two of its four call sites asked and + * two did not — the assumption lives here, so the guard does too. + */ +describe('captureTrace on a native session', () => { + /** A driver that answers nothing: any page-side call is a failure here. No + * `browserName`, which is what makes it an APP session. */ + const nativeBrowser = () => + ({ + isMobile: true, + isAndroid: true, + capabilities: { + platformName: 'Android', + 'appium:automationName': 'UiAutomator2', + 'appium:app': '/app.apk' + }, + execute: vi + .fn() + .mockRejectedValue(new Error('Method is not implemented')), + getUrl: vi.fn().mockRejectedValue(new Error('Method is not implemented')), + takeScreenshot: vi.fn().mockResolvedValue('screenshot') + }) as never + + /** Appium driving Chrome on a device: mobile, but with a real page. WDIO's + * own `isMobile` is FALSE for these capabilities while `isAndroid` is true, + * which is how the broader predicate came to claim them. */ + const mobileWebBrowser = () => + ({ + isMobile: false, + isAndroid: true, + capabilities: { + platformName: 'Android', + browserName: 'Chrome', + 'appium:automationName': 'Chrome' + }, + execute: vi.fn().mockResolvedValue(null), + getUrl: vi.fn().mockResolvedValue('https://example.com'), + takeScreenshot: vi.fn().mockResolvedValue('screenshot') + }) as never + + const webBrowser = () => + ({ + execute: vi.fn().mockResolvedValue(null), + getUrl: vi.fn().mockResolvedValue('https://example.com'), + takeScreenshot: vi.fn().mockResolvedValue('screenshot') + }) as never + + it('makes no round trip at all', async () => { + const browser = nativeBrowser() + const capturer = new SessionCapturer() + + await capturer.captureTrace(browser) + + expect( + (browser as unknown as { execute: ReturnType }).execute + ).not.toHaveBeenCalled() + expect( + (browser as unknown as { getUrl: ReturnType }).getUrl + ).not.toHaveBeenCalled() + }) + + it('makes none when a caller forces an anchor either', async () => { + // The teardown drain passes forceAnchor, and was one of the unguarded two. + const browser = nativeBrowser() + + await new SessionCapturer().captureTrace(browser, true) + + expect( + (browser as unknown as { execute: ReturnType }).execute + ).not.toHaveBeenCalled() + }) + + it('still drains a web session', async () => { + const browser = webBrowser() + + await new SessionCapturer().captureTrace(browser) + + expect( + (browser as unknown as { execute: ReturnType }).execute + ).toHaveBeenCalled() + }) + + it('drains after a page transition on a mobile BROWSER session', async () => { + // The other half of the same gate: `afterCommand` decides whether a + // navigating command is followed by a drain and a performance read, both + // page-side. A mobile browser session navigates like any other. + const browser = mobileWebBrowser() + + await new SessionCapturer().afterCommand( + browser, + 'navigateTo', + ['https://example.com'], + undefined, + undefined + ) + + expect( + (browser as unknown as { execute: ReturnType }).execute + ).toHaveBeenCalled() + }) + + it('makes no page call after a transition on a native app', async () => { + const browser = nativeBrowser() + + await new SessionCapturer().afterCommand( + browser, + 'navigateTo', + ['/some/deeplink'], + undefined, + undefined + ) + + expect( + (browser as unknown as { execute: ReturnType }).execute + ).not.toHaveBeenCalled() + }) + + it('still drains a mobile BROWSER session', async () => { + // The guard's whole reason is a missing document, and this session has one. + // It is also the session with the most to lose: the BiDi preload is skipped + // for every Appium session, so this drain's recovery injection is the only + // collector it ever gets — gated, it captured no DOM at all. + const browser = mobileWebBrowser() + + await new SessionCapturer().captureTrace(browser) + + expect( + (browser as unknown as { execute: ReturnType }).execute + ).toHaveBeenCalled() + }) +})