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/.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/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' }) + }) +}) 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 `