diff --git a/.changeset/native-capture-states-its-device.md b/.changeset/native-capture-states-its-device.md new file mode 100644 index 00000000..701a02d8 --- /dev/null +++ b/.changeset/native-capture-states-its-device.md @@ -0,0 +1,21 @@ +--- +"@wdio/devtools-service": minor +"@wdio/devtools-app": minor +"@wdio/selenium-devtools": minor +"@wdio/nightwatch-devtools": minor +"@wdio/devtools-backend": minor +--- + +Carry a native mobile session's viewport, capabilities and device into the trace. A native Appium session produced a zip claiming `viewport: 1280x720` and `browserName: chromium` — both the exporter's own fallbacks rather than anything measured. Three separate causes had to be fixed together, because none of them is useful alone. + +The values were never read: the WDIO service skipped its metadata send entirely for a native session, because it resolves the viewport from `window.visualViewport` and a native app has no DOM. It now reads the window off the driver instead (`getWindowSize`, measured at 1080x2219 on a Pixel 7 — the window minus the navigation bar), and degrades to no viewport rather than failing the session if that read is refused. + +Reading them would not have been enough: the capturer's `metadata` — the copy the exporter serializes — was only ever written by the page-side collector's payload, while `sendUpstream` merely transmits. A value resolved on the driver therefore reached a live dashboard and was dropped before the zip. `SessionCapturer.mergeMetadata` now stores as well as publishes, and merges rather than replaces so a later push naming only a url cannot wipe the device. + +And there was nowhere in the zip to put the device: `browserName` is normalized to `chromium` for android/iOS, `platform` names the HOST OS, and the reader rebuilt capabilities as `{ browserName }` alone, so the device survived only as prose inside `title` and every consumer re-derived "was this a phone?" from a heuristic. A `DeviceInfo` type and a single `deviceFromCapabilities` reader now live in shared, the zip states it as a `device` extension field on `context-options` (the same pattern the existing `runner` field uses), and the trace reader narrows it back in and puts the platform back onto the rebuilt capabilities. The naming order is what real hardware requires: `appium:deviceName` then `deviceModel` then `deviceName`, rejecting any candidate that merely repeats the udid — a device cloud reports an Android serial as both `deviceName` and `udid` and the friendly name only in `deviceModel`, while iOS reports a friendly `deviceName` with `udid` separate. + +Because the field is derived in the exporter from capabilities every adapter already sends, Selenium, Nightwatch and the Python adapter gain it with no adapter-side change. The viewport read is per-adapter and remains done only in the WDIO service; Selenium and Nightwatch set no viewport at all today, desktop or native, so their zips still take the exporter's fallback. + +The Metadata tab shows it as a `Device` row (`iPhone 17 (ios 18.1)`), which is all that reads it for now; #347 is the consumer this unblocks, and is what will shape and label the player's frame. + +Note on units, for anything tempted to size a captured image by this viewport: don't. It disagrees with the screenshot on both platforms — Android reports the window without the navigation bar (1080x2219 against a 1080x2400 shot) and iOS reports points rather than pixels (390x844 against 1170x2532). Fit by the image's own decoded dimensions. diff --git a/packages/app/src/components/workbench/metadata.ts b/packages/app/src/components/workbench/metadata.ts index 773cf707..a110b441 100644 --- a/packages/app/src/components/workbench/metadata.ts +++ b/packages/app/src/components/workbench/metadata.ts @@ -3,6 +3,7 @@ import { html, css, nothing, type TemplateResult } from 'lit' import { customElement, state } from 'lit/decorators.js' import { consume } from '@lit/context' +import { deviceLabel } from '@wdio/devtools-shared' import type { Metadata, MetadataBySession } from '@wdio/devtools-shared' import { metadataContext, @@ -168,6 +169,12 @@ export class DevtoolsMetadata extends Element { if (m.url) { sessionInfo.URL = m.url } + // The one place the trace's own device statement is shown. Present only + // for a native capture, and only for a zip recorded since the field + // existed — a desktop trace shows no Device row at all. + if (m.device) { + sessionInfo.Device = deviceLabel(m.device) + } // A viewport can arrive before its dimensions are serialized, and a // `0 × 0 px` row would read as a captured value rather than a missing one. if (m.viewport?.width && m.viewport.height) { diff --git a/packages/app/test-ui/workbench/panels/metadata.test.ts b/packages/app/test-ui/workbench/panels/metadata.test.ts index c4452fed..97e48fc3 100644 --- a/packages/app/test-ui/workbench/panels/metadata.test.ts +++ b/packages/app/test-ui/workbench/panels/metadata.test.ts @@ -182,6 +182,51 @@ describe('wdio-devtools-metadata', () => { ]) }) + /** + * The one place the trace's own device statement is shown. Before the zip + * carried a `device` field, a native capture was indistinguishable here + * from a desktop Chrome one: `browserName` is normalized to `chromium` and + * `platform` names the host OS. + */ + describe('the device it was recorded on', () => { + it('names the device a native capture reported', async () => { + const panel = await mountMetadata( + metadata({ + device: { platform: 'ios', name: 'iPhone 17', version: '18.1' } + }) + ) + + const session = sectionNamed(panel, 'Session') + expect(session.keys).toContain('Device') + expect(session.values).toContain('iPhone 17 (ios 18.1)') + }) + + it('degrades to what the session actually reported', async () => { + // A device cloud can report only a serial, which is rejected as a name. + const panel = await mountMetadata( + metadata({ device: { platform: 'android' } }) + ) + + expect(sectionNamed(panel, 'Session').values).toContain('android') + }) + + it('renders no Device row for a desktop capture', async () => { + const panel = await mountMetadata( + metadata({ + viewport: { + width: 1280, + height: 800, + offsetLeft: 0, + offsetTop: 0, + scale: 1 + } + }) + ) + + expect(sectionNamed(panel, 'Session').keys).not.toContain('Device') + }) + }) + it('renders the captured viewport as one row of dimensions', async () => { const viewport = { width: 1024, diff --git a/packages/backend/src/trace-reader-types.ts b/packages/backend/src/trace-reader-types.ts index 912588fc..e6d700e3 100644 --- a/packages/backend/src/trace-reader-types.ts +++ b/packages/backend/src/trace-reader-types.ts @@ -71,6 +71,10 @@ export interface ContextOptionsEvent { * zips and in ours from before the field existed. Untrusted — narrowed * through `isTestRunnerId` before it reaches `Metadata.runner`. */ runner?: string + /** Extension field naming the device the zip was recorded on. Same contract + * as `runner`: absent in foreign zips and in ours from before the field + * existed, and narrowed through `isDeviceInfo` on the way in. */ + device?: unknown } /** Sidecar `.stacks` shape: file table + per-call [fileIndex, line, column, function] frames. */ diff --git a/packages/backend/src/trace-reader-utils.ts b/packages/backend/src/trace-reader-utils.ts index 5f59d8fb..0026dce0 100644 --- a/packages/backend/src/trace-reader-utils.ts +++ b/packages/backend/src/trace-reader-utils.ts @@ -4,6 +4,7 @@ import { strFromU8 } from 'fflate' import { sourceResourceName } from '@wdio/devtools-trace/trace-sources' import { + isDeviceInfo, isTestRunnerId, TraceType, type ConsoleLog, @@ -315,13 +316,21 @@ export function buildMetadata(ctx: ContextOptionsEvent | undefined): Metadata { scale: 1 } const sessionId = ctx?.contextId?.split('@')[1] + const device = isDeviceInfo(ctx?.device) ? ctx.device : undefined return { type: TraceType.Standalone, viewport, + // A native session's browserName is normalized to `chromium` on the way + // out, so the platform is put back from the device rather than left for a + // reader to conclude a phone was a desktop Chrome. capabilities: ctx?.browserName - ? { browserName: ctx.browserName } + ? { + browserName: ctx.browserName, + ...(device ? { platformName: device.platform } : {}) + } : undefined, ...(sessionId ? { sessionId } : {}), - ...(isTestRunnerId(ctx?.runner) ? { runner: ctx.runner } : {}) + ...(isTestRunnerId(ctx?.runner) ? { runner: ctx.runner } : {}), + ...(device ? { device } : {}) } } diff --git a/packages/backend/tests/trace-device-roundtrip.test.ts b/packages/backend/tests/trace-device-roundtrip.test.ts new file mode 100644 index 00000000..ee2f67f1 --- /dev/null +++ b/packages/backend/tests/trace-device-roundtrip.test.ts @@ -0,0 +1,119 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { TraceType, type Metadata } from '@wdio/devtools-shared' +import { writeTraceZip } from '@wdio/devtools-trace/trace-exporter' +import { afterEach, describe, expect, it } from 'vitest' + +import { readTraceZip } from '../src/trace-reader.js' + +/** + * The writer lives in `trace` and the reader in `backend`, so no test proved + * they agree about the device — and the device is the whole point: a native + * capture normalizes `browserName` to `chromium` and reports the HOST OS as + * `platform`, so before this field the player had nothing to tell a phone from + * a desktop Chrome, and framed a portrait capture as a desktop window (#347). + */ +const dirs: string[] = [] + +afterEach(async () => { + await Promise.all( + dirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })) + ) +}) + +async function roundTrip(metadata: Metadata): Promise { + const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), 'trace-device-')) + dirs.push(outputDir) + const zip = await writeTraceZip( + { + mutations: [], + traceLogs: [], + consoleLogs: [], + networkRequests: [], + commandsLog: [ + { command: 'click', args: ['~signIn'], timestamp: 2000, id: 1 } + ], + sources: new Map(), + metadata, + startWallTime: 1000 + }, + { outputDir, sessionId: 'abc12345' } + ) + return (await readTraceZip(zip)).trace.metadata +} + +describe('the device a capture was recorded on, written then read back', () => { + it('survives the round trip from a device cloud session', async () => { + const metadata = await roundTrip({ + type: TraceType.Testrunner, + capabilities: { + platformName: 'android', + // Both the serial; only deviceModel is friendly. + deviceName: '28111FDH200CUX', + udid: '28111FDH200CUX', + deviceModel: 'Pixel 7', + platformVersion: '14' + } + }) + + expect(metadata.device).toEqual({ + platform: 'android', + name: 'Pixel 7', + version: '14' + }) + // And the platform is recoverable from capabilities again, which said only + // `chromium` before. + expect(metadata.capabilities).toEqual({ + browserName: 'chromium', + platformName: 'android' + }) + }) + + it('survives it from a local iOS session', async () => { + const metadata = await roundTrip({ + type: TraceType.Testrunner, + capabilities: { + platformName: 'iOS', + 'appium:deviceName': 'iPhone 17', + 'appium:platformVersion': '18.1' + } + }) + + expect(metadata.device).toEqual({ + platform: 'ios', + name: 'iPhone 17', + version: '18.1' + }) + }) + + it('carries a viewport the session measured rather than the fallback', async () => { + // 1080x2219 is what a Pixel 7 answers to getWindowSize — the window minus + // its navigation bar. Without it the zip claimed the exporter's 1280x720. + const metadata = await roundTrip({ + type: TraceType.Testrunner, + capabilities: { platformName: 'android', deviceModel: 'Pixel 7' }, + viewport: { + width: 1080, + height: 2219, + offsetLeft: 0, + offsetTop: 0, + scale: 1 + } + }) + + expect(metadata.viewport?.width).toBe(1080) + expect(metadata.viewport?.height).toBe(2219) + }) + + it('leaves a desktop capture with no device at all', async () => { + const metadata = await roundTrip({ + type: TraceType.Testrunner, + capabilities: { browserName: 'firefox', browserVersion: '145' } + }) + + expect(metadata.device).toBeUndefined() + expect(metadata.capabilities).toEqual({ browserName: 'firefox' }) + }) +}) diff --git a/packages/backend/tests/trace-reader.test.ts b/packages/backend/tests/trace-reader.test.ts index 90573db1..a414ac56 100644 --- a/packages/backend/tests/trace-reader.test.ts +++ b/packages/backend/tests/trace-reader.test.ts @@ -250,6 +250,72 @@ describe('parseTraceZip', () => { }) }) + // Same contract as the runner: the zip states the device, and the reader + // narrows it rather than casting, so a foreign zip cannot smuggle a shape + // through. Without it the player frames a phone as a desktop window. + describe('recording device', () => { + const withDevice = (device: unknown) => + zipSync({ + 'trace.trace': toNdjson([ + { + type: 'context-options', + wallTime: WALL_TIME, + // What a native capture writes: normalized away from the device. + browserName: 'chromium', + contextId: 'context@abcd1234', + options: { viewport: { width: 1080, height: 2219 } }, + ...(device === undefined ? {} : { device }) + } + ]) + }) + + it('restores the device the zip names', () => { + const { trace } = parseTraceZip( + withDevice({ platform: 'ios', name: 'iPhone 17', version: '18.1' }) + ) + + expect(trace.metadata.device).toEqual({ + platform: 'ios', + name: 'iPhone 17', + version: '18.1' + }) + }) + + it('puts the platform back on the rebuilt capabilities', () => { + // browserName alone said `chromium`, so a reader of capabilities had no + // way to tell a phone from a desktop Chrome. + const { trace } = parseTraceZip(withDevice({ platform: 'android' })) + + expect(trace.metadata.capabilities).toEqual({ + browserName: 'chromium', + platformName: 'android' + }) + }) + + it('leaves it unset for a zip recorded without one', () => { + const { trace } = parseTraceZip(withDevice(undefined)) + + expect(trace.metadata.device).toBeUndefined() + expect(trace.metadata.capabilities).toEqual({ browserName: 'chromium' }) + }) + + it('drops a device whose shape does not hold up', () => { + expect( + parseTraceZip(withDevice({ platform: 'windows' })).trace.metadata.device + ).toBeUndefined() + expect( + parseTraceZip(withDevice({ name: 'iPhone 17' })).trace.metadata.device + ).toBeUndefined() + expect( + parseTraceZip(withDevice({ platform: 'ios', name: 17 })).trace.metadata + .device + ).toBeUndefined() + expect( + parseTraceZip(withDevice('iPhone 17')).trace.metadata.device + ).toBeUndefined() + }) + }) + it('restores DOM mutations from a trace.mutations stream, dropping the marker', () => { const mutations = [ { diff --git a/packages/core/src/session-capturer.ts b/packages/core/src/session-capturer.ts index 56b889bb..990276f9 100644 --- a/packages/core/src/session-capturer.ts +++ b/packages/core/src/session-capturer.ts @@ -165,6 +165,22 @@ export abstract class SessionCapturerBase { // no-op } + /** + * Store a metadata fragment AND publish the merged result. The only writer of + * `this.metadata`, which is what the exporter serializes into the zip's + * `context-options` — `sendUpstream` merely transmits, so a value resolved on + * the driver (a native session's viewport, capabilities and device: it has no + * page-side collector to report them) reached a live dashboard and was then + * dropped before the zip. + * + * Merging rather than replacing is what lets a producer contribute the one + * field it knows: a later push naming only a url cannot wipe the device. + */ + mergeMetadata(partial: Partial): void { + this.metadata = { ...this.metadata, ...partial } as Metadata + this.sendUpstream('metadata', this.metadata) + } + /** True once the WS has opened at least once and is currently OPEN. */ isConnected(): boolean { return Boolean(this.ws) && this.ws?.readyState === WebSocket.OPEN @@ -340,11 +356,7 @@ export abstract class SessionCapturerBase { // Page-side trace data is a JS bag; only fields that match Metadata // survive at runtime, but TS can't prove that. Cast to Partial // so the merge stays type-checked while accepting incomplete payloads. - this.metadata = { - ...this.metadata, - ...(metadata as Partial) - } as Metadata - this.sendUpstream('metadata', this.metadata) + this.mergeMetadata(metadata as Partial) } if ( diff --git a/packages/core/tests/session-capturer-base.test.ts b/packages/core/tests/session-capturer-base.test.ts index 5207f1a3..36e769d8 100644 --- a/packages/core/tests/session-capturer-base.test.ts +++ b/packages/core/tests/session-capturer-base.test.ts @@ -47,6 +47,55 @@ describe('processTracePayload — metadata merge', () => { }) }) +/** + * The exporter serializes the capturer's own `metadata`, and before this method + * the only writer of it was `processTracePayload` — i.e. the page-side + * collector. A native session has no collector, so a value resolved on the + * driver reached a live dashboard through `sendUpstream` and was then dropped + * before the zip: that is why a native capture claimed a viewport it had never + * measured. + */ +describe('mergeMetadata — the driver-side writer', () => { + it('stores as well as publishes', () => { + cap.mergeMetadata({ sessionId: 'a', url: 'first' }) + + expect(cap.metadata?.sessionId).toBe('a') + expect(cap.upstream).toEqual([ + { scope: 'metadata', data: { sessionId: 'a', url: 'first' } } + ]) + }) + + it('merges rather than replaces, so a partial cannot wipe a prior field', () => { + cap.mergeMetadata({ device: { platform: 'ios', name: 'iPhone 17' } }) + cap.mergeMetadata({ url: 'https://example.com' }) + + expect(cap.metadata?.device).toEqual({ + platform: 'ios', + name: 'iPhone 17' + }) + expect(cap.metadata?.url).toBe('https://example.com') + }) + + it('publishes the merged bag, not the fragment it was handed', () => { + // The dashboard merges per session too, but sending the whole bag keeps a + // late-joining client from seeing only the last fragment. + cap.mergeMetadata({ sessionId: 'a' }) + cap.mergeMetadata({ url: 'later' }) + + expect(cap.upstream.at(-1)?.data).toEqual({ + sessionId: 'a', + url: 'later' + }) + }) + + it('is the path processTracePayload takes, so both writers agree', () => { + cap.mergeMetadata({ sessionId: 'a' }) + cap.process({ metadata: { url: 'from-the-page' } }) + + expect(cap.metadata).toEqual({ sessionId: 'a', url: 'from-the-page' }) + }) +}) + describe('processTracePayload — BiDi gating (the duplicate-suppression contract)', () => { it('skips consoleLogs/networkRequests entirely when their skip flag is set', () => { cap.process( diff --git a/packages/service/src/index.ts b/packages/service/src/index.ts index 101e3f59..68a89508 100644 --- a/packages/service/src/index.ts +++ b/packages/service/src/index.ts @@ -57,6 +57,7 @@ import { PAGE_TRANSITION_COMMANDS } from './constants.js' import { isNativeMobile } from './mobile.js' +import { resolveSessionMetadata } from './session-metadata.js' import { stampRunnerMetadata } from './wdio-runner-id.js' import { detectInvocationConfigPath } from './standalone.js' @@ -181,7 +182,11 @@ export default class DevToolsHookService implements Services.ServiceInstance { emitManifest: this.#options.emitArtifactsManifest ?? this.#allureReporterConfigured, collectedArtifacts: this.#artifacts, - onArtifact: (a) => this.#artifacts.push(a) + onArtifact: (a) => this.#artifacts.push(a), + // Settled under core's timeout cap before anything is written, so a + // `getWindowSize` left hanging by a tearing-down session degrades to the + // fallback instead of deadlocking the export. + awaitPending: this.#metadataCapture ? [this.#metadataCapture] : [] } } @@ -194,6 +199,12 @@ export default class DevToolsHookService implements Services.ServiceInstance { // This is used to track if the injection script is currently being injected #injecting = false + /** In-flight session-metadata resolution. It runs on the driver, so it lands + * after `before()` returns; finalize has to wait for it or a spec that fails + * immediately exports the fallback viewport and no device. Never rejects, so + * holding it unhandled until finalize attaches cannot crash the run. */ + #metadataCapture?: Promise + async before( caps: Capabilities.W3CCapabilities, __: string[], @@ -248,22 +259,11 @@ export default class DevToolsHookService implements Services.ServiceInstance { await this.#screencast.start(browser) /** - * propagate session metadata at the beginning of the session. - * Skip on mobile — Appium sessions don't have a browser DOM context. + * Propagate session metadata at the beginning of the session. Not awaited + * here, so session start is never held up by a driver round trip — but + * tracked, because finalize must not export before it lands. */ - if (!isNativeMobile(browser)) { - browser - .execute(() => window.visualViewport) - .then((viewport) => - this.#sessionCapturer.sendUpstream('metadata', { - viewport: viewport || undefined, - type: this.captureType, - options: browser.options, - capabilities: browser.capabilities as Capabilities.W3CCapabilities, - runner: this.#sessionCapturer.metadata?.runner - }) - ) - } + this.#metadataCapture = this.#captureSessionMetadata(browser) /** * Runtime DOM snapshot for agent auto-healing loops. Calls into @@ -286,6 +286,21 @@ export default class DevToolsHookService implements Services.ServiceInstance { ) } + /** Resolve what the session can state about itself and both store and publish + * it. Stored, because the exporter serializes the capturer's own metadata: + * `sendUpstream` alone reached a live dashboard and was dropped before the + * zip, which is why a native capture claimed a 1280x720 viewport it never + * measured. */ + async #captureSessionMetadata(browser: WebdriverIO.Browser): Promise { + try { + this.#sessionCapturer.mergeMetadata( + await resolveSessionMetadata(browser, this.captureType) + ) + } catch (err) { + log.warn(`Could not capture session metadata: ${errorMessage(err)}`) + } + } + // The method signature is corrected to use W3CCapabilities beforeSession( config: Options.Testrunner, diff --git a/packages/service/src/session-metadata.ts b/packages/service/src/session-metadata.ts new file mode 100644 index 00000000..c95843c6 --- /dev/null +++ b/packages/service/src/session-metadata.ts @@ -0,0 +1,77 @@ +// Session metadata for the WDIO adapter, resolved at session start. Kept out of +// index.ts (already over the file cap) so the native/desktop split is +// unit-testable and the plugin only forwards its lifecycle hook. + +import logger from '@wdio/logger' +import { + deviceFromCapabilities, + type Metadata, + type TraceType, + type Viewport +} from '@wdio/devtools-shared' +import type { Capabilities } from '@wdio/types' + +import { isNativeMobile } from './mobile.js' + +const log = logger('@wdio/devtools-service') + +/** + * Size of the captured surface. A page reports its own visual viewport; a + * native app has no DOM to ask, so the driver's window size is the only answer + * — measured at 1080x2219 on a Pixel 7, which is the window minus the + * navigation bar. + * + * Metadata only, in both cases: neither number matches the screenshot's own + * pixels (that Pixel 7 shot is 1080x2400, and iOS reports points rather than + * pixels), so anything sizing a captured image measures the image instead. + */ +async function resolveViewport( + browser: WebdriverIO.Browser +): Promise { + try { + if (isNativeMobile(browser)) { + const size = await browser.getWindowSize() + return size + ? { + width: size.width, + height: size.height, + offsetLeft: 0, + offsetTop: 0, + scale: 1 + } + : undefined + } + return (await browser.execute(() => window.visualViewport)) || undefined + } catch (err) { + // A viewport is descriptive, not load-bearing — the capture is still worth + // keeping without it, so this degrades rather than failing the session. + log.warn( + `Could not resolve the session viewport: ${(err as Error).message}` + ) + return undefined + } +} + +/** + * What the session can state about itself. A native session reports the device + * only through its capabilities, so `deviceFromCapabilities` reads it here + * rather than every consumer re-deriving "was this a phone?" downstream. + * + * `runner` is deliberately absent: `stampRunnerMetadata` has already put it on + * the capturer, and the caller merges rather than replaces. + */ +export async function resolveSessionMetadata( + browser: WebdriverIO.Browser, + type: TraceType +): Promise> { + const capabilities = browser.capabilities as Capabilities.W3CCapabilities + const viewport = await resolveViewport(browser) + const device = deviceFromCapabilities(capabilities) + return { + type, + options: browser.options, + capabilities, + ...(viewport ? { viewport } : {}), + ...(device ? { device } : {}) + } +} diff --git a/packages/service/tests/assertion-rows.test.ts b/packages/service/tests/assertion-rows.test.ts index 33176b43..66890162 100644 --- a/packages/service/tests/assertion-rows.test.ts +++ b/packages/service/tests/assertion-rows.test.ts @@ -17,6 +17,7 @@ const capturer = vi.hoisted(() => ({ injectScript: vi.fn().mockResolvedValue(undefined), captureTrace: vi.fn().mockResolvedValue(undefined), sendUpstream: vi.fn(), + mergeMetadata: vi.fn(), cleanup: vi.fn(), resetLastSelector: vi.fn(), resetRetryTracker: vi.fn(), diff --git a/packages/service/tests/index.test.ts b/packages/service/tests/index.test.ts index 3ff2fc03..aa22f87e 100644 --- a/packages/service/tests/index.test.ts +++ b/packages/service/tests/index.test.ts @@ -30,6 +30,7 @@ vi.mock('stack-trace', () => ({ const mockSessionCapturerInstance = { afterCommand: vi.fn(), sendUpstream: vi.fn(), + mergeMetadata: vi.fn(), injectScript: vi.fn().mockResolvedValue(undefined), captureTrace: vi.fn().mockResolvedValue(undefined), resetScriptInjection: vi.fn(), diff --git a/packages/service/tests/session-metadata.test.ts b/packages/service/tests/session-metadata.test.ts new file mode 100644 index 00000000..60aa8057 --- /dev/null +++ b/packages/service/tests/session-metadata.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from 'vitest' + +import { resolveSessionMetadata } from '../src/session-metadata.js' +import { TraceType } from '../src/types.js' + +/** + * A native session answers `getWindowSize` and nothing DOM-shaped; a desktop + * one answers `execute`. The flags are what `isNativeMobile` narrows on. + */ +function browserDouble(overrides: Record = {}) { + return { + capabilities: {}, + options: { hostname: 'localhost' }, + execute: vi.fn().mockResolvedValue({ + width: 1280, + height: 720, + offsetLeft: 0, + offsetTop: 0, + scale: 1 + }), + getWindowSize: vi.fn().mockResolvedValue({ width: 1080, height: 2219 }), + ...overrides + } as unknown as WebdriverIO.Browser +} + +const NATIVE_CAPS = { + platformName: 'Android', + deviceName: '28111FDH200CUX', + udid: '28111FDH200CUX', + deviceModel: 'Pixel 7', + platformVersion: '14' +} + +describe('resolveSessionMetadata', () => { + it("reads a page's own visual viewport on desktop", async () => { + const browser = browserDouble({ capabilities: { browserName: 'chrome' } }) + + const metadata = await resolveSessionMetadata(browser, TraceType.Testrunner) + + expect(metadata.viewport).toEqual({ + width: 1280, + height: 720, + offsetLeft: 0, + offsetTop: 0, + scale: 1 + }) + expect(metadata.capabilities).toEqual({ browserName: 'chrome' }) + expect(metadata.device).toBeUndefined() + expect(browser.getWindowSize).not.toHaveBeenCalled() + }) + + /** + * The measurement in #345: the session was skipped entirely because it reads + * `window.visualViewport` and a native app has no DOM, so the zip fell back + * to the exporter's 1280x720. `getWindowSize` answers 1080x2219 on a Pixel 7. + */ + it('reads the window off the driver on a native session', async () => { + const browser = browserDouble({ + isMobile: true, + isAndroid: true, + capabilities: NATIVE_CAPS + }) + + const metadata = await resolveSessionMetadata(browser, TraceType.Testrunner) + + expect(metadata.viewport).toEqual({ + width: 1080, + height: 2219, + offsetLeft: 0, + offsetTop: 0, + scale: 1 + }) + // Never asked to run script in a session that has no DOM to run it in. + expect(browser.execute).not.toHaveBeenCalled() + }) + + it('states the device a native session reports', async () => { + const browser = browserDouble({ + isMobile: true, + capabilities: NATIVE_CAPS + }) + + const metadata = await resolveSessionMetadata(browser, TraceType.Testrunner) + + expect(metadata.device).toEqual({ + platform: 'android', + name: 'Pixel 7', + version: '14' + }) + }) + + it('keeps the rest of the metadata when the viewport cannot be read', async () => { + const browser = browserDouble({ + isMobile: true, + capabilities: NATIVE_CAPS, + getWindowSize: vi.fn().mockRejectedValue(new Error('no such session')) + }) + + const metadata = await resolveSessionMetadata(browser, TraceType.Testrunner) + + // Descriptive, not load-bearing: the capture is still worth keeping. + expect('viewport' in metadata).toBe(false) + expect(metadata.device?.name).toBe('Pixel 7') + expect(metadata.type).toBe(TraceType.Testrunner) + }) + + it('omits a viewport a desktop page answered as null', async () => { + const browser = browserDouble({ + capabilities: { browserName: 'chrome' }, + execute: vi.fn().mockResolvedValue(null) + }) + + const metadata = await resolveSessionMetadata(browser, TraceType.Testrunner) + + expect('viewport' in metadata).toBe(false) + }) + + it('carries the capture type and the session options through', async () => { + const metadata = await resolveSessionMetadata( + browserDouble(), + TraceType.Standalone + ) + + expect(metadata.type).toBe(TraceType.Standalone) + expect(metadata.options).toEqual({ hostname: 'localhost' }) + }) +}) diff --git a/packages/service/tests/trace-granularity.test.ts b/packages/service/tests/trace-granularity.test.ts index ca99cb7f..f74e4089 100644 --- a/packages/service/tests/trace-granularity.test.ts +++ b/packages/service/tests/trace-granularity.test.ts @@ -36,6 +36,7 @@ vi.mock('stack-trace', () => ({ parse: () => [] })) const mockSessionCapturerInstance = { afterCommand: vi.fn(), sendUpstream: vi.fn(), + mergeMetadata: vi.fn(), injectScript: vi.fn().mockResolvedValue(undefined), captureTrace: vi.fn().mockResolvedValue(undefined), captureAssertCommand: vi.fn(), diff --git a/packages/service/tests/trace-metadata.test.ts b/packages/service/tests/trace-metadata.test.ts index 8715fb42..22607ff5 100644 --- a/packages/service/tests/trace-metadata.test.ts +++ b/packages/service/tests/trace-metadata.test.ts @@ -16,6 +16,7 @@ vi.mock('stack-trace', () => ({ parse: () => [] })) const mockSessionCapturerInstance = { afterCommand: vi.fn(), sendUpstream: vi.fn(), + mergeMetadata: vi.fn(), injectScript: vi.fn().mockResolvedValue(undefined), captureTrace: vi.fn().mockResolvedValue(undefined), captureAssertCommand: vi.fn(), @@ -235,3 +236,99 @@ describe('DevtoolsService - afterTest state stamping', () => { ).toBe('failed') }) }) + +/** + * Session metadata resolves on the driver, so it lands after `before()` has + * returned. A spec that fails immediately therefore finalized while the read + * was still in flight, and the zip got the exporter's fallback viewport and no + * device — the values #345 exists to carry. Finalize has to wait for it. + */ +describe('session metadata vs. an immediately finalizing spec', () => { + const mockBrowser = ( + resolveWindowSize: () => Promise<{ width: number; height: number }> + ) => + ({ + sessionId: 'sess-race', + isMobile: true, + isAndroid: true, + execute: vi.fn().mockResolvedValue(undefined), + getWindowSize: vi.fn(resolveWindowSize), + takeScreenshot: vi.fn().mockResolvedValue('shot'), + getWindowRect: vi.fn().mockResolvedValue({ + width: 1, + height: 1, + offsetLeft: 0, + offsetTop: 0 + }), + on: vi.fn(), + emit: vi.fn(), + addCommand: vi.fn(), + options: { rootDir: '/proj' }, + capabilities: { + platformName: 'android', + deviceName: '28111FDH200CUX', + udid: '28111FDH200CUX', + deviceModel: 'Pixel 7', + platformVersion: '14' + } + }) as never + + beforeEach(() => { + vi.clearAllMocks() + finalizeTraceExport.mockResolvedValue([]) + }) + + it('hands the in-flight read to finalize as pending work', async () => { + let release: (size: { width: number; height: number }) => void = () => {} + const service = new DevToolsHookService({ mode: 'trace' }) + await service.before( + {} as never, + [], + mockBrowser( + () => + new Promise<{ width: number; height: number }>((resolve) => { + release = resolve + }) + ) + ) + + // Finalize while the driver has not answered yet — the race the review + // found. The promise must reach core, which settles it under its own cap. + await service.after() + const ctx = finalizeTraceExport.mock.calls.at(-1)?.[0] as { + awaitPending?: Promise[] + } + expect(ctx.awaitPending).toHaveLength(1) + + release({ width: 1080, height: 2219 }) + await ctx.awaitPending?.[0] + // And once it lands it is STORED, not merely published: the zip reads this. + expect(mockSessionCapturerInstance.mergeMetadata).toHaveBeenCalledWith( + expect.objectContaining({ + viewport: expect.objectContaining({ width: 1080, height: 2219 }), + device: { platform: 'android', name: 'Pixel 7', version: '14' } + }) + ) + }) + + it('never rejects, so an unhandled rejection cannot outrun finalize', async () => { + const service = new DevToolsHookService({ mode: 'trace' }) + await service.before( + {} as never, + [], + mockBrowser(() => Promise.reject(new Error('no such session'))) + ) + await service.after() + + const ctx = finalizeTraceExport.mock.calls.at(-1)?.[0] as { + awaitPending?: Promise[] + } + await expect(ctx.awaitPending?.[0]).resolves.toBeUndefined() + // The viewport is dropped; everything else the session knew survives. + expect(mockSessionCapturerInstance.mergeMetadata).toHaveBeenCalledWith( + expect.objectContaining({ + device: { platform: 'android', name: 'Pixel 7', version: '14' } + }) + ) + }) +}) diff --git a/packages/shared/src/device.ts b/packages/shared/src/device.ts new file mode 100644 index 00000000..cc57bcb9 --- /dev/null +++ b/packages/shared/src/device.ts @@ -0,0 +1,120 @@ +// The device a capture was recorded on. A native mobile session reports this +// only through its capabilities, and every consumer that wants to know "was +// this a phone?" reads it through `deviceFromCapabilities` rather than +// re-deriving it from a heuristic. + +/** Native platforms a session can run on. */ +export const NATIVE_PLATFORMS = ['android', 'ios'] as const + +export type NativePlatform = (typeof NATIVE_PLATFORMS)[number] + +export function isNativePlatform(value: unknown): value is NativePlatform { + return NATIVE_PLATFORMS.includes(value as NativePlatform) +} + +/** The device a capture was recorded on, as far as the session could tell. */ +export interface DeviceInfo { + platform: NativePlatform + /** Friendly model name (`Pixel 7`, `iPhone 17`); absent when the session + * reported only a hardware serial. */ + name?: string + /** OS version the session reported (`18.1`, `16`). */ + version?: string +} + +/** + * Capability keys holding a device's name, in the order that reads correctly on + * real hardware: + * + * - a local run puts the requested name in `appium:deviceName` + * - a device cloud + Android reports the hardware serial as BOTH `deviceName` + * and `udid`, and the friendly name only in `deviceModel` + * - iOS reports a friendly `deviceName` with `udid` separate + * + * so `deviceModel` has to be preferred over `deviceName`, and any candidate + * that merely repeats the serial is rejected — which is what keeps the cloud's + * `deviceName` from winning. + */ +const DEVICE_NAME_KEYS = ['appium:deviceName', 'deviceModel', 'deviceName'] +const SERIAL_KEYS = ['udid', 'appium:udid'] +const PLATFORM_VERSION_KEYS = ['appium:platformVersion', 'platformVersion'] + +function capString( + caps: Record, + key: string +): string | undefined { + const value = caps[key] + return typeof value === 'string' && value.trim() ? value : undefined +} + +function firstCapString( + caps: Record, + keys: string[], + reject: (value: string) => boolean = () => false +): string | undefined { + for (const key of keys) { + const value = capString(caps, key) + if (value && !reject(value)) { + return value + } + } + return undefined +} + +/** + * Read the device out of a session's capabilities, or undefined when the + * session was not a native mobile one. The single reader for this fact: a + * capture states it on the way out (`context-options.device`) and the trace + * reader narrows it on the way back in, so no consumer has to guess. + */ +export function deviceFromCapabilities( + capabilities: unknown +): DeviceInfo | undefined { + if (!capabilities || typeof capabilities !== 'object') { + return undefined + } + const caps = capabilities as Record + const platform = capString(caps, 'platformName')?.toLowerCase() + if (!isNativePlatform(platform)) { + return undefined + } + const serials = SERIAL_KEYS.map((key) => capString(caps, key)).filter( + (value): value is string => value !== undefined + ) + const name = firstCapString(caps, DEVICE_NAME_KEYS, (value) => + serials.includes(value) + ) + const version = firstCapString(caps, PLATFORM_VERSION_KEYS) + return { + platform, + ...(name ? { name } : {}), + ...(version ? { version } : {}) + } +} + +/** + * 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 + * from before the field existed carries nothing — so a reader narrows here + * rather than casting. + */ +export function isDeviceInfo(value: unknown): value is DeviceInfo { + if (!value || typeof value !== 'object') { + return false + } + const candidate = value as Record + return ( + isNativePlatform(candidate.platform) && + (candidate.name === undefined || typeof candidate.name === 'string') && + (candidate.version === undefined || typeof candidate.version === 'string') + ) +} + +/** `iPhone 17 (ios 18.1)` — one label for a device, so every consumer that + * shows it spells it the same way. */ +export function deviceLabel(device: DeviceInfo): string { + const version = device.version ? ` ${device.version}` : '' + return device.name + ? `${device.name} (${device.platform}${version})` + : `${device.platform}${version}` +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6fcdfd15..268d7283 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -4,6 +4,7 @@ export * from './action-mapping.js' export * from './baseline.js' export * from './console.js' +export * from './device.js' export * from './element-scripts.js' export * from './collector.js' export * from './files.js' diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 64ccc423..7fc79a35 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -4,6 +4,8 @@ // these shapes. The backend stores and forwards them. The app consumes them. // See ARCHITECTURE.md §2 and CLAUDE.md §2.1. +import type { DeviceInfo } from './device.js' + export const LOG_LEVELS = [ 'trace', 'debug', @@ -439,6 +441,11 @@ export interface Metadata { * capture emits and the player's locator hint; undefined for a trace zip * recorded before the field existed, or by a foreign tool. */ runner?: TestRunnerId + /** The device this was recorded on, when the session was a native mobile one. + * Read from the session's capabilities through `deviceFromCapabilities`; + * undefined for a desktop capture, and for a zip recorded before the field + * existed or by a foreign tool. */ + device?: DeviceInfo } /** Captured metadata keyed by browser `sessionId` — lets the UI keep each diff --git a/packages/shared/tests/device.test.ts b/packages/shared/tests/device.test.ts new file mode 100644 index 00000000..1350d924 --- /dev/null +++ b/packages/shared/tests/device.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest' + +import { + deviceFromCapabilities, + deviceLabel, + isNativePlatform +} from '../src/device.js' + +/** Capability bags as real sessions report them, per the measurements in #345. */ +const LOCAL_ANDROID = { + platformName: 'Android', + 'appium:deviceName': 'Pixel_7_API_34', + 'appium:platformVersion': '14' +} +/** A device cloud reports the hardware serial as BOTH deviceName and udid; the + * friendly name is only in deviceModel. */ +const CLOUD_ANDROID = { + platformName: 'android', + deviceName: '28111FDH200CUX', + udid: '28111FDH200CUX', + deviceModel: 'Pixel 7', + platformVersion: '14' +} +const IOS = { + platformName: 'iOS', + deviceName: 'iPhone 17', + udid: '00008140-001A2C3D4E5F001E', + 'appium:platformVersion': '18.1' +} +const DESKTOP = { + browserName: 'chrome', + browserVersion: '152', + platform: 'mac' +} + +describe('isNativePlatform', () => { + it('accepts the two native platforms and nothing else', () => { + expect(isNativePlatform('android')).toBe(true) + expect(isNativePlatform('ios')).toBe(true) + expect(isNativePlatform('windows')).toBe(false) + expect(isNativePlatform(undefined)).toBe(false) + }) +}) + +describe('deviceFromCapabilities', () => { + it('is undefined for a desktop session', () => { + expect(deviceFromCapabilities(DESKTOP)).toBeUndefined() + }) + + it('is undefined for input that is no capability bag', () => { + expect(deviceFromCapabilities(undefined)).toBeUndefined() + expect(deviceFromCapabilities('android')).toBeUndefined() + expect(deviceFromCapabilities({})).toBeUndefined() + }) + + it('reads a local run from appium:deviceName', () => { + expect(deviceFromCapabilities(LOCAL_ANDROID)).toEqual({ + platform: 'android', + name: 'Pixel_7_API_34', + version: '14' + }) + }) + + it('prefers deviceModel over a deviceName that repeats the serial', () => { + expect(deviceFromCapabilities(CLOUD_ANDROID)).toEqual({ + platform: 'android', + name: 'Pixel 7', + version: '14' + }) + }) + + it("keeps iOS's friendly deviceName, which is not its udid", () => { + expect(deviceFromCapabilities(IOS)).toEqual({ + platform: 'ios', + name: 'iPhone 17', + version: '18.1' + }) + }) + + it('normalizes the platform case the session reported', () => { + expect(deviceFromCapabilities({ platformName: 'IOS' })).toEqual({ + platform: 'ios' + }) + }) + + it('reports the platform alone when every name is the serial', () => { + expect( + deviceFromCapabilities({ + platformName: 'android', + deviceName: 'R5CT10', + 'appium:deviceName': 'R5CT10', + udid: 'R5CT10' + }) + ).toEqual({ platform: 'android' }) + }) + + it('ignores blank and non-string capability values', () => { + expect( + deviceFromCapabilities({ + platformName: 'ios', + 'appium:deviceName': ' ', + deviceModel: 17, + deviceName: 'iPad Pro' + }) + ).toEqual({ platform: 'ios', name: 'iPad Pro' }) + }) +}) + +describe('deviceLabel', () => { + it('reads as name, platform and version', () => { + expect( + deviceLabel({ platform: 'ios', name: 'iPhone 17', version: '18.1' }) + ).toBe('iPhone 17 (ios 18.1)') + }) + + it('degrades to what the device actually reported', () => { + expect(deviceLabel({ platform: 'android', name: 'Pixel 7' })).toBe( + 'Pixel 7 (android)' + ) + expect(deviceLabel({ platform: 'android', version: '14' })).toBe( + 'android 14' + ) + expect(deviceLabel({ platform: 'android' })).toBe('android') + }) +}) diff --git a/packages/trace/src/trace-exporter.ts b/packages/trace/src/trace-exporter.ts index 083a5745..6f6a7b65 100644 --- a/packages/trace/src/trace-exporter.ts +++ b/packages/trace/src/trace-exporter.ts @@ -8,6 +8,7 @@ import type { ActionSnapshot, CommandLog, ConsoleLog, + DeviceInfo, Metadata, NetworkRequest, ScreencastFrame, @@ -17,7 +18,10 @@ import type { TraceLog, TraceMutation } from '@wdio/devtools-shared' -import { mapCommandToAction } from '@wdio/devtools-shared' +import { + deviceFromCapabilities, + mapCommandToAction +} from '@wdio/devtools-shared' import { buildConsoleEvents, type ConsoleEvent, @@ -120,6 +124,11 @@ interface ContextOptionsEvent { * the user how to resolve a captured locator in their own framework. Absent * when the capture didn't identify itself, and in foreign zips. */ runner?: TestRunnerId + /** Extension field: the device this was recorded on. `browserName` is + * normalized to `chromium` for a native session and `platform` names the + * HOST OS, so without this the device survived only as prose in `title`. + * Absent for a desktop capture and in foreign zips. */ + device?: DeviceInfo } type TraceEvent = @@ -142,22 +151,27 @@ function allocateTraceIds(sessionId?: string): { return { contextId: `context@${idPrefix}`, pageId: `page@${idPrefix}` } } +/** + * A native session's `browserName` stays normalized to `chromium` because a + * standard trace viewer keys its own behaviour off that field and knows no + * mobile platform. That normalization is why the device needs a field of its + * own: with `device` on the event, the name no longer has to carry the fact. + */ function resolveContextNaming(caps: Record | undefined): { browserName: string title: string + device?: DeviceInfo } { - const platformName = - typeof caps?.platformName === 'string' - ? caps.platformName.toLowerCase() - : undefined - const deviceName = - typeof caps?.['appium:deviceName'] === 'string' - ? (caps['appium:deviceName'] as string) - : undefined - if (platformName === 'android' || platformName === 'ios') { + const device = deviceFromCapabilities(caps) + if (device) { + // Title unchanged from before the `device` field existed; it is prose for a + // foreign viewer, and the typed field is what our own consumers read. return { browserName: 'chromium', - title: deviceName ? `${platformName} — ${deviceName}` : platformName + title: device.name + ? `${device.platform} — ${device.name}` + : device.platform, + device } } const browserName = @@ -172,7 +186,7 @@ function buildContextOptions( ): ContextOptionsEvent { const caps = trace.metadata.capabilities as Record | undefined - const { browserName, title } = resolveContextNaming(caps) + const { browserName, title, device } = resolveContextNaming(caps) const viewport = trace.metadata.viewport ?? { width: 1280, height: 720 } return { version: TRACE_VERSION, @@ -195,7 +209,13 @@ function buildContextOptions( options: { viewport: { width: viewport.width, height: viewport.height } }, - ...(trace.metadata.runner ? { runner: trace.metadata.runner } : {}) + ...(trace.metadata.runner ? { runner: trace.metadata.runner } : {}), + // A capture states its own device; the metadata's is preferred over one + // re-derived here, because an adapter may know the device from a source + // its capabilities never carried. + ...((trace.metadata.device ?? device) + ? { device: trace.metadata.device ?? device } + : {}) } } diff --git a/packages/trace/tests/trace-exporter.test.ts b/packages/trace/tests/trace-exporter.test.ts index 7c642e42..fe44bcc9 100644 --- a/packages/trace/tests/trace-exporter.test.ts +++ b/packages/trace/tests/trace-exporter.test.ts @@ -625,4 +625,59 @@ describe('exported trace stream — context-options runner', () => { expect('runner' in ctx).toBe(false) }) + + /** + * `browserName` is normalized to `chromium` for a native session and + * `platform` names the HOST OS, so before this field the device survived only + * as prose inside `title` and every consumer re-derived it from a heuristic. + */ + describe('the device it was recorded on', () => { + const CLOUD_ANDROID = { + platformName: 'android', + deviceName: '28111FDH200CUX', + udid: '28111FDH200CUX', + deviceModel: 'Pixel 7', + platformVersion: '14' + } + + it('states the device as a typed field, not only in the title', async () => { + const ctx = await contextOptions({ + type: TraceType.Testrunner, + capabilities: CLOUD_ANDROID + }) + + expect(ctx.device).toEqual({ + platform: 'android', + name: 'Pixel 7', + version: '14' + }) + // Unchanged: a standard trace viewer keys its behaviour off browserName + // and knows no mobile platform. + expect(ctx.browserName).toBe('chromium') + // Unchanged prose, so an older viewer reads exactly what it always did. + expect(ctx.title).toBe('android — Pixel 7') + }) + + it('prefers a device the capture resolved over one re-derived here', async () => { + // An adapter may know the device from a source its capabilities never + // carried, so metadata.device wins. + const ctx = await contextOptions({ + type: TraceType.Testrunner, + capabilities: CLOUD_ANDROID, + device: { platform: 'ios', name: 'iPhone 17' } + }) + + expect(ctx.device).toEqual({ platform: 'ios', name: 'iPhone 17' }) + }) + + it('omits the field entirely for a desktop capture', async () => { + const ctx = await contextOptions({ + type: TraceType.Testrunner, + capabilities: { browserName: 'firefox' } + }) + + expect('device' in ctx).toBe(false) + expect(ctx.browserName).toBe('firefox') + }) + }) })