Skip to content
Open
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
12 changes: 12 additions & 0 deletions .changeset/find-the-collector-bundle-either-way.md
Original file line number Diff line number Diff line change
@@ -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 <conf>.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.
24 changes: 24 additions & 0 deletions .changeset/native-detection-for-every-adapter.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 20 additions & 5 deletions packages/app/src/controller/contextUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand Down Expand Up @@ -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,
Expand Down
68 changes: 68 additions & 0 deletions packages/app/tests/contextUpdates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) =>
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' })
})
})
39 changes: 36 additions & 3 deletions packages/core/src/script-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <conf>.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 `<script>` body, a function declaration for a
* BiDi preload script. */
export async function loadCollectorSource(): Promise<string> {
const scriptPath = require.resolve('@wdio/devtools-script')
const scriptDir = path.dirname(scriptPath)
return fs.readFile(path.join(scriptDir, 'script.js'), 'utf-8')
const candidates = collectorSourceCandidates(
require.resolve('@wdio/devtools-script')
)
const failures: string[] = []
for (const candidate of candidates) {
try {
return await fs.readFile(candidate, 'utf-8')
} catch (err) {
// Not this shape — record it and try the next, so a genuine failure
// reports every place that was looked at rather than only the last.
failures.push(`${candidate} (${errorMessage(err)})`)
}
}
throw new Error(
`collector bundle not found — is @wdio/devtools-script built? Looked at: ${failures.join('; ')}`
)
}

/**
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/session-capturer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions packages/core/tests/script-loader.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from 'vitest'
import {
collectorSourceCandidates,
drainCollectorWithRecovery,
loadInjectableScript,
pollUntilReady
Expand Down Expand Up @@ -169,3 +170,35 @@ describe('pollUntilReady', () => {
expect(check).toHaveBeenCalledTimes(1)
})
})

/**
* The collector bundle has to be found whether the package resolved to its
* BUILT entry or to its source. The repo tsconfig maps
* `@wdio/devtools-script` to `packages/script/src/index.ts`, and every resolver
* that honours those paths lands there — `tsx`/`ts-node` among them, which is
* how `wdio run <conf>.ts` loads a config. Looking only beside the entry then
* ENOENTs on `src/script.js`, and since the callers only warn, the run loses
* DOM capture without failing.
*/
describe('collectorSourceCandidates', () => {
it('looks beside a built entry first', () => {
const [first] = collectorSourceCandidates(
'/repo/node_modules/@wdio/devtools-script/dist/script.js'
)
expect(first).toBe(
'/repo/node_modules/@wdio/devtools-script/dist/script.js'
)
})

it('also offers dist when the entry resolved to source', () => {
const candidates = collectorSourceCandidates(
'/repo/packages/script/src/index.ts'
)
// The src neighbour does not exist; the built bundle is one level up.
expect(candidates).toContain('/repo/packages/script/dist/script.js')
})

it('offers two shapes for any entry', () => {
expect(collectorSourceCandidates('/x/y/entry.js')).toHaveLength(2)
})
})
41 changes: 41 additions & 0 deletions packages/core/tests/session-capturer-base.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
18 changes: 14 additions & 4 deletions packages/nightwatch-devtools/src/action-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,25 @@ export function captureActionSnapshot(
browser: NightwatchBrowser,
command: string,
timestamp?: number,
runner?: TestRunnerId
runner?: TestRunnerId,
native = false
): Promise<ActionSnapshot | null> {
// 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<string>(browser, 'screenshot'),
getUrl: () => webdriverGet<string>(browser, 'url'),
getTitle: () => webdriverGet<string>(browser, 'title')
...(native
? {}
: {
runScript: (src: string) =>
webdriverExecute(browser, `return (${src})`),
getUrl: () => webdriverGet<string>(browser, 'url'),
getTitle: () => webdriverGet<string>(browser, 'title')
})
})
}
22 changes: 21 additions & 1 deletion packages/nightwatch-devtools/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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<CapturedPerformancePayload | undefined>(
Expand Down Expand Up @@ -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<void> {
// 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 () => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading