Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/no-page-drain-on-a-native-session.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 7 additions & 3 deletions packages/service/src/action-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -108,7 +110,9 @@ export function captureActionSnapshot(
command: string,
timestamp?: number
): Promise<ActionSnapshot | null> {
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,
Expand Down
4 changes: 2 additions & 2 deletions packages/service/src/assertion-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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) {
Expand Down
11 changes: 7 additions & 4 deletions packages/service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -671,7 +671,9 @@ export default class DevToolsHookService implements Services.ServiceInstance {
}

#markDocument(): Promise<unknown> {
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
Expand Down Expand Up @@ -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)
) {
Expand Down
41 changes: 40 additions & 1 deletion packages/service/src/mobile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
11 changes: 7 additions & 4 deletions packages/service/src/session-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand All @@ -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<Viewport | undefined> {
try {
if (isNativeMobile(browser)) {
if (isNativeAppSession(browser)) {
const size = await browser.getWindowSize()
return size
? {
Expand Down
16 changes: 12 additions & 4 deletions packages/service/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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([
Expand Down Expand Up @@ -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
Expand Down
114 changes: 113 additions & 1 deletion packages/service/tests/action-snapshot.test.ts
Original file line number Diff line number Diff line change
@@ -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 = () =>
({
Expand Down Expand Up @@ -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('<html></html>')
}) 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('<hierarchy/>')
}) 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<string, unknown>) =>
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)
})
})
Loading