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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/native-capture-states-its-device.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions packages/app/src/components/workbench/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
45 changes: 45 additions & 0 deletions packages/app/test-ui/workbench/panels/metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/backend/src/trace-reader-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
13 changes: 11 additions & 2 deletions packages/backend/src/trace-reader-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { strFromU8 } from 'fflate'
import { sourceResourceName } from '@wdio/devtools-trace/trace-sources'
import {
isDeviceInfo,
isTestRunnerId,
TraceType,
type ConsoleLog,
Expand Down Expand Up @@ -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 } : {})
}
}
119 changes: 119 additions & 0 deletions packages/backend/tests/trace-device-roundtrip.test.ts
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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' })
})
})
66 changes: 66 additions & 0 deletions packages/backend/tests/trace-reader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down
22 changes: 17 additions & 5 deletions packages/core/src/session-capturer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Metadata>): 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
Expand Down Expand Up @@ -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<Metadata>
// so the merge stays type-checked while accepting incomplete payloads.
this.metadata = {
...this.metadata,
...(metadata as Partial<Metadata>)
} as Metadata
this.sendUpstream('metadata', this.metadata)
this.mergeMetadata(metadata as Partial<Metadata>)
}

if (
Expand Down
Loading
Loading