From 884173147d466efc354045d7345a61205e58a1e7 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Thu, 10 Sep 2026 22:34:35 +0530 Subject: [PATCH 01/11] fix(selenium-devtools): publish capabilities as a readable bag --- .../src/helpers/driverMetadata.ts | 31 +++++++-- .../tests/driverMetadata.test.ts | 68 ++++++++++++++++++- 2 files changed, 90 insertions(+), 9 deletions(-) diff --git a/packages/selenium-devtools/src/helpers/driverMetadata.ts b/packages/selenium-devtools/src/helpers/driverMetadata.ts index 68751c2b..2e506aa9 100644 --- a/packages/selenium-devtools/src/helpers/driverMetadata.ts +++ b/packages/selenium-devtools/src/helpers/driverMetadata.ts @@ -25,6 +25,30 @@ export interface DriverMetadataResult { type CapGet = (k: string) => unknown +/** + * A plain bag from selenium's `Capabilities`, whose data lives in a private + * Map. It exposes `serialize` only under a Symbol, so a string-keyed + * `serialize?.()` is `undefined` and the instance itself JSON-serializes to + * `{"map_":{}}` — which is what the dashboard has been receiving as a + * Selenium run's capabilities, and why its traces carried no `device` and + * fell back to a guessed browser name. Everything downstream reads + * capabilities as a bag, so the one conversion happens here. + */ +function serializeCapabilities(capabilities: unknown): Record { + const caps = capabilities as + | { + keys?: () => Iterable + get?: (k: string) => unknown + serialize?: () => Record + } + | undefined + if (typeof caps?.keys === 'function' && typeof caps.get === 'function') { + const get = caps.get.bind(caps) + return Object.fromEntries([...caps.keys()].map((key) => [key, get(key)])) + } + return caps?.serialize?.() ?? (capabilities as Record) ?? {} +} + function makeCapGet(capabilities: unknown): CapGet { return (k: string) => { const caps = capabilities as @@ -91,12 +115,7 @@ export async function buildDriverMetadata( sessionId, metadata: { type: TraceType.Testrunner, - capabilities: - ( - capabilities as { serialize?: () => unknown } | undefined - )?.serialize?.() ?? - capabilities ?? - {}, + capabilities: serializeCapabilities(capabilities), sessionId, runner: SELENIUM_RUNNER_ID, options: { diff --git a/packages/selenium-devtools/tests/driverMetadata.test.ts b/packages/selenium-devtools/tests/driverMetadata.test.ts index 0274b58a..33b1855d 100644 --- a/packages/selenium-devtools/tests/driverMetadata.test.ts +++ b/packages/selenium-devtools/tests/driverMetadata.test.ts @@ -3,11 +3,24 @@ import { buildDriverMetadata } from '../src/helpers/driverMetadata.js' import { SELENIUM_RUNNER_ID } from '../src/constants.js' import type { SeleniumDriverLike } from '../src/types.js' -function driverStub(sessionId = 'sess-1'): SeleniumDriverLike { +/** + * Shaped like selenium's own `Capabilities`: the data lives in a private Map + * reachable through `keys()`/`get()`, and `serialize` exists only under a + * Symbol. A stub with a string-keyed `serialize()` — which is what this used + * to be — validates a shape no driver has ever had. + */ +function capabilitiesStub(bag: Record) { + const map = new Map(Object.entries(bag)) + return { keys: () => map.keys(), get: (key: string) => map.get(key) } +} + +function driverStub( + sessionId = 'sess-1', + bag: Record = { browserName: 'chrome' } +): SeleniumDriverLike { return { getSession: () => Promise.resolve({ getId: () => sessionId }), - getCapabilities: () => - Promise.resolve({ serialize: () => ({ browserName: 'chrome' }) }) + getCapabilities: () => Promise.resolve(capabilitiesStub(bag)) } as unknown as SeleniumDriverLike } @@ -69,3 +82,52 @@ describe('buildDriverMetadata', () => { expect(metadata).toBeUndefined() }) }) + +/** + * `Capabilities` keeps its data in a private Map and exposes `serialize` only + * under a Symbol, so the instance reached the dashboard as `{"map_":{}}` — no + * device on the trace, a guessed browser name, and an empty capabilities pane. + */ +describe('buildDriverMetadata capability serialization', () => { + const NATIVE = { + platformName: 'Android', + 'appium:automationName': 'UiAutomator2', + 'appium:app': '/app.apk' + } + + it('flattens the driver capabilities into a readable bag', async () => { + const { metadata } = await buildDriverMetadata({ + driver: driverStub('sess-1', NATIVE), + driverReadyTs: Date.now(), + detectedRunner: 'mocha' + }) + + expect(metadata?.capabilities).toEqual(NATIVE) + }) + + it('survives the JSON round trip that carries it upstream', async () => { + const { metadata } = await buildDriverMetadata({ + driver: driverStub('sess-1', NATIVE), + driverReadyTs: Date.now(), + detectedRunner: 'mocha' + }) + + expect( + JSON.parse(JSON.stringify({ capabilities: metadata?.capabilities })) + .capabilities + ).toEqual(NATIVE) + }) + + it('reads a bag that is already plain', async () => { + const { metadata } = await buildDriverMetadata({ + driver: { + getSession: () => Promise.resolve({ getId: () => 'sess-2' }), + getCapabilities: () => Promise.resolve({ browserName: 'firefox' }) + } as unknown as SeleniumDriverLike, + driverReadyTs: Date.now(), + detectedRunner: 'mocha' + }) + + expect(metadata?.capabilities).toEqual({ browserName: 'firefox' }) + }) +}) From 01a068c03a868e22d5a24508f5a1ee71c4d284b7 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Thu, 10 Sep 2026 22:34:49 +0530 Subject: [PATCH 02/11] feat(shared): one reader for whether a session had a document --- .../native-detection-for-every-adapter.md | 24 ++++ packages/shared/src/device.ts | 81 ++++++++++- packages/shared/tests/device.test.ts | 131 ++++++++++++++++++ 3 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 .changeset/native-detection-for-every-adapter.md diff --git a/.changeset/native-detection-for-every-adapter.md b/.changeset/native-detection-for-every-adapter.md new file mode 100644 index 00000000..a7b13e0f --- /dev/null +++ b/.changeset/native-detection-for-every-adapter.md @@ -0,0 +1,24 @@ +--- +"@wdio/devtools-service": patch +"@wdio/selenium-devtools": patch +"@wdio/nightwatch-devtools": patch +"@wdio/devtools-app": patch +--- + +Let every adapter tell a native session from a browser one. Until now only the WDIO service could: the predicate read `browser.isMobile`/`isAndroid`/`isIOS`, which are WDIO runtime flags that Selenium's `WebDriver`, Nightwatch's `browser` and the Python driver do not have. So Selenium and Nightwatch ran their DOM drain, their collector injection and their page-script probes against a native app anyway — the same wasted round trips and `Method is not implemented` errors the service stopped emitting — and the Python adapter read `window.innerWidth` on a session with no window. + +The fact now has one reader, `isNativeAppSession` in shared, which asks the capabilities every adapter already publishes rather than a driver flag. It keys on whether the session named a browser, because a device alone does not answer the question — an Appium session driving Chrome or Safari runs on a phone and has a real page — and it reads both `platformName` and `browserName` one level into vendor options, since a device cloud commonly states them only inside its own bag. + +`SessionCapturerBase` exposes it as `isNativeAppSession`, resolved from the metadata the adapter has already set. That indirection is not decoration: Selenium's own `getCapabilities()` is async, and a guard cannot await it at the point it has to decide. Guards live inside the guarded method rather than at its call sites, which is what the service's own fix established — Selenium's drain has three call sites and Nightwatch's has four. + +Gated per adapter: Selenium's `captureTrace`, `injectScript`, `reinjectIfNavigated` and its performance read (whose 500 ms settle was being spent to reach a document that does not exist); Nightwatch's `captureTrace`, `injectScript`, `anchorAfterNavigation` — which polls the page for its own document identity — and its own performance read; Python's collector, its performance read and its viewport, which now measures the device window rather than asking a page that isn't there for `window.innerWidth`. + +The densest of these is the per-action snapshot, and all three adapters were paying it: two injected scripts plus `url` and `title`, on **every** action. Those four now drop out on a native session while the screenshot — the one probe a native app does serve — is still taken, so the trace keeps its per-action frames. Screenshots and `manage().logs()` are deliberately left alone too: Appium serves both, and logcat arrives through the second, so gating them would lose data rather than save a failed call. + +Two pre-existing bugs fell out of the work, both from the same root: Selenium published its capabilities as selenium-webdriver's `Capabilities` **instance**. That class keeps its data in a private Map and exposes `serialize` only under a Symbol, so the string-keyed `serialize?.()` the adapter called returned `undefined` and the instance reached the dashboard as `{"map_":{}}`. Every Selenium trace therefore carried no `device` and a guessed browser name, and the dashboard's capabilities pane was empty. It is now flattened through the class's own `keys()`/`get()` — which is also what makes the new guards work at all, since they read that bag. The test stub that hid this had a string-keyed `serialize()` no real driver has ever had. + +Reading the device out of vendor options fixes the same field for a cloud session, which previously read as desktop and reached the player framed as a browser window rather than a phone. + +The player's whole mobile layout was also still WDIO-only, in live mode. It gates on `metadata.device`, and only the WDIO service derives one before sending — Selenium, Nightwatch and Python send capabilities alone. So a phone run on those adapters arrived as a desktop session and got the desktop layout, even though the same run's *trace* was framed correctly, because the exporter derives the device on the way into the zip. The app now derives it from the capabilities when the adapter sent none: one place rather than four — the single ingestion point every live message passes through. A device the adapter did send wins. Live mode only: a trace's device is already derived by the exporter on the way into the zip. + +Not included: a native session still gets no accessibility tree, because deriving one from page source is a capture *feature* the WDIO service has and the other three do not. Selenium and Nightwatch also still publish no viewport at all, so their traces — desktop ones included — are framed at the reader's 1280x720 fallback. Both are tracked separately. diff --git a/packages/shared/src/device.ts b/packages/shared/src/device.ts index cc57bcb9..28830564 100644 --- a/packages/shared/src/device.ts +++ b/packages/shared/src/device.ts @@ -47,13 +47,52 @@ function capString( return typeof value === 'string' && value.trim() ? value : undefined } +/** A capability read from the bag or from one level of vendor options. A device + * cloud commonly states `platformName` and `browserName` only inside its own + * bag (`bstack:options`), which is why WDIO's mobile detection reads there + * too; scanning one level needs no list of vendors to keep current. + * + * Every caller passes MATCHED capabilities — what the session answered with — + * so a request-shaped bag's `firstMatch` array is deliberately not scanned: the + * server merges `alwaysMatch` with the ONE entry it chose, and reading a + * browser out of any entry would claim one the session never got. */ +function deepCapString( + caps: Record, + key: string +): string | undefined { + const own = capString(caps, key) + if (own) { + return own + } + for (const nested of Object.values(caps)) { + if (nested && typeof nested === 'object' && !Array.isArray(nested)) { + const value = capString(nested as Record, key) + if (value) { + return value + } + } + } + return undefined +} + +/** Whether the capabilities name an Appium automation. Separate from naming a + * DEVICE: a Mac2, WinAppDriver or tvOS session has no document either, and + * `NATIVE_PLATFORMS` deliberately excludes them because that list chooses a + * device frame, which is a display concern. */ +function namesAnAutomation(caps: Record): boolean { + return Boolean( + deepCapString(caps, 'appium:automationName') ?? + deepCapString(caps, 'automationName') + ) +} + function firstCapString( caps: Record, keys: string[], reject: (value: string) => boolean = () => false ): string | undefined { for (const key of keys) { - const value = capString(caps, key) + const value = deepCapString(caps, key) if (value && !reject(value)) { return value } @@ -74,11 +113,11 @@ export function deviceFromCapabilities( return undefined } const caps = capabilities as Record - const platform = capString(caps, 'platformName')?.toLowerCase() + const platform = deepCapString(caps, 'platformName')?.toLowerCase() if (!isNativePlatform(platform)) { return undefined } - const serials = SERIAL_KEYS.map((key) => capString(caps, key)).filter( + const serials = SERIAL_KEYS.map((key) => deepCapString(caps, key)).filter( (value): value is string => value !== undefined ) const name = firstCapString(caps, DEVICE_NAME_KEYS, (value) => @@ -92,6 +131,42 @@ export function deviceFromCapabilities( } } +/** + * Whether a session had no web document to run page script in — it drove an + * app rather than a browser. The question every adapter has to answer before a + * DOM drain, a page-script probe or a viewport read, since each of those is a + * round trip that can only fail on a native session. + * + * A device alone does not answer it: an Appium session driving Chrome or Safari + * runs on a phone and has a real page. So the browser it names is the + * discriminator — mobile web must state one, a native app states none. + * + * Capabilities rather than a driver flag, because capabilities are the one + * thing all four adapters have. + * + * A device is not required — an Appium automation is enough. Answering "web" + * for a document-less session is NOT the cheap direction: the service's + * post-action settle reads a page tag, treats the failure as a navigation, and + * then polls a probe that can only fail for its full 8 s timeout, per action. + * So a Mac2 or tvOS session, which `NATIVE_PLATFORMS` excludes because that + * list chooses a device frame, has to answer true here too. + * + * Residual: a hybrid app switched into a webview context does have a document, + * and no capability can say so — only a runtime context read knows that. And a + * bag this cannot read at all answers false, which is the expensive direction; + * in practice every adapter reads capabilities straight off its own session. + */ +export function isNativeAppSession(capabilities: unknown): boolean { + if (!capabilities || typeof capabilities !== 'object') { + return false + } + const caps = capabilities as Record + if (!deviceFromCapabilities(caps) && !namesAnAutomation(caps)) { + return false + } + return !deepCapString(caps, 'browserName') +} + /** * Narrow a `device` read back off a trace's `context-options`. The field is * untrusted — a foreign zip may carry anything under that name, and one of ours diff --git a/packages/shared/tests/device.test.ts b/packages/shared/tests/device.test.ts index 1350d924..daf28c69 100644 --- a/packages/shared/tests/device.test.ts +++ b/packages/shared/tests/device.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { deviceFromCapabilities, deviceLabel, + isNativeAppSession, isNativePlatform } from '../src/device.js' @@ -106,6 +107,39 @@ describe('deviceFromCapabilities', () => { }) }) +/** A cloud that states the platform only in its own bag used to read as a + * desktop session, so its trace carried no device and the player framed a + * phone as a browser window. */ +describe('deviceFromCapabilities with vendor options', () => { + it('reads the whole device out of a vendor bag', () => { + // Every part, not just the platform: with a shallow name read this device + // was labelled bare "android" in the player. + expect( + deviceFromCapabilities({ + 'bstack:options': { + platformName: 'android', + deviceModel: 'Pixel 7', + platformVersion: '14' + } + }) + ).toEqual({ platform: 'android', name: 'Pixel 7', version: '14' }) + }) + + it('still rejects a name that only repeats the serial', () => { + // The cloud reports the serial as both deviceName and udid; reading one + // level deeper must not lose that rejection. + expect( + deviceFromCapabilities({ + 'bstack:options': { + platformName: 'android', + deviceName: '28111FDH200CUX', + udid: '28111FDH200CUX' + } + }) + ).toEqual({ platform: 'android' }) + }) +}) + describe('deviceLabel', () => { it('reads as name, platform and version', () => { expect( @@ -123,3 +157,100 @@ describe('deviceLabel', () => { expect(deviceLabel({ platform: 'android' })).toBe('android') }) }) + +/** + * Whether the session had a document. Not answerable from the device alone: an + * Appium session driving Chrome runs on a phone and has a real page, and gating + * a DOM drain on "is it a phone" took all DOM capture away from one. + */ +describe('isNativeAppSession', () => { + const MOBILE_WEB_ANDROID = { + platformName: 'Android', + browserName: 'Chrome', + 'appium:automationName': 'Chrome' + } + const MOBILE_WEB_IOS = { + platformName: 'iOS', + browserName: 'Safari', + 'appium:automationName': 'XCUITest' + } + /** A cloud states both facts inside its own bag and neither at the top. */ + const CLOUD_NESTED_WEB = { + 'bstack:options': { + platformName: 'Android', + deviceName: 'Google Pixel 7', + browserName: 'Chrome' + } + } + const CLOUD_NESTED_APP = { + 'bstack:options': { + platformName: 'Android', + deviceName: 'Google Pixel 7', + appiumVersion: '2.0.0' + } + } + + it('is true for a session that named no browser', () => { + expect(isNativeAppSession(LOCAL_ANDROID)).toBe(true) + expect(isNativeAppSession(CLOUD_ANDROID)).toBe(true) + expect(isNativeAppSession(IOS)).toBe(true) + }) + + it('is false for a session that named one', () => { + expect(isNativeAppSession(MOBILE_WEB_ANDROID)).toBe(false) + expect(isNativeAppSession(MOBILE_WEB_IOS)).toBe(false) + }) + + it('is false for a desktop session, which has no device at all', () => { + expect(isNativeAppSession(DESKTOP)).toBe(false) + }) + + it('reads both facts out of a vendor bag', () => { + // Neither key is at the top level here, so a shallow read calls the web + // session desktop and the app session desktop too. + expect(isNativeAppSession(CLOUD_NESTED_WEB)).toBe(false) + expect(isNativeAppSession(CLOUD_NESTED_APP)).toBe(true) + }) + + it('is true for an Appium session with no device platform', () => { + // Mac2, WinAppDriver and tvOS have no document either, and answering "web" + // for them is the expensive direction: the service's post-action settle + // then polls a failing probe for its full 8 s timeout, per action. + expect( + isNativeAppSession({ + platformName: 'mac', + 'appium:automationName': 'Mac2', + 'appium:bundleId': 'com.apple.TextEdit' + }) + ).toBe(true) + expect( + isNativeAppSession({ + platformName: 'tvOS', + 'appium:automationName': 'XCUITest' + }) + ).toBe(true) + }) + + it('is false for a desktop browser, which names no automation', () => { + expect( + isNativeAppSession({ browserName: 'firefox', 'moz:firefoxOptions': {} }) + ).toBe(false) + }) + + it('reads a blank browserName as no browser at all', () => { + // A driver that echoes the key rather than omitting it must not read as + // web, or a native app loses the guard. + expect( + isNativeAppSession({ platformName: 'Android', browserName: ' ' }) + ).toBe(true) + }) + + it('says nothing about a bag it cannot read', () => { + // False, not true, is the safe answer with no information: a guard that + // fails open costs a failed round trip and a log line, one that fails + // closed costs the run's whole DOM capture. + expect(isNativeAppSession(undefined)).toBe(false) + expect(isNativeAppSession('android')).toBe(false) + expect(isNativeAppSession({})).toBe(false) + }) +}) From f993c536e5dbeb44ab95f8ad92d9119a6bc756c7 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Thu, 10 Sep 2026 22:35:02 +0530 Subject: [PATCH 03/11] feat(core): expose the native-session answer on the capturer base --- packages/core/src/session-capturer.ts | 9 +++- .../core/tests/session-capturer-base.test.ts | 41 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/core/src/session-capturer.ts b/packages/core/src/session-capturer.ts index 990276f9..7be4f9b7 100644 --- a/packages/core/src/session-capturer.ts +++ b/packages/core/src/session-capturer.ts @@ -11,7 +11,7 @@ import type { TraceMutation } from '@wdio/devtools-shared' import { WORKER_WS_QUERY, WS_PATHS, WS_SCOPE } from '@wdio/devtools-shared' -import { mapCommandToAction } from '@wdio/devtools-shared' +import { isNativeAppSession, mapCommandToAction } from '@wdio/devtools-shared' import { resolveRunId } from './run-id.js' import { reattributeDomAnchors } from '@wdio/devtools-trace/trace-mutations' import { @@ -97,6 +97,13 @@ export abstract class SessionCapturerBase { traceLogs: string[] = [] metadata?: Metadata + /** Whether this session drove an app rather than a browser. Resolved from + * the published metadata because selenium's own `getCapabilities()` is + * async, and a guard cannot await it where it has to decide. */ + get isNativeAppSession(): boolean { + return isNativeAppSession(this.metadata?.capabilities) + } + // ── Construction ──────────────────────────────────────────────────────── constructor(opts: SessionCapturerOptions = {}) { const { hostname, port, reconnect } = opts diff --git a/packages/core/tests/session-capturer-base.test.ts b/packages/core/tests/session-capturer-base.test.ts index 36e769d8..e83d9225 100644 --- a/packages/core/tests/session-capturer-base.test.ts +++ b/packages/core/tests/session-capturer-base.test.ts @@ -309,3 +309,44 @@ describe('failLastAction', () => { expect(cap.commandsLog[0]!.error).toBeUndefined() }) }) + +/** Selenium's own `getCapabilities()` is async, so the answer has to come from + * the capabilities the adapter already published. */ +describe('SessionCapturerBase.isNativeAppSession', () => { + const withCapabilities = (capabilities: unknown) => { + const capturer = new TestSessionCapturer() + capturer.metadata = { capabilities } as never + return capturer + } + + it('is true for a session that named no browser', () => { + expect( + withCapabilities({ platformName: 'Android', 'appium:app': '/a.apk' }) + .isNativeAppSession + ).toBe(true) + }) + + it('is false for a phone running a browser', () => { + expect( + withCapabilities({ platformName: 'Android', browserName: 'Chrome' }) + .isNativeAppSession + ).toBe(false) + }) + + it('is false before the adapter has published any metadata', () => { + expect(new TestSessionCapturer().isNativeAppSession).toBe(false) + }) + + it('follows a merged metadata fragment', () => { + // The adapters publish through `mergeMetadata`, so the answer has to track + // it rather than being read once at construction. + const capturer = new TestSessionCapturer() + expect(capturer.isNativeAppSession).toBe(false) + + capturer.mergeMetadata({ + capabilities: { platformName: 'iOS', 'appium:app': '/a.app' } + } as never) + + expect(capturer.isNativeAppSession).toBe(true) + }) +}) From eda7b1ebbc904868393c3504f4858b1161f5d82a Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Thu, 10 Sep 2026 22:37:18 +0530 Subject: [PATCH 04/11] refactor(service): read the document question from shared --- packages/service/src/action-snapshot.ts | 8 +- packages/service/src/index.ts | 10 +- packages/service/src/mobile.ts | 37 ------ packages/service/src/session-metadata.ts | 5 +- packages/service/src/session.ts | 7 +- packages/service/tests/mobile.test.ts | 145 +++++------------------ 6 files changed, 46 insertions(+), 166 deletions(-) diff --git a/packages/service/src/action-snapshot.ts b/packages/service/src/action-snapshot.ts index 9391c916..f1db78c0 100644 --- a/packages/service/src/action-snapshot.ts +++ b/packages/service/src/action-snapshot.ts @@ -13,8 +13,8 @@ import { mapCommandToAction, upsertRichestSnapshot } from '@wdio/devtools-core' -import type { ActionSnapshot } from '@wdio/devtools-shared' -import { isNativeAppSession, mobilePlatform } from './mobile.js' +import { isNativeAppSession, type ActionSnapshot } from '@wdio/devtools-shared' +import { mobilePlatform } from './mobile.js' import { INTERNAL_COMMANDS } from './constants.js' import { wdioRunnerId } from './wdio-runner-id.js' @@ -75,7 +75,7 @@ export async function captureActionResult( } // 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)) { + if (!isNativeAppSession(browser.capabilities)) { await waitForActionResult(browser) } // Stamped before the capture, not after: a snapshot probe can never enter @@ -112,7 +112,7 @@ export function captureActionSnapshot( ): Promise { // 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) + const native = isNativeAppSession(browser.capabilities) return coreCapture({ command, timestamp, diff --git a/packages/service/src/index.ts b/packages/service/src/index.ts index 107bda92..8dc49f68 100644 --- a/packages/service/src/index.ts +++ b/packages/service/src/index.ts @@ -33,7 +33,11 @@ import { captureActionResult, captureActionSnapshot } from './action-snapshot.js' -import type { ActionSnapshot, TestMetadataMap } from '@wdio/devtools-shared' +import { + isNativeAppSession, + type ActionSnapshot, + type TestMetadataMap +} from '@wdio/devtools-shared' import { SevereServiceError } from 'webdriverio' import type { Services, Capabilities, Options, Reporters } from '@wdio/types' import type { WebDriverCommands } from '@wdio/protocols' @@ -56,7 +60,7 @@ import { LOCATOR_COMMANDS, PAGE_TRANSITION_COMMANDS } from './constants.js' -import { isAppiumSession, isNativeAppSession } from './mobile.js' +import { isAppiumSession } from './mobile.js' import { resolveSessionMetadata } from './session-metadata.js' import { stampRunnerMetadata } from './wdio-runner-id.js' import { detectInvocationConfigPath } from './standalone.js' @@ -673,7 +677,7 @@ export default class DevToolsHookService implements Services.ServiceInstance { #markDocument(): Promise { // 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)) { + if (!this.#browser || isNativeAppSession(this.#browser.capabilities)) { return Promise.resolve() } return this.#browser diff --git a/packages/service/src/mobile.ts b/packages/service/src/mobile.ts index fc49595d..25c01031 100644 --- a/packages/service/src/mobile.ts +++ b/packages/service/src/mobile.ts @@ -16,43 +16,6 @@ export function isAppiumSession(browser: WebdriverIO.Browser): boolean { 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 9cdd2e7f..7747d3e5 100644 --- a/packages/service/src/session-metadata.ts +++ b/packages/service/src/session-metadata.ts @@ -5,14 +5,13 @@ import logger from '@wdio/logger' import { deviceFromCapabilities, + isNativeAppSession, type Metadata, type TraceType, type Viewport } from '@wdio/devtools-shared' import type { Capabilities } from '@wdio/types' -import { isNativeAppSession } from './mobile.js' - const log = logger('@wdio/devtools-service') /** @@ -32,7 +31,7 @@ async function resolveViewport( browser: WebdriverIO.Browser ): Promise { try { - if (isNativeAppSession(browser)) { + if (isNativeAppSession(browser.capabilities)) { const size = await browser.getWindowSize() return size ? { diff --git a/packages/service/src/session.ts b/packages/service/src/session.ts index 5c978ef9..384169e2 100644 --- a/packages/service/src/session.ts +++ b/packages/service/src/session.ts @@ -15,7 +15,8 @@ import { rememberElementSelector, selectorForCommand } from './command-selectors.js' -import { isAppiumSession, isNativeAppSession } from './mobile.js' +import { isNativeAppSession } from '@wdio/devtools-shared' +import { isAppiumSession } from './mobile.js' import { CAPTURE_PERFORMANCE_SCRIPT, LOG_SOURCES, @@ -186,7 +187,7 @@ export class SessionCapturer extends SessionCapturerBase { // Skipped when there is no document to run either script in; a mobile // BROWSER session has one, so it keeps both. if ( - !isNativeAppSession(browser) && + !isNativeAppSession(browser.capabilities) && PAGE_TRANSITION_COMMANDS.includes(command) ) { await Promise.all([ @@ -385,7 +386,7 @@ export class SessionCapturer extends SessionCapturerBase { // 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)) { + if (isNativeAppSession(browser.capabilities)) { return } // No `#isScriptInjected` gate: that flag tracks the preload REGISTRATION, diff --git a/packages/service/tests/mobile.test.ts b/packages/service/tests/mobile.test.ts index 42b2df60..9a440082 100644 --- a/packages/service/tests/mobile.test.ts +++ b/packages/service/tests/mobile.test.ts @@ -1,128 +1,41 @@ 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' }) +import { isAppiumSession } from '../src/mobile.js' + +/** + * `isAppiumSession` answers "can this session serve WebDriver BiDi", and + * Appium cannot — whether it drives an app or a browser. Whether the session + * has a DOCUMENT is a different question, answered from capabilities by + * shared's `isNativeAppSession` (see packages/shared/tests/device.test.ts). + * + * 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 +}) => flags as never 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) + expect(isAppiumSession(session({ isMobile: true, isAndroid: true }))).toBe( + true + ) + expect(isAppiumSession(session({ isMobile: true, isIOS: true }))).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 true where WDIO excludes mobile web but the platform flag remains', () => { + // Appium Chrome: WDIO's own `isMobile` is false (it excludes a chrome + // automationName) while `isAndroid` stays true. Still an Appium session, + // so still no BiDi — which is why the OR is right for THIS question and + // was wrong for the document one. + expect(isAppiumSession(session({ isMobile: false, isAndroid: true }))).toBe( + true + ) }) 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) + expect(isAppiumSession(session({}))).toBe(false) }) }) From 4e0e4c414ae5b05fae0ea2cbf20e69fb7446d65c Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Thu, 10 Sep 2026 22:37:37 +0530 Subject: [PATCH 05/11] fix(selenium-devtools): skip page-side capture on a native session --- .../selenium-devtools/src/action-snapshot.ts | 35 +++--- .../src/helpers/commandPostActions.ts | 14 ++- packages/selenium-devtools/src/session.ts | 10 +- .../tests/action-snapshot.test.ts | 62 +++++++++++ .../selenium-devtools/tests/session.test.ts | 100 ++++++++++++++++++ 5 files changed, 202 insertions(+), 19 deletions(-) diff --git a/packages/selenium-devtools/src/action-snapshot.ts b/packages/selenium-devtools/src/action-snapshot.ts index e45f5d02..29959678 100644 --- a/packages/selenium-devtools/src/action-snapshot.ts +++ b/packages/selenium-devtools/src/action-snapshot.ts @@ -14,28 +14,37 @@ import type { SeleniumDriverLike } from './types.js' export function captureActionSnapshot( driver: SeleniumDriverLike, command: string, - timestamp?: number + timestamp?: number, + native = false ): 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. return coreCapture({ command, timestamp, runner: SELENIUM_RUNNER_ID, - runScript: (src) => - orig.executeScript - ? orig.executeScript(driver, `return (${src})`) - : driver.executeScript(`return (${src})`), takeScreenshot: () => orig.takeScreenshot ? orig.takeScreenshot(driver).catch(() => undefined) : Promise.resolve(undefined), - getUrl: () => - orig.getCurrentUrl - ? orig.getCurrentUrl(driver).catch(() => undefined) - : Promise.resolve(undefined), - getTitle: () => - orig.getTitle - ? orig.getTitle(driver).catch(() => undefined) - : Promise.resolve(undefined) + ...(native + ? {} + : { + runScript: (src: string) => + orig.executeScript + ? orig.executeScript(driver, `return (${src})`) + : driver.executeScript(`return (${src})`), + getUrl: () => + orig.getCurrentUrl + ? orig.getCurrentUrl(driver).catch(() => undefined) + : Promise.resolve(undefined), + getTitle: () => + orig.getTitle + ? orig.getTitle(driver).catch(() => undefined) + : Promise.resolve(undefined) + }) }) } diff --git a/packages/selenium-devtools/src/helpers/commandPostActions.ts b/packages/selenium-devtools/src/helpers/commandPostActions.ts index 8655ba10..3577b586 100644 --- a/packages/selenium-devtools/src/helpers/commandPostActions.ts +++ b/packages/selenium-devtools/src/helpers/commandPostActions.ts @@ -137,7 +137,9 @@ async function capturePerformance( args: unknown[] | undefined ): Promise { const exec = getDriverOriginals().executeScript - if (!exec) { + // Page script, and the 500 ms settle below would be spent to reach a + // document that does not exist. + if (!exec || capturer.isNativeAppSession) { return } try { @@ -328,7 +330,7 @@ export async function handleOnCommand( } maybeDrainAfterDomCommand(ctx, capturer, cmd) maybeDrainAfterLiveCommand(ctx, capturer, cmd) - queueActionSnapshot(ctx, cmd, entry.timestamp, error) + queueActionSnapshot(ctx, capturer, cmd, entry.timestamp, error) } /** Fire-and-forget post-action snapshot, drained at finalize. Stamped with the @@ -337,6 +339,7 @@ export async function handleOnCommand( * command timestamp (FrameSnapshotIndex.claimAfter / elementsAt). */ function queueActionSnapshot( ctx: OnCommandCtx, + capturer: SessionCapturer, cmd: CapturedCommand, timestamp: number, error: unknown @@ -350,7 +353,12 @@ function queueActionSnapshot( return } ctx.snapshotCaptures.push( - captureActionSnapshot(ctx.driver, cmd.command, timestamp).then((snap) => { + captureActionSnapshot( + ctx.driver, + cmd.command, + timestamp, + capturer.isNativeAppSession + ).then((snap) => { if (snap) { upsertRichestSnapshot(ctx.actionSnapshots, snap) } diff --git a/packages/selenium-devtools/src/session.ts b/packages/selenium-devtools/src/session.ts index ab7ae590..e8d033aa 100644 --- a/packages/selenium-devtools/src/session.ts +++ b/packages/selenium-devtools/src/session.ts @@ -211,7 +211,7 @@ export class SessionCapturer extends SessionCapturerBase { async injectScript(): Promise { const driver = this.#driver const exec = getDriverOriginals().executeScript - if (!driver || !exec) { + if (!driver || !exec || this.isNativeAppSession) { return } try { @@ -259,7 +259,11 @@ export class SessionCapturer extends SessionCapturerBase { async captureTrace(forceAnchor = false): Promise { const driver = this.#driver const exec = getDriverOriginals().executeScript - if (!driver || !exec) { + // A native app has no document, so the drain, its recovery injection and + // the url read are round trips that can only fail. Inside the method + // rather than at its call sites, which are the live drain, the navigation + // hook and teardown — one of them would forget. + if (!driver || !exec || this.isNativeAppSession) { return } try { @@ -347,7 +351,7 @@ export class SessionCapturer extends SessionCapturerBase { async reinjectIfNavigated(): Promise { const driver = this.#driver const exec = getDriverOriginals().executeScript - if (!driver || !exec) { + if (!driver || !exec || this.isNativeAppSession) { return } try { diff --git a/packages/selenium-devtools/tests/action-snapshot.test.ts b/packages/selenium-devtools/tests/action-snapshot.test.ts index 51439c36..7e75bcd8 100644 --- a/packages/selenium-devtools/tests/action-snapshot.test.ts +++ b/packages/selenium-devtools/tests/action-snapshot.test.ts @@ -9,6 +9,8 @@ import { handleOnCommand, type OnCommandCtx } from '../src/helpers/commandPostActions.js' +import { captureActionSnapshot } from '../src/action-snapshot.js' +import { getDriverOriginals } from '../src/driverPatcher.js' import { RetryTracker } from '@wdio/devtools-core' import type { ActionSnapshot } from '@wdio/devtools-shared' import type { CapturedCommand, SeleniumDriverLike } from '../src/types.js' @@ -132,3 +134,63 @@ 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. + * + * 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. + */ +describe('the per-action snapshot on a native session', () => { + const probes = () => { + 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') + } + Object.assign(originals, calls) + return { + calls, + restore: () => { + for (const key of Object.keys(calls)) { + delete (originals as Record)[key] + } + Object.assign(originals, before) + } + } + } + + it('takes the screenshot and makes no page read', async () => { + const { calls, restore } = probes() + try { + const snap = await captureActionSnapshot(fakeDriver(), 'click', 1, true) + + expect(calls.takeScreenshot).toHaveBeenCalled() + expect(calls.executeScript).not.toHaveBeenCalled() + expect(calls.getCurrentUrl).not.toHaveBeenCalled() + expect(calls.getTitle).not.toHaveBeenCalled() + expect(snap?.screenshot).toBe('AA') + } finally { + restore() + } + }) + + it('reads the page for a session that has one', async () => { + const { calls, restore } = probes() + try { + await captureActionSnapshot(fakeDriver(), 'click', 1, false) + + expect(calls.executeScript).toHaveBeenCalled() + expect(calls.getCurrentUrl).toHaveBeenCalled() + } finally { + restore() + } + }) +}) diff --git a/packages/selenium-devtools/tests/session.test.ts b/packages/selenium-devtools/tests/session.test.ts index 786a7e02..7ee09565 100644 --- a/packages/selenium-devtools/tests/session.test.ts +++ b/packages/selenium-devtools/tests/session.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest' import { loadInjectableScript } from '@wdio/devtools-core' import { SessionCapturer } from '../src/session.js' import { getDriverOriginals } from '../src/driverPatcher.js' +import { buildDriverMetadata } from '../src/helpers/driverMetadata.js' // `@wdio/devtools-script` is a workspace sibling that may not be built // yet in a CI test job that runs before the script-package build step. @@ -360,3 +361,102 @@ describe('selenium SessionCapturer.setDriver', () => { return cap.takeScreenshot().then((s) => expect(s).toBeNull()) }) }) + +/** + * A native app has no document, so every page-side call is a round trip to the + * device that can only fail. The predicate reads the capabilities the adapter + * already published as `metadata`, since selenium's own `getCapabilities()` is + * async and a guard cannot await it. + */ +describe('selenium SessionCapturer on a native session', () => { + const NATIVE = { + platformName: 'Android', + 'appium:automationName': 'UiAutomator2', + 'appium:app': '/app.apk' + } + const MOBILE_WEB = { + platformName: 'Android', + browserName: 'Chrome', + 'appium:automationName': 'Chrome' + } + + let restore: (() => void) | undefined + let scripts: string[] + + beforeEach(() => { + scripts = [] + const originals = getDriverOriginals() + const prev = originals.executeScript + originals.executeScript = (async (_driver: unknown, script: unknown) => { + scripts.push(String(script)) + return null + }) as (typeof originals)['executeScript'] + restore = () => { + if (prev) { + originals.executeScript = prev + } else { + delete originals.executeScript + } + } + }) + + afterEach(() => { + restore?.() + restore = undefined + }) + + const capturerWith = (capabilities: Record) => { + const cap = makeCapturer({}) + cap.metadata = { capabilities } as never + return cap + } + + it('reports itself native from the published capabilities', () => { + expect(capturerWith(NATIVE).isNativeAppSession).toBe(true) + expect(capturerWith(MOBILE_WEB).isNativeAppSession).toBe(false) + }) + + it('reads the bag the adapter actually publishes', async () => { + // Not a hand-written object: `buildDriverMetadata` is what fills + // `metadata`, and it receives selenium's `Capabilities` — whose data is in + // a private Map. Assigning a plain bag here proved nothing about that, and + // every guard in this file was dead against a real driver. + const { metadata } = await buildDriverMetadata({ + driver: { + getSession: () => Promise.resolve({ getId: () => 'sess-1' }), + getCapabilities: () => + Promise.resolve({ + keys: () => new Map(Object.entries(NATIVE)).keys(), + get: (key: string) => (NATIVE as Record)[key] + }) + } as never, + driverReadyTs: Date.now(), + detectedRunner: 'mocha' + }) + const cap = makeCapturer({}) + cap.metadata = metadata as never + + expect(cap.isNativeAppSession).toBe(true) + }) + + it('makes no page call from captureTrace', async () => { + await capturerWith(NATIVE).captureTrace(true) + expect(scripts).toEqual([]) + }) + + it('makes none from injectScript or reinjectIfNavigated', async () => { + const cap = capturerWith(NATIVE) + + await cap.injectScript() + await cap.reinjectIfNavigated() + + expect(scripts).toEqual([]) + }) + + it('still drains a phone running a browser', async () => { + // Its recovery injection is the only collector such a session gets, so a + // guard keyed on the device rather than the document silences it entirely. + await capturerWith(MOBILE_WEB).captureTrace(true) + expect(scripts.length).toBeGreaterThan(0) + }) +}) From 6c3d2ea46e39ef749800ffe2d356546696ac1369 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Fri, 11 Sep 2026 00:00:25 +0530 Subject: [PATCH 06/11] fix(nightwatch-devtools): skip page-side capture on a native session --- .../src/action-snapshot.ts | 18 ++- packages/nightwatch-devtools/src/session.ts | 22 +++- .../tests/native-session.test.ts | 113 ++++++++++++++++++ 3 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 packages/nightwatch-devtools/tests/native-session.test.ts diff --git a/packages/nightwatch-devtools/src/action-snapshot.ts b/packages/nightwatch-devtools/src/action-snapshot.ts index 5c4aa814..2dd1f55c 100644 --- a/packages/nightwatch-devtools/src/action-snapshot.ts +++ b/packages/nightwatch-devtools/src/action-snapshot.ts @@ -16,15 +16,25 @@ export function captureActionSnapshot( browser: NightwatchBrowser, command: string, timestamp?: number, - runner?: TestRunnerId + runner?: TestRunnerId, + native = false ): 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. return coreCapture({ command, timestamp, runner, - runScript: (src) => webdriverExecute(browser, `return (${src})`), takeScreenshot: () => webdriverGet(browser, 'screenshot'), - getUrl: () => webdriverGet(browser, 'url'), - getTitle: () => webdriverGet(browser, 'title') + ...(native + ? {} + : { + runScript: (src: string) => + webdriverExecute(browser, `return (${src})`), + getUrl: () => webdriverGet(browser, 'url'), + getTitle: () => webdriverGet(browser, 'title') + }) }) } diff --git a/packages/nightwatch-devtools/src/session.ts b/packages/nightwatch-devtools/src/session.ts index 4bd105b3..c8f459dd 100644 --- a/packages/nightwatch-devtools/src/session.ts +++ b/packages/nightwatch-devtools/src/session.ts @@ -160,7 +160,8 @@ export class SessionCapturer extends SessionCapturerBase { this.#browser, command, timestamp, - this.runner + this.runner, + this.isNativeAppSession ).then((snap) => { if (snap) { upsertRichestSnapshot(this.actionSnapshots, snap) @@ -173,6 +174,11 @@ export class SessionCapturer extends SessionCapturerBase { commandLogEntry: CommandLog & { _id?: number }, args: unknown[] ) { + // Page script, and the 500 ms settle below would be spent to reach a + // document that does not exist. + if (this.isNativeAppSession) { + return + } await new Promise((resolve) => setTimeout(resolve, 500)) const raw = await this.#browser!.execute(CAPTURE_PERFORMANCE_SCRIPT) const payload = unwrapDriverValue( @@ -300,6 +306,11 @@ export class SessionCapturer extends SessionCapturerBase { * is idempotent per document, so an already-anchored page costs one drain. */ async anchorAfterNavigation(browser: NightwatchBrowser): Promise { + // Polls the page for its own document identity, so there is nothing to + // poll and nothing to anchor without one. + if (this.isNativeAppSession) { + return + } const before = this.lastDocumentOrigin const replaced = await pollUntilReady( async () => { @@ -368,6 +379,9 @@ export class SessionCapturer extends SessionCapturerBase { * Inject the WDIO devtools script into the browser page */ async injectScript(browser: NightwatchBrowser) { + if (this.isNativeAppSession) { + return + } try { // Injecting over a live collector replaces `window.wdioTraceCollector` // with a fresh instance and DISCARDS whatever it had buffered — including @@ -494,6 +508,12 @@ export class SessionCapturer extends SessionCapturerBase { forceAnchor = false, anchorTimestamp?: number ) { + // A native app has no document to drain. Inside the method, because four + // call sites reach it — the command hook, the navigation proxy, the + // cucumber pre-quit hook and finalize. + if (this.isNativeAppSession) { + return + } // Performance logs only accumulate, so they lose nothing by waiting, while // the drain is racing the page it reads from (`drainOutgoingPage`). await this.drainCollector(browser, forceAnchor, anchorTimestamp) diff --git a/packages/nightwatch-devtools/tests/native-session.test.ts b/packages/nightwatch-devtools/tests/native-session.test.ts new file mode 100644 index 00000000..1a99aa20 --- /dev/null +++ b/packages/nightwatch-devtools/tests/native-session.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import { captureActionSnapshot } from '../src/action-snapshot.js' +import { SessionCapturer } from '../src/session.js' +import { webdriverExecute, webdriverGet } from '../src/helpers/webdriverHttp.js' +import type { NightwatchBrowser } from '../src/types.js' + +// The page-side calls all go over raw WebDriver HTTP to stay off Nightwatch's +// command queue, so the transport is what a native guard has to leave alone. +vi.mock('../src/helpers/webdriverHttp.js', () => ({ + resolveWebDriverAddress: () => ({ host: 'localhost', port: 4444 }), + webdriverGet: vi.fn(async () => null), + webdriverPost: vi.fn(async () => null), + webdriverExecute: vi.fn(async () => null) +})) + +/** + * A native app has no document, so the drain, the injection and the + * document-replacement poll are round trips that can only fail. Read off the + * capabilities `session-init` already published as metadata. + */ +describe('nightwatch SessionCapturer on a native session', () => { + const NATIVE = { + platformName: 'Android', + 'appium:automationName': 'UiAutomator2', + 'appium:app': '/app.apk' + } + const MOBILE_WEB = { + platformName: 'Android', + browserName: 'chrome', + 'appium:automationName': 'UiAutomator2' + } + + const browser = () => + ({ + sessionId: 'sess-1', + capabilities: {}, + execute: vi.fn(async () => ({ value: null })), + options: { webdriver: { host: 'localhost', port: 4444 } } + }) as unknown as NightwatchBrowser + + const capturerWith = (capabilities: Record) => { + const cap = new SessionCapturer({}, browser()) + cap.metadata = { capabilities } as never + return cap + } + + beforeEach(() => { + vi.mocked(webdriverExecute).mockClear() + vi.mocked(webdriverGet).mockClear() + }) + + it('reports itself native from the published capabilities', () => { + expect(capturerWith(NATIVE).isNativeAppSession).toBe(true) + expect(capturerWith(MOBILE_WEB).isNativeAppSession).toBe(false) + }) + + it('makes no page call from captureTrace', async () => { + await capturerWith(NATIVE).captureTrace(browser(), true) + expect(webdriverExecute).not.toHaveBeenCalled() + }) + + it('makes none from injectScript or anchorAfterNavigation', async () => { + const cap = capturerWith(NATIVE) + const b = browser() + + await cap.injectScript(b) + await cap.anchorAfterNavigation(b) + + expect(webdriverExecute).not.toHaveBeenCalled() + }) + + it('still drains a phone running a browser', async () => { + await capturerWith(MOBILE_WEB).captureTrace(browser(), true) + expect(webdriverExecute).toHaveBeenCalled() + }) +}) + +/** + * 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. + */ +describe('nightwatch per-action snapshot on a native session', () => { + const browser = () => + ({ + sessionId: 'sess-1', + capabilities: {}, + options: { webdriver: { host: 'localhost', port: 4444 } } + }) as unknown as NightwatchBrowser + + beforeEach(() => { + vi.mocked(webdriverExecute).mockClear() + vi.mocked(webdriverGet).mockClear() + }) + + it('takes the screenshot and makes no page read', async () => { + await captureActionSnapshot(browser(), 'click', 1, undefined, true) + + expect(webdriverExecute).not.toHaveBeenCalled() + const paths = vi.mocked(webdriverGet).mock.calls.map(([, path]) => path) + expect(paths).toEqual(['screenshot']) + }) + + it('reads the page for a session that has one', async () => { + await captureActionSnapshot(browser(), 'click', 1, undefined, false) + + expect(webdriverExecute).toHaveBeenCalled() + const paths = vi.mocked(webdriverGet).mock.calls.map(([, path]) => path) + expect(paths).toContain('url') + expect(paths).toContain('title') + }) +}) From 9468d4ffa1920501c987894aecc37f2a50097288 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Fri, 11 Sep 2026 00:00:36 +0530 Subject: [PATCH 07/11] feat(selenium-devtools-py): mirror the native-session predicate --- .../scripts/gen_contract.py | 9 ++ .../src/selenium_devtools/_contract.py | 1 + .../src/selenium_devtools/device.py | 84 +++++++++++++ .../selenium-devtools-py/tests/test_device.py | 110 ++++++++++++++++++ 4 files changed, 204 insertions(+) create mode 100644 packages/selenium-devtools-py/src/selenium_devtools/device.py create mode 100644 packages/selenium-devtools-py/tests/test_device.py diff --git a/packages/selenium-devtools-py/scripts/gen_contract.py b/packages/selenium-devtools-py/scripts/gen_contract.py index 836ad9d9..3d7c2ea1 100644 --- a/packages/selenium-devtools-py/scripts/gen_contract.py +++ b/packages/selenium-devtools-py/scripts/gen_contract.py @@ -193,6 +193,13 @@ def _test_runner_ids(types_ts: str) -> list[str]: return re.findall(r"'([^']+)'", m.group(1)) +def _native_platforms(device_ts: str) -> list[str]: + m = re.search(r"export const NATIVE_PLATFORMS = \[(.*?)\] as const", device_ts, re.DOTALL) + if not m: + raise SystemExit("could not find `NATIVE_PLATFORMS` in shared/device.ts") + return re.findall(r"'([^']+)'", m.group(1)) + + def main() -> int: root = _repo_root() shared = root / "packages" / "shared" @@ -210,6 +217,7 @@ def main() -> int: routes_ts = (shared / "src" / "routes.ts").read_text() control = _ws_scopes(routes_ts) worker_query = _worker_query(routes_ts) + native_platforms = _native_platforms((shared / "src" / "device.ts").read_text()) runner_ts = (shared / "src" / "runner.ts").read_text() run_id_env = _run_id_env(runner_ts) rerun_slot = _rerun_slot(runner_ts) @@ -289,6 +297,7 @@ def main() -> int: f'ELEMENT_SCRIPTS_PATH = "{element_scripts_path}"', f'RUNNER_ID = "{REQUIRED_RUNNER_ID}"', f"TEST_RUNNER_IDS = frozenset({sorted(runner_ids)!r})", + f"NATIVE_PLATFORMS = frozenset({sorted(native_platforms)!r})", "", f'WORKER_QUERY_RUN_ID = "{worker_query["runId"]}"', f'ENV_RUN_ID = "{run_id_env}"', diff --git a/packages/selenium-devtools-py/src/selenium_devtools/_contract.py b/packages/selenium-devtools-py/src/selenium_devtools/_contract.py index d07778de..c46e05d0 100644 --- a/packages/selenium-devtools-py/src/selenium_devtools/_contract.py +++ b/packages/selenium-devtools-py/src/selenium_devtools/_contract.py @@ -21,6 +21,7 @@ ELEMENT_SCRIPTS_PATH = "/api/element-scripts" RUNNER_ID = "selenium-webdriver" TEST_RUNNER_IDS = frozenset(['cucumber', 'jasmine', 'mocha', 'nightwatch', 'nightwatch-cucumber', 'selenium-webdriver']) +NATIVE_PLATFORMS = frozenset(['android', 'ios']) WORKER_QUERY_RUN_ID = "runId" ENV_RUN_ID = "DEVTOOLS_RUN_ID" diff --git a/packages/selenium-devtools-py/src/selenium_devtools/device.py b/packages/selenium-devtools-py/src/selenium_devtools/device.py new file mode 100644 index 00000000..3b2453ab --- /dev/null +++ b/packages/selenium-devtools-py/src/selenium_devtools/device.py @@ -0,0 +1,84 @@ +"""Whether a session's capabilities describe a device, and a document. + +Mirrors the native-session predicate in ``packages/shared/src/device.ts`` — the +device's name and version are read on the TS side, from the capabilities this +adapter sends. The platform list comes from the generated contract rather than +being retyped here, so shared stays the one place it is stated. +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + +from ._contract import NATIVE_PLATFORMS + + +def _cap_string(caps: Dict[str, Any], key: str) -> Optional[str]: + value = caps.get(key) + if isinstance(value, str) and value.strip(): + return value + return None + + +def _deep_cap_string(caps: Dict[str, Any], key: str) -> Optional[str]: + """The capability from the bag, or from one level of vendor options. A + device cloud commonly states ``platformName`` and ``browserName`` only + inside its own bag (``bstack:options``). + + Every caller passes MATCHED capabilities, so a request-shaped bag's + ``firstMatch`` list is deliberately not scanned — the server merges + ``alwaysMatch`` with the one entry it chose, and reading a browser out of + any entry would claim one the session never got.""" + own = _cap_string(caps, key) + if own: + return own + for nested in caps.values(): + if isinstance(nested, dict): + value = _cap_string(nested, key) + if value: + return value + return None + + +def _names_an_automation(caps: Dict[str, Any]) -> bool: + """Whether the capabilities name an Appium automation. Separate from naming + a DEVICE: a Mac2, WinAppDriver or tvOS session has no document either.""" + return bool( + _deep_cap_string(caps, "appium:automationName") + or _deep_cap_string(caps, "automationName") + ) + + +def native_platform(capabilities: Any) -> Optional[str]: + """``'android'``/``'ios'`` when the session ran on a device, else None.""" + if not isinstance(capabilities, dict): + return None + platform = _deep_cap_string(capabilities, "platformName") + if platform is None: + return None + lowered = platform.lower() + return lowered if lowered in NATIVE_PLATFORMS else None + + +def is_native_app_session(capabilities: Any) -> bool: + """Whether the session had no web document to run page script in. + + A device alone does not answer it: an Appium session driving Chrome or + Safari runs on a phone and has a real page, so the browser it names is the + discriminator. A device is not required either — an Appium automation is + enough, because a Mac2 or tvOS session has no document. See shared's + `isNativeAppSession` for why answering False is the expensive direction. + """ + if not isinstance(capabilities, dict): + return False + if native_platform(capabilities) is None and not _names_an_automation( + capabilities + ): + return False + return _deep_cap_string(capabilities, "browserName") is None + + +def driver_is_native_app(driver: Any) -> bool: + """`is_native_app_session` for a live driver, whose capabilities selenium + exposes as a plain dict.""" + return is_native_app_session(getattr(driver, "capabilities", None)) diff --git a/packages/selenium-devtools-py/tests/test_device.py b/packages/selenium-devtools-py/tests/test_device.py new file mode 100644 index 00000000..a75c9b97 --- /dev/null +++ b/packages/selenium-devtools-py/tests/test_device.py @@ -0,0 +1,110 @@ +"""What the capabilities say about the device and the document. + +Mirrors `packages/shared/tests/device.test.ts` — the two implementations answer +the same question for the same bags, and a divergence here is a divergence in +what the two ends of a trace believe about the same session. +""" + +import unittest + +from selenium_devtools.device import ( + driver_is_native_app, + is_native_app_session, + native_platform, +) + +LOCAL_ANDROID = { + "platformName": "Android", + "appium:deviceName": "Pixel_7_API_34", + "appium:platformVersion": "14", +} +IOS = { + "platformName": "iOS", + "appium:automationName": "XCUITest", + "appium:app": "/app.app", +} +MOBILE_WEB = { + "platformName": "Android", + "browserName": "Chrome", + "appium:automationName": "Chrome", +} +DESKTOP = {"browserName": "chrome", "browserVersion": "152", "platform": "mac"} +CLOUD_NESTED_APP = { + "bstack:options": {"platformName": "Android", "deviceName": "Google Pixel 7"} +} +CLOUD_NESTED_WEB = { + "bstack:options": { + "platformName": "Android", + "deviceName": "Google Pixel 7", + "browserName": "Chrome", + } +} + + +class TestNativePlatform(unittest.TestCase): + def test_it_names_the_two_native_platforms(self): + self.assertEqual(native_platform(LOCAL_ANDROID), "android") + self.assertEqual(native_platform(IOS), "ios") + + def test_a_desktop_session_has_none(self): + self.assertIsNone(native_platform(DESKTOP)) + + def test_it_reads_a_vendor_bag(self): + self.assertEqual(native_platform(CLOUD_NESTED_APP), "android") + + def test_it_survives_a_bag_it_cannot_read(self): + self.assertIsNone(native_platform(None)) + self.assertIsNone(native_platform("android")) + self.assertIsNone(native_platform({})) + + +class TestIsNativeAppSession(unittest.TestCase): + def test_true_when_the_session_named_no_browser(self): + self.assertTrue(is_native_app_session(LOCAL_ANDROID)) + self.assertTrue(is_native_app_session(IOS)) + + def test_false_when_it_named_one(self): + # A phone driving Chrome has a real page; skipping its DOM capture is + # the failure this predicate exists to avoid. + self.assertFalse(is_native_app_session(MOBILE_WEB)) + + def test_false_for_a_desktop_session(self): + self.assertFalse(is_native_app_session(DESKTOP)) + + def test_it_reads_both_facts_out_of_a_vendor_bag(self): + self.assertTrue(is_native_app_session(CLOUD_NESTED_APP)) + self.assertFalse(is_native_app_session(CLOUD_NESTED_WEB)) + + def test_true_for_an_appium_session_with_no_device_platform(self): + # Mirrors the TS side: a Mac2 or tvOS session has no document either. + self.assertTrue( + is_native_app_session( + {"platformName": "mac", "appium:automationName": "Mac2"} + ) + ) + + def test_a_blank_browser_name_is_no_browser(self): + self.assertTrue( + is_native_app_session({"platformName": "Android", "browserName": " "}) + ) + + def test_false_for_a_bag_it_cannot_read(self): + self.assertFalse(is_native_app_session(None)) + self.assertFalse(is_native_app_session({})) + + +class TestDriverIsNativeApp(unittest.TestCase): + class _Driver: + def __init__(self, capabilities): + self.capabilities = capabilities + + def test_it_reads_the_live_driver(self): + self.assertTrue(driver_is_native_app(self._Driver(LOCAL_ANDROID))) + self.assertFalse(driver_is_native_app(self._Driver(MOBILE_WEB))) + + def test_a_driver_exposing_nothing_is_not_native(self): + self.assertFalse(driver_is_native_app(object())) + + +if __name__ == "__main__": + unittest.main() From 08f40e0ac54cdef10aab91ba01a22285a88a643c Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Fri, 11 Sep 2026 00:00:47 +0530 Subject: [PATCH 08/11] fix(selenium-devtools-py): skip page-side capture on a native session --- .../src/selenium_devtools/instrumentation.py | 39 +++++++++++++- .../src/selenium_devtools/snapshot.py | 7 +++ .../tests/test_a11y_elements.py | 24 +++++++++ .../tests/test_instrumentation.py | 52 +++++++++++++++++++ .../tests/test_snapshot.py | 33 ++++++++++++ 5 files changed, 154 insertions(+), 1 deletion(-) diff --git a/packages/selenium-devtools-py/src/selenium_devtools/instrumentation.py b/packages/selenium-devtools-py/src/selenium_devtools/instrumentation.py index 805cdf80..6a81cf9c 100644 --- a/packages/selenium-devtools-py/src/selenium_devtools/instrumentation.py +++ b/packages/selenium-devtools-py/src/selenium_devtools/instrumentation.py @@ -24,6 +24,7 @@ from .capturer import SessionCapturer from .cdp_screencast import start_push_screencast from .collector_source import reset_cache as reset_collector_cache +from .device import driver_is_native_app from .element_scripts import reset_cache as reset_element_scripts_cache from .constants import ( BIDI_CAPABILITY, @@ -324,7 +325,7 @@ def _attach_performance( adapters can afford. A read that lands too early anyway carries no `navigation` entry and is discarded. """ - if row is None: + if row is None or driver_is_native_app(driver): return try: payload = _guarded_execute_script(driver)( @@ -407,9 +408,14 @@ def _capture_action_snapshot( Goes through the guarded executor, or each read lands back in this same hook and the timeline grows an `executeScript` row beside every action — the bug the CDP window-handle read caused, in a path that runs far more often. + + Skipped entirely on a native app: both reads are page script, they run on + every action, and neither can do anything but fail there. """ if not _state["trace"] or not _state["a11y"]: return + if driver_is_native_app(driver): + return scripts = _state.get("element_scripts") if not scripts: return @@ -583,6 +589,31 @@ def _finalize_screencast( _log.info("screencast saved: %s", info.get("video_path")) +def _driver_window(driver: Any) -> Optional[Viewport]: + """The device window, for a session with no page to measure. + + Marked internal for the same reason the page read is guarded: selenium + implements this as `getWindowRect`, which routes back through `execute` and + would open every native run with a command row of our own making. + """ + _internal.active = True + try: + size = driver.get_window_size() + except Exception as exc: # noqa: BLE001 — a default frame, not a failed run + _log.debug("window size read failed: %s", exc) + return None + finally: + _internal.active = False + if not isinstance(size, dict): + return None + width, height = size.get("width"), size.get("height") + if not isinstance(width, int) or not isinstance(height, int): + return None + if width <= 0 or height <= 0: + return None + return {"width": width, "height": height} + + def _viewport(driver: Any) -> Optional[Viewport]: """The page's own viewport, for the player's frame geometry. @@ -593,7 +624,13 @@ def _viewport(driver: Any) -> Optional[Viewport]: Guarded, or the read lands back in the command hook as an `executeScript` row at the head of every run. + + A native app has no window to ask, so the driver's own window size is the + only answer there — and without it such a run reached the player framed at + 1280x720, which is a desktop browser's shape rather than a phone's. """ + if driver_is_native_app(driver): + return _driver_window(driver) run = _guarded_execute_script(driver) try: size = run("return [window.innerWidth, window.innerHeight]") diff --git a/packages/selenium-devtools-py/src/selenium_devtools/snapshot.py b/packages/selenium-devtools-py/src/selenium_devtools/snapshot.py index eb8c6f82..46de9242 100644 --- a/packages/selenium-devtools-py/src/selenium_devtools/snapshot.py +++ b/packages/selenium-devtools-py/src/selenium_devtools/snapshot.py @@ -25,6 +25,7 @@ from .collector_source import fetch_collector_source from .constants import LOGGER_NAME +from .device import driver_is_native_app #: A ``driver.execute_script(script, *args)`` shaped callable — injectable so #: tests drive injection/readback without a real driver. @@ -297,6 +298,12 @@ def start_snapshot_capture( ``execute_fn`` overrides ``driver.execute_script`` — the adapter passes a capture-bypassing variant so injection/readback don't appear as commands.""" + # A native app has no document to collect from, and the caller already + # treats None as "there is nothing to drain" — so the injection, its + # readiness probe and every later drain are skipped at the one place that + # decides whether DOM capture exists for this session. + if driver_is_native_app(driver): + return None run = execute_fn or getattr(driver, "execute_script", None) if not callable(run): _warn("driver has no execute_script — snapshot capture skipped") diff --git a/packages/selenium-devtools-py/tests/test_a11y_elements.py b/packages/selenium-devtools-py/tests/test_a11y_elements.py index 713753e5..83bd1295 100644 --- a/packages/selenium-devtools-py/tests/test_a11y_elements.py +++ b/packages/selenium-devtools-py/tests/test_a11y_elements.py @@ -130,6 +130,30 @@ def _capture(self, driver, *, trace=True, a11y=True, scripts=SCRIPTS): ) return instrumentation.action_snapshots() + def test_a_native_app_is_not_read_at_all(self): + """Both reads are page script and they fire on every action, so on a + session with no document they are the densest source of round trips + that can only fail.""" + + class NativeDriver(self.Driver): + capabilities = {"platformName": "Android", "appium:app": "/app.apk"} + + driver = NativeDriver(elements=[{"selector": "#go"}]) + snaps = self._capture(driver) + + self.assertEqual(driver.scripts_run, []) + self.assertEqual(snaps, []) + + def test_a_phone_running_a_browser_is_still_read(self): + class MobileWebDriver(self.Driver): + capabilities = {"platformName": "Android", "browserName": "Chrome"} + + driver = MobileWebDriver(elements=[{"selector": "#go"}]) + snaps = self._capture(driver) + + self.assertEqual(len(driver.scripts_run), 2) + self.assertIn("elements", snaps[0]) + # Two reads, two panes. Capturing only `elements` left the A11y tab # reporting "no accessibility snapshot for this command" while 39 element # files sat in the same archive — the tab reads the serialized TREE. diff --git a/packages/selenium-devtools-py/tests/test_instrumentation.py b/packages/selenium-devtools-py/tests/test_instrumentation.py index 79ea2dbb..3ded406f 100644 --- a/packages/selenium-devtools-py/tests/test_instrumentation.py +++ b/packages/selenium-devtools-py/tests/test_instrumentation.py @@ -1162,6 +1162,58 @@ def test_a_driver_that_cannot_answer_omits_it(self): [meta] = [d for s, d in tx.sent if s == "metadata"] self.assertNotIn("viewport", meta) + def test_a_native_app_is_measured_off_the_driver(self): + """A native app has no `window` to read, so an unguarded probe left the + run with no viewport and the player framed a phone at 1280x720.""" + + class NativeDriver(ViewportDriver): + def __init__(self): + super().__init__() + self.capabilities = { + "platformName": "Android", + "appium:app": "/app.apk", + } + self.window_reads = 0 + + def get_window_size(self): + self.window_reads += 1 + return {"width": 1080, "height": 2219} + + instrumentation.uninstall() + tx = FakeTransport() + instrumentation.install(SessionCapturer(tx), NativeDriver) + driver = NativeDriver() + driver.execute("get", {"url": "app://start"}) + + [meta] = [d for s, d in tx.sent if s == "metadata"] + self.assertEqual(meta["viewport"], {"width": 1080, "height": 2219}) + self.assertEqual(driver.window_reads, 1) + # And never asked the page, which is the round trip that can only fail. + self.assertFalse(any("innerWidth" in s for s in driver.scripts)) + + def test_a_phone_running_a_browser_is_still_measured_off_the_page(self): + """The same phone with a browserName has a real page, and its viewport + is the page's own — not the device window.""" + + class MobileWebDriver(ViewportDriver): + def __init__(self): + super().__init__() + self.capabilities = { + "platformName": "Android", + "browserName": "Chrome", + } + + def get_window_size(self): + raise AssertionError("should not read the device window") + + instrumentation.uninstall() + tx = FakeTransport() + instrumentation.install(SessionCapturer(tx), MobileWebDriver) + MobileWebDriver().execute("get", {"url": "https://x/"}) + + [meta] = [d for s, d in tx.sent if s == "metadata"] + self.assertEqual(meta["viewport"], {"width": 1280, "height": 1024}) + def test_a_nonsense_size_is_refused(self): for bad in ([0, 800], [1280, -1], ["1280", 800], [1280], "1280x800"): with self.subTest(size=bad): diff --git a/packages/selenium-devtools-py/tests/test_snapshot.py b/packages/selenium-devtools-py/tests/test_snapshot.py index 9e7553e9..cd27b70f 100644 --- a/packages/selenium-devtools-py/tests/test_snapshot.py +++ b/packages/selenium-devtools-py/tests/test_snapshot.py @@ -286,6 +286,39 @@ def execute_script(self, script, *args): def test_none_when_driver_has_no_execute_script(self): self.assertIsNone(start_snapshot_capture(object())) + def test_none_for_a_native_app_and_no_script_reaches_the_device(self): + # There is no document to collect from, and the caller already treats + # None as "nothing to drain" — so the injection, its readiness probe + # and every later drain are all skipped from this one decision. + class Driver: + capabilities = {"platformName": "iOS", "appium:app": "/app.app"} + + def __init__(self): + self.calls = [] + + def execute_script(self, script, *args): + self.calls.append(script) + return True + + driver = Driver() + + self.assertIsNone(start_snapshot_capture(driver)) + self.assertEqual(driver.calls, []) + + def test_a_phone_running_a_browser_still_gets_a_capturer(self): + # Same phone, but it named a browser: it has a real page, and this is + # the only DOM capture such a session gets. + class Driver: + capabilities = {"platformName": "iOS", "browserName": "Safari"} + + def execute_script(self, script, *args): + return True + + self.assertIsInstance( + start_snapshot_capture(Driver(), script_path=self._tmp_script()), + SnapshotCapturer, + ) + def test_the_capturer_survives_a_failed_first_injection(self): # Returning None here made the failure terminal: the caller stores None, # its post-command refresh skips a missing capturer, and inject() is From 597116e3096397755bff50065d2000d27647cd0c Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Fri, 11 Sep 2026 00:01:00 +0530 Subject: [PATCH 09/11] fix(app): derive the device when the adapter sent none --- packages/app/src/controller/contextUpdates.ts | 25 +++++-- packages/app/tests/contextUpdates.test.ts | 68 +++++++++++++++++++ 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/packages/app/src/controller/contextUpdates.ts b/packages/app/src/controller/contextUpdates.ts index fb3dda4c..41b2a8d0 100644 --- a/packages/app/src/controller/contextUpdates.ts +++ b/packages/app/src/controller/contextUpdates.ts @@ -7,11 +7,12 @@ * new value the ContextProvider should publish. */ -import type { - CommandLog, - NetworkRequest, - Metadata, - MetadataBySession +import { + deviceFromCapabilities, + type CommandLog, + type NetworkRequest, + type Metadata, + type MetadataBySession } from '@wdio/devtools-shared' /** @@ -128,6 +129,20 @@ export function mergeSessionMetadata( delete bySession[PENDING_SESSION_KEY] } + // A live session states its device only if its adapter derived one, and only + // the WDIO service does — so a Selenium, Nightwatch or Python phone run + // reached the player as a desktop session and got the desktop layout. Derived + // here rather than in each adapter, because this is the one ingestion point + // every live message passes through. A device the adapter DID send wins. + // + // Live mode only: this reducer serves the `metadata` WS scope. A trace's + // metadata is built by the backend's reader, and the exporter already derives + // the device on the way INTO the zip. + const device = merged.device ?? deviceFromCapabilities(merged.capabilities) + if (device) { + merged = { ...merged, device } + } + bySession[sessionId] = merged return { bySession, diff --git a/packages/app/tests/contextUpdates.test.ts b/packages/app/tests/contextUpdates.test.ts index 641acd55..35bf9c29 100644 --- a/packages/app/tests/contextUpdates.test.ts +++ b/packages/app/tests/contextUpdates.test.ts @@ -204,3 +204,71 @@ describe('mergeSessionMetadata', () => { expect(Object.keys(state.bySession)).toEqual(['s1']) }) }) + +/** + * The device selects the player's whole layout, and only the WDIO service + * derives one before sending. A Selenium, Nightwatch or Python phone run sends + * capabilities that say `platformName` and nothing else, so without this + * fallback it reached the player as a desktop session. + */ +describe('mergeSessionMetadata deriving the device', () => { + const merge = (incoming: Record) => + mergeSessionMetadata( + { bySession: {}, currentSessionId: undefined }, + incoming as never + ).active + + it('derives it from capabilities when the adapter sent none', () => { + const active = merge({ + sessionId: 's1', + capabilities: { + platformName: 'Android', + deviceModel: 'Pixel 7', + platformVersion: '14' + } + }) + + expect(active.device).toEqual({ + platform: 'android', + name: 'Pixel 7', + version: '14' + }) + }) + + it('keeps a device the adapter did send', () => { + // The service resolves its own, and it may know more than the caps do. + const active = merge({ + sessionId: 's1', + device: { platform: 'ios', name: 'iPhone 17', version: '18.1' }, + capabilities: { platformName: 'iOS' } + }) + + expect(active.device).toEqual({ + platform: 'ios', + name: 'iPhone 17', + version: '18.1' + }) + }) + + it('leaves a desktop session without one', () => { + expect( + merge({ sessionId: 's1', capabilities: { browserName: 'chrome' } }).device + ).toBeUndefined() + }) + + it('derives it once the capabilities arrive in a later message', () => { + // Metadata is merged per session across messages, so the derivation has to + // read the MERGED bag rather than only what this message carried. + const state = mergeSessionMetadata( + { bySession: {}, currentSessionId: undefined }, + { sessionId: 's1' } as never + ) + expect(state.active.device).toBeUndefined() + + const next = mergeSessionMetadata(state, { + capabilities: { platformName: 'Android', deviceModel: 'Pixel 7' } + } as never) + + expect(next.active.device).toEqual({ platform: 'android', name: 'Pixel 7' }) + }) +}) From 34199c892814744af3c4b269b83ea09af385408b Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Fri, 11 Sep 2026 00:01:14 +0530 Subject: [PATCH 10/11] fix(core): find the collector bundle either way it resolved --- .../find-the-collector-bundle-either-way.md | 12 ++++++ packages/core/src/script-loader.ts | 39 +++++++++++++++++-- packages/core/tests/script-loader.test.ts | 33 ++++++++++++++++ 3 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 .changeset/find-the-collector-bundle-either-way.md diff --git a/.changeset/find-the-collector-bundle-either-way.md b/.changeset/find-the-collector-bundle-either-way.md new file mode 100644 index 00000000..20d7bb31 --- /dev/null +++ b/.changeset/find-the-collector-bundle-either-way.md @@ -0,0 +1,12 @@ +--- +"@wdio/devtools-core": patch +"@wdio/devtools-service": patch +"@wdio/selenium-devtools": patch +"@wdio/nightwatch-devtools": patch +--- + +Find the collector bundle whether the package resolved to its build or its source. `loadCollectorSource` resolved `@wdio/devtools-script` and then read `script.js` from **that entry's directory**, which only holds when the entry is the built one. The repo tsconfig maps the package to `packages/script/src/index.ts`, and every resolver honouring those paths lands there instead — `tsx` and `ts-node` among them, which is how `wdio run .ts` loads a config. The read then ENOENTs on `packages/script/src/script.js`. + +Nothing failed loudly, which is why this survived: every caller treats an injection failure as a warning, so the run continued and lost its DOM capture. Surfaced as `Collector re-injection failed: ENOENT … packages/script/src/script.js` on a mobile-web Appium run, and reproduced in three lines against a plain `tsx` entry point, so it was never mobile-specific — any TS-config-driven run was affected. + +The bundle is now looked for beside the entry *and* at `../dist/script.js`, and a genuine miss reports every path it tried instead of only the last. diff --git a/packages/core/src/script-loader.ts b/packages/core/src/script-loader.ts index 7c4da0ef..fb3156bf 100644 --- a/packages/core/src/script-loader.ts +++ b/packages/core/src/script-loader.ts @@ -32,13 +32,46 @@ export function collectorDrainExpression(forceAnchor = false): string { return `if (!(${COLLECTOR_READY_EXPRESSION})) { return null; } ${call} return window.wdioTraceCollector.getTraceData();` } +/** + * Where the collector bundle sits relative to the RESOLVED package entry. + * + * Two shapes, because the entry is not always the built one. The package's own + * `exports` points at `dist/script.js`, so the bundle is its neighbour — but + * the repo tsconfig maps `@wdio/devtools-script` to `packages/script/src/ + * index.ts`, and every resolver that honours those paths lands there instead. + * That includes `tsx`/`ts-node`, which is how `wdio run .ts` loads a + * config: reading `script.js` beside the entry then ENOENTs on + * `packages/script/src/script.js`, and because the callers only warn, DOM + * capture is silently lost for the whole run. + */ +export function collectorSourceCandidates(entry: string): string[] { + const dir = path.dirname(entry) + return [ + path.join(dir, 'script.js'), + path.join(dir, '..', 'dist', 'script.js') + ] +} + /** The collector bundle's raw source. Callers wrap it for their own injection * mechanism — an async IIFE for a `