diff --git a/.changeset/frame-a-device-capture.md b/.changeset/frame-a-device-capture.md
new file mode 100644
index 00000000..acfedecb
--- /dev/null
+++ b/.changeset/frame-a-device-capture.md
@@ -0,0 +1,11 @@
+---
+"@wdio/devtools-app": minor
+---
+
+Frame a capture that came off a device as the device, not as a desktop browser window. A mobile trace rendered inside the desktop chrome — traffic lights, and an address bar reading `unknown`, since a native app has no url — with the phone left as a narrow strip in the middle of a landscape frame. Measured on a 1170x2532 capture: the image used 162 px of a 388 px frame and the remaining ~60% was backdrop.
+
+The frame is now shaped from the capture's own decoded pixels, and its header states the device instead of drawing window furniture that describes nothing. The capture's pixels are the only workable source: the metadata viewport disagrees with the screenshot on both platforms, since Android reports the window without the navigation bar and iOS reports points, so an older binary on an iPhone 17 reports a 390x844 window for a 402x874 screen. The frame's own header and padding are taken off before fitting and added back after, or the capture area comes out short by them and the image letterboxes inside a frame that was supposed to be its shape.
+
+Only the screenshot branch is reframed. A mobile *browser* session — Appium driving Chrome on Android — reports a device and also carries a DOM, and that replay is an iframe laid out at its own captured viewport; shaping the frame to a screenshot as well would fight that sizing for the same box, and such a session is a real browser with a real url, so the browser chrome stays honest there. A desktop capture is untouched.
+
+Reads the `device` field added in #345, and the decoded-size helper added in #344.
diff --git a/packages/app/src/components/browser/device-frame.ts b/packages/app/src/components/browser/device-frame.ts
new file mode 100644
index 00000000..5fca3c43
--- /dev/null
+++ b/packages/app/src/components/browser/device-frame.ts
@@ -0,0 +1,100 @@
+// The player's frame for a capture that came off a device. A native session has
+// no browser window and no url, so the desktop chrome — traffic lights and an
+// address bar reading `unknown` — describes nothing, and a landscape frame
+// leaves a portrait capture as a narrow strip in the middle of it.
+//
+// Kept out of snapshot.ts (already over the file cap) so the geometry is a pure
+// function the specs can measure without a browser.
+
+import type { DeviceInfo, ImageSize } from '@wdio/devtools-shared'
+import { deviceLabel } from '@wdio/devtools-shared'
+import { html, type nothing, type TemplateResult } from 'lit'
+
+/**
+ * Padding plus border across one axis. Both come out of the size set on a
+ * border-box element, so a frame sized without the border hands its capture an
+ * area smaller than the one it was fitted for.
+ */
+export function edgeInset(
+ style: CSSStyleDeclaration,
+ from: 'Left' | 'Top',
+ to: 'Right' | 'Bottom'
+): number {
+ const read = (value: string) => parseFloat(value || '0') || 0
+ return (
+ read(style[`padding${from}` as 'paddingLeft']) +
+ read(style[`padding${to}` as 'paddingRight']) +
+ read(style[`border${from}Width` as 'borderLeftWidth']) +
+ read(style[`border${to}Width` as 'borderRightWidth'])
+ )
+}
+
+/** What the frame spends on itself before the capture gets any room. */
+export interface FrameChrome {
+ /** Height of the frame's header — furniture above the capture. */
+ headerHeight: number
+ /** The frame's own horizontal padding and border. */
+ insetX: number
+ /** The frame's own vertical padding and border. */
+ insetY: number
+}
+
+/**
+ * Size for a frame holding `capture`, so the frame is the shape of the device
+ * rather than of the pane. Derived from the capture's own decoded pixels: the
+ * metadata viewport cannot serve, because it disagrees with the screenshot on
+ * both mobile platforms — Android reports the window without the navigation
+ * bar, and iOS reports points, so an older binary on an iPhone 17 says 390x844
+ * of a 402x874 screen.
+ *
+ * The frame's own furniture is taken off before fitting and added back after.
+ * Fitting the whole frame to the capture instead leaves the capture AREA short
+ * by the header and the padding, so the image letterboxes inside a frame that
+ * was supposed to be its shape.
+ */
+export function deviceFrameSize(
+ pane: { width: number; height: number },
+ capture: ImageSize,
+ chrome: FrameChrome
+): { width: number; height: number } {
+ const usableWidth = pane.width - chrome.insetX
+ const usableHeight = pane.height - chrome.insetY - chrome.headerHeight
+ if (
+ !capture.width ||
+ !capture.height ||
+ usableWidth <= 0 ||
+ usableHeight <= 0
+ ) {
+ return { width: pane.width, height: pane.height }
+ }
+ const scale = Math.min(
+ usableWidth / capture.width,
+ usableHeight / capture.height
+ )
+ return {
+ width: Math.round(capture.width * scale) + chrome.insetX,
+ height:
+ Math.round(capture.height * scale) + chrome.headerHeight + chrome.insetY
+ }
+}
+
+/**
+ * The frame's header for a device capture: what it was recorded on, in place of
+ * window furniture that does not apply. Keeps the view-toggle slot, which is
+ * how the Snapshot/Screencast switch stays reachable.
+ */
+export function renderDeviceChrome(
+ device: DeviceInfo,
+ viewToggle: TemplateResult | typeof nothing
+): TemplateResult {
+ return html`
+
+ `
+}
diff --git a/packages/app/src/components/browser/snapshot-styles.ts b/packages/app/src/components/browser/snapshot-styles.ts
index c08f4650..9e74e324 100644
--- a/packages/app/src/components/browser/snapshot-styles.ts
+++ b/packages/app/src/components/browser/snapshot-styles.ts
@@ -63,6 +63,24 @@ export const snapshotStyles = css`
border-radius: 0 0 14px 14px;
}
+ /* Device frame: the header states what the capture came off, in place of an
+ address bar a native session could only have filled with "unknown".
+ NB: no backticks in this file — the rules live in a tagged template. */
+ .device-chrome {
+ padding: 0.45rem 0.25rem;
+ gap: 0.5rem;
+ }
+
+ .device-label {
+ flex: 1;
+ min-width: 0;
+ padding-left: 0.5rem;
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ color: var(--vscode-descriptionForeground, #ccc);
+ }
+
.screenshot-overlay {
position: absolute;
inset: 0;
diff --git a/packages/app/src/components/browser/snapshot.ts b/packages/app/src/components/browser/snapshot.ts
index 19c80c54..93d5a399 100644
--- a/packages/app/src/components/browser/snapshot.ts
+++ b/packages/app/src/components/browser/snapshot.ts
@@ -4,6 +4,11 @@ import { html, nothing } from 'lit'
import { consume } from '@lit/context'
import { snapshotStyles } from './snapshot-styles.js'
import { renderBrowserChrome } from './browser-chrome.js'
+import {
+ deviceFrameSize,
+ edgeInset,
+ renderDeviceChrome
+} from './device-frame.js'
import {
drawElementOverlay,
clearElementOverlay,
@@ -21,7 +26,12 @@ import type { SimplifiedVNode } from '@wdio/devtools-script/types'
// characterData wire shape (parent ref + child index), so the replay reads it
// from the same declaration that produces it.
import type { TextMutation } from '@wdio/devtools-script/mutations.js'
-import { imageMime, type CommandLog } from '@wdio/devtools-shared'
+import {
+ imageDimensions,
+ imageMime,
+ type CommandLog,
+ type ImageSize
+} from '@wdio/devtools-shared'
import {
mutationContext,
@@ -29,7 +39,11 @@ import {
metadataBySessionContext,
commandContext
} from '../../controller/context.js'
-import type { Metadata, MetadataBySession } from '@wdio/devtools-shared'
+import type {
+ DeviceInfo,
+ Metadata,
+ MetadataBySession
+} from '@wdio/devtools-shared'
import '../placeholder.js'
import './screencast-player.js'
@@ -157,6 +171,48 @@ export class DevtoolsBrowser extends Element {
}
}
+ #captureShape?: { screenshot: string; size: ImageSize | null }
+
+ /** Shape of the capture on screen, from its own decoded pixels. Memoized on
+ * the frame it was read from, because this is read per render and per rAF. */
+ get #captureSize(): ImageSize | null {
+ const screenshot = this.#screenshotData ?? this.#latestAutoScreenshot
+ if (!screenshot) {
+ return null
+ }
+ if (this.#captureShape?.screenshot !== screenshot) {
+ this.#captureShape = { screenshot, size: imageDimensions(screenshot) }
+ }
+ return this.#captureShape.size
+ }
+
+ /**
+ * The device a capture was recorded on, once there is an image to shape the
+ * frame around — a mobile BROWSER session (Appium driving Chrome on Android)
+ * reports a device too, and must keep the browser frame. Two conditions
+ * separate them, because either alone is decidable too late:
+ *
+ * - no DOM. That replay is an iframe laid out at its own captured viewport,
+ * so shaping the frame to a screenshot would fight
+ * `#sizeSnapshotToViewport` for the same box. It is the same signal
+ * `#renderViewport` branches on, so the frame can never disagree with what
+ * is inside it — but mutations arrive in batches, so early in a live run a
+ * browser session has none yet.
+ * - no url ON THE METADATA. A native session never issues a navigation, so
+ * it never reports one, while a browser session reports one from its first
+ * navigation — which lands before any DOM batch and closes that window.
+ * Deliberately not `#displayUrl`: that is a display concern, resolved from
+ * the selected command and empty until one is selected.
+ */
+ get #deviceCapture(): { device: DeviceInfo; size: ImageSize } | null {
+ if (this.mutations?.length || this.metadata?.url) {
+ return null
+ }
+ const device = this.metadata?.device
+ const size = device ? this.#captureSize : null
+ return device && size ? { device, size } : null
+ }
+
#setIframeSize() {
if (!this.section || !this.header) {
return
@@ -170,9 +226,62 @@ export class DevtoolsBrowser extends Element {
this.section.style.height = '100%'
return
}
+ if (this.#deviceCapture) {
+ this.#sizeSectionToDevice()
+ return
+ }
this.#sizeSnapshotToViewport()
}
+ /** Shape the frame to the device rather than to the pane. A native capture
+ * reaches the screenshot branch, which fills whatever box it is given — so
+ * in a landscape frame a portrait capture was a narrow strip with ~60% of
+ * the frame backdrop, wrapped in window furniture describing nothing. */
+ #sizeSectionToDevice() {
+ requestAnimationFrame(() => {
+ const capture = this.#deviceCapture
+ // The mode can flip between scheduling and firing: `updated()` re-sizes
+ // on every view-mode change and the video branch is synchronous, so an
+ // unguarded callback lands AFTER it and pins the screencast inside a
+ // phone-shaped box until the next resize.
+ if (
+ !this.section ||
+ !this.header ||
+ !capture ||
+ this.#viewMode === 'video'
+ ) {
+ return
+ }
+ const hostStyle = getComputedStyle(this)
+ const rect = this.getBoundingClientRect()
+ const padX =
+ parseFloat(hostStyle.paddingLeft || '0') +
+ parseFloat(hostStyle.paddingRight || '0')
+ const padY =
+ parseFloat(hostStyle.paddingTop || '0') +
+ parseFloat(hostStyle.paddingBottom || '0')
+ const sectionStyle = getComputedStyle(this.section)
+ const frame = deviceFrameSize(
+ {
+ width: Math.max(0, rect.width - padX),
+ height: Math.max(0, rect.height - padY)
+ },
+ capture.size,
+ {
+ headerHeight: this.header.getBoundingClientRect().height,
+ // Padding AND border: the section is border-box, so both come out of
+ // the width and height set on it. Omitting the 2px border left the
+ // capture area 4px short per axis and letterboxed it inside a frame
+ // that was supposed to be its shape.
+ insetX: edgeInset(sectionStyle, 'Left', 'Right'),
+ insetY: edgeInset(sectionStyle, 'Top', 'Bottom')
+ }
+ )
+ this.section.style.width = `${frame.width}px`
+ this.section.style.height = `${frame.height}px`
+ })
+ }
+
#sizeSnapshotToViewport() {
const metadata = this.metadata
if (!this.section || !this.header || !metadata) {
@@ -940,12 +1049,19 @@ export class DevtoolsBrowser extends Element {
`
diff --git a/packages/app/test-ui/workbench/player/fixtures.ts b/packages/app/test-ui/workbench/player/fixtures.ts
index 1cda87df..344c1b20 100644
--- a/packages/app/test-ui/workbench/player/fixtures.ts
+++ b/packages/app/test-ui/workbench/player/fixtures.ts
@@ -557,6 +557,17 @@ export const metadataForViewport = (
viewport: { ...viewport, width, height }
})
+/**
+ * A native mobile capture: it states its device, and its viewport is the one a
+ * device really reports — iOS in POINTS, deliberately disagreeing with the
+ * 120x260 frames, which is why the frame is shaped by the decoded image.
+ */
+export const deviceMetadata: Metadata = {
+ type: TraceType.Testrunner,
+ device: { platform: 'ios', name: 'iPhone 17', version: '18.1' },
+ viewport: { ...viewport, width: 402, height: 874 }
+}
+
/** Metadata whose viewport never made it onto the wire — the race the player
* defaults for. */
export const viewportlessMetadata: Metadata = { type: TraceType.Testrunner }
diff --git a/packages/app/test-ui/workbench/player/snapshot.test.ts b/packages/app/test-ui/workbench/player/snapshot.test.ts
index 4caff460..c49f2380 100644
--- a/packages/app/test-ui/workbench/player/snapshot.test.ts
+++ b/packages/app/test-ui/workbench/player/snapshot.test.ts
@@ -44,6 +44,7 @@ import {
} from './captured-pages.js'
import {
CAPTURED_VIEWPORT,
+ deviceMetadata,
domlessTrace,
landscapeTrace,
LOGIN_SHOT,
@@ -54,6 +55,7 @@ import {
metadataForViewport,
orphanTrace,
overlayLabelTrace,
+ PORTRAIT_CAPTURE,
portraitTrace,
preCaptureTrace,
RECORDING,
@@ -1519,6 +1521,117 @@ describe('wdio-devtools-browser', () => {
expect(getComputedStyle(img).objectFit).toBe('contain')
})
+ /**
+ * A native capture has no browser window and no url, so the desktop
+ * chrome describes nothing and a landscape frame left the phone as a
+ * narrow strip: measured on a 1170x2532 capture, the image used 162px of
+ * a 388px frame and ~60% was backdrop.
+ */
+ describe('a capture the trace says came off a device', () => {
+ const DEVICE_CHROME = 'header.device-chrome'
+ const DEVICE_LABEL = '.device-label'
+ const FRAME_DOT = '.frame-dot'
+ /** The address bar's own icon — `header .truncate` also matches the
+ * device label, so it cannot tell the two chromes apart. */
+ const URL_AFFORDANCE = 'header icon-mdi-world, header icon-mdi-lock'
+
+ const framed = () =>
+ mountBrowser({ ...portraitTrace, metadata: deviceMetadata })
+
+ it('names the device instead of drawing window furniture', async () => {
+ const el = await framed()
+ await settle(el)
+
+ expect(text(shadow(el, DEVICE_LABEL))).toBe('iPhone 17 (ios 18.1)')
+ // The furniture that does not apply: no traffic lights, and no url
+ // affordance, which could only have read `unknown`.
+ expect(shadowAll(el, FRAME_DOT)).toHaveLength(0)
+ expect(shadowAll(el, URL_AFFORDANCE)).toHaveLength(0)
+ })
+
+ it('keeps the view toggle reachable', async () => {
+ // Losing the slot would strand the Snapshot/Screencast switch, which
+ // only appears once a recording has arrived.
+ const el = await framed()
+ recordingArrives()
+ await settle(el)
+
+ expect(texts(el, VIEW_BUTTON)).toEqual(['Snapshot', 'Screencast'])
+ expect(
+ shadow(el, DEVICE_CHROME)?.querySelector('.view-toggle')
+ ).toBeTruthy()
+ })
+
+ it('shapes the frame to the capture, not to the pane', async () => {
+ const el = await framed()
+ await settle(el)
+ await resizeScreenshotPane(el, ...paneFor(el, { w: 400, h: 300 }))
+ const section = shadow(el, 'section')!
+ await waitUntil(
+ () => section.style.width !== '',
+ 'the frame to be sized to the capture'
+ )
+
+ // Measured, not derived: the wrapper's own rect IS the box the
+ // capture gets, so this holds whatever the frame spends on padding,
+ // border and header. Deriving it from padding alone missed the 2px
+ // border and let the image letterbox by 4px per axis.
+ const captureBox = shadow(
+ el,
+ '.iframe-wrapper'
+ )!.getBoundingClientRect()
+ expect(captureBox.width / captureBox.height).toBeCloseTo(
+ PORTRAIT_CAPTURE.width / PORTRAIT_CAPTURE.height,
+ 2
+ )
+ // ...and the frame is the shape of that box plus its furniture, not
+ // the 400px-wide pane it used to span.
+ expect(section.getBoundingClientRect().width).toBeLessThan(300)
+ })
+
+ it('hands the screencast back its own sizing when the mode flips', async () => {
+ const el = await framed()
+ recordingArrives()
+ await settle(el)
+ const section = shadow(el, 'section')!
+
+ // Leave a device-sizing frame in flight, then switch modes before it
+ // runs: `updated()` re-sizes on every flip and the video branch is
+ // synchronous, so an unguarded callback lands after it.
+ window.dispatchEvent(new Event('resize'))
+ shadowAll(el, VIEW_BUTTON)[1].click()
+ await settle(el)
+ await new Promise((resolve) => requestAnimationFrame(resolve))
+
+ expect(section.style.width).toBe('100%')
+ expect(section.style.height).toBe('100%')
+ })
+
+ it('keeps the browser frame for a device session that has a url', async () => {
+ // A mobile browser — Appium driving Chrome on Android — reports a
+ // device too, and its url arrives before any DOM batch.
+ const el = await mountBrowser({
+ ...portraitTrace,
+ metadata: { ...deviceMetadata, url: LOGIN_URL }
+ })
+ await settle(el)
+
+ expect(shadowAll(el, DEVICE_CHROME)).toHaveLength(0)
+ expect(shadowAll(el, FRAME_DOT)).toHaveLength(3)
+ // The address bar itself stays empty until a command is selected —
+ // it reads the navigation active at that command, not the metadata.
+ expect(shadowAll(el, URL_AFFORDANCE).length).toBeGreaterThan(0)
+ })
+
+ it('leaves a trace with no device in the browser frame', async () => {
+ const el = await mountBrowser(portraitTrace)
+ await settle(el)
+
+ expect(shadowAll(el, DEVICE_CHROME)).toHaveLength(0)
+ expect(shadowAll(el, FRAME_DOT)).toHaveLength(3)
+ })
+ })
+
it('holds a capture wider than the pane inside it too', async () => {
const el = await mountBrowser(landscapeTrace)
await settle(el)
diff --git a/packages/app/tests/device-frame.test.ts b/packages/app/tests/device-frame.test.ts
new file mode 100644
index 00000000..961d1768
--- /dev/null
+++ b/packages/app/tests/device-frame.test.ts
@@ -0,0 +1,144 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ deviceFrameSize,
+ edgeInset
+} from '../src/components/browser/device-frame.js'
+
+/** The capture from the issue's measurement, and a Pixel 7 for the other shape. */
+const IPHONE = { width: 1170, height: 2532 }
+const PIXEL = { width: 1080, height: 2400 }
+/** The player's own furniture: a header, and the frame's 0.5rem padding. */
+const CHROME = { headerHeight: 40, insetX: 16, insetY: 16 }
+const BARE = { headerHeight: 0, insetX: 0, insetY: 0 }
+
+/** Shape of the box the capture actually gets, which is the point of all this. */
+const captureShape = (
+ frame: { width: number; height: number },
+ chrome = CHROME
+) =>
+ (frame.width - chrome.insetX) /
+ (frame.height - chrome.headerHeight - chrome.insetY)
+
+describe('deviceFrameSize', () => {
+ it('gives the capture a box of its own shape, not the pane’s', () => {
+ // The measured case: a 388px-wide landscape pane left the image using
+ // 162px, the rest backdrop, because the frame was the pane's shape.
+ const frame = deviceFrameSize({ width: 388, height: 420 }, IPHONE, CHROME)
+
+ // Precision 2: the width is rounded to a whole pixel, which on a ~150px
+ // frame moves the ratio by ~0.0005.
+ expect(captureShape(frame)).toBeCloseTo(IPHONE.width / IPHONE.height, 2)
+ // Height-bound here, so the frame is only as wide as the capture needs —
+ // far narrower than the 388px pane it used to span.
+ expect(frame.height).toBe(420)
+ expect(frame.width).toBeLessThan(200)
+ })
+
+ it('takes the furniture off before fitting and adds it back after', () => {
+ const framed = deviceFrameSize({ width: 1000, height: 400 }, PIXEL, CHROME)
+ const bare = deviceFrameSize(
+ {
+ width: 1000 - CHROME.insetX,
+ height: 400 - CHROME.insetY - CHROME.headerHeight
+ },
+ PIXEL,
+ BARE
+ )
+
+ // Same capture area either way: the header and padding are spent on top of
+ // the capture's box, not taken out of it.
+ expect(framed.width - CHROME.insetX).toBe(bare.width)
+ expect(framed.height - CHROME.headerHeight - CHROME.insetY).toBe(
+ bare.height
+ )
+ })
+
+ it('is bounded by the width when that is what constrains it', () => {
+ const frame = deviceFrameSize({ width: 136, height: 4000 }, PIXEL, CHROME)
+
+ expect(frame.width).toBe(136)
+ expect(captureShape(frame)).toBeCloseTo(PIXEL.width / PIXEL.height, 2)
+ })
+
+ it('keeps a landscape capture landscape', () => {
+ const frame = deviceFrameSize(
+ { width: 816, height: 856 },
+ { width: 1280, height: 800 },
+ CHROME
+ )
+
+ expect(frame.width).toBe(816)
+ expect(captureShape(frame)).toBeCloseTo(1280 / 800, 2)
+ })
+
+ it('falls back to the whole pane when there is nothing to fit', () => {
+ expect(
+ deviceFrameSize(
+ { width: 400, height: 300 },
+ { width: 0, height: 0 },
+ CHROME
+ )
+ ).toEqual({ width: 400, height: 300 })
+ // A pane with no room left once the furniture is paid for.
+ expect(
+ deviceFrameSize(
+ { width: 400, height: CHROME.headerHeight },
+ IPHONE,
+ CHROME
+ )
+ ).toEqual({ width: 400, height: CHROME.headerHeight })
+ expect(
+ deviceFrameSize({ width: CHROME.insetX, height: 900 }, IPHONE, CHROME)
+ ).toEqual({ width: CHROME.insetX, height: 900 })
+ })
+})
+
+/**
+ * The section is `box-sizing: border-box` with a 2px border, so the border
+ * comes out of the size set on it exactly as the padding does. Covered here
+ * rather than in the component spec: the harness does not apply the Tailwind
+ * utility that draws that border, so it measures 0px there and cannot tell a
+ * frame that accounts for it from one that does not.
+ */
+describe('edgeInset', () => {
+ /** Only the longhands `edgeInset` reads — a real declaration carries ~340. */
+ const style = (values: Record) =>
+ values as unknown as CSSStyleDeclaration
+
+ it('sums padding and border across the axis', () => {
+ const declaration = style({
+ paddingLeft: '8px',
+ paddingRight: '8px',
+ borderLeftWidth: '2px',
+ borderRightWidth: '2px'
+ })
+
+ // 16 of padding + 4 of border. Reading padding alone left the capture area
+ // 4px short per axis and letterboxed it inside its own frame.
+ expect(edgeInset(declaration, 'Left', 'Right')).toBe(20)
+ })
+
+ it('reads the vertical axis from the vertical longhands', () => {
+ const declaration = style({
+ paddingTop: '8px',
+ paddingBottom: '4px',
+ borderTopWidth: '2px',
+ borderBottomWidth: '1px'
+ })
+
+ expect(edgeInset(declaration, 'Top', 'Bottom')).toBe(15)
+ })
+
+ it('treats an absent or non-numeric edge as zero', () => {
+ expect(edgeInset(style({}), 'Left', 'Right')).toBe(0)
+ // A computed border-width reads `medium` when the style is `none`.
+ expect(
+ edgeInset(
+ style({ paddingLeft: '8px', borderLeftWidth: 'medium' }),
+ 'Left',
+ 'Right'
+ )
+ ).toBe(8)
+ })
+})