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
16 changes: 16 additions & 0 deletions .changeset/follow-the-runtime-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@wdio/devtools-service": patch
"@wdio/devtools-app": patch
---

Capture a hybrid app's webview half as a page, and frame a mobile capture as a device in the trace player as well as live.

**Following the context.** Document availability was answered from the startup capabilities and never revisited, so a session that switched into a webview was still treated as native: the collector injection, the DOM drain and the `__wdioSnapMark` tag stayed skipped, and its per-action snapshot read a real HTML document through the page-source XML reader. `sessionHasDocument(capabilities, context)` in shared is now the question a capture guard asks; `isNativeAppSession` remains the capability-level answer for what genuinely cannot change. Anything that is not Appium's `NATIVE_APP` counts as a webview, because the `WEBVIEW_` prefix is a convention and a driver naming its webview otherwise would have its capture skipped. Following it costs no round trip: `switchContext` carries the context it moves to in its own arguments, and a switch that failed is ignored.

Verified on a real hybrid app (Appium's ApiDemos on an Android emulator): the webview action is exported with a page snapshot — `[Page: I am a page title — file:///android_asset/html/index.html]`, a heading, a link and a working locator — where the native actions on either side stay `[android] hierarchy FrameLayout…`.

**Per-action snapshots are skipped where Appium has a document to probe, and nowhere else.** They are issued from inside the command hook, and Appium serialises a probe behind the command it is observing: a hybrid trace run measured the DIRECT transport timing out exactly as `browser.execute` had, so the serialisation is Appium's own and going round the client cannot escape it. That run spent 2m6s hitting timeouts where the same spec takes 34s untouched.

The IN-PAGE probes are what hang, though, so the gate asks whether a document is in play rather than whether the driver is Appium. A native session passes no `runScript` at all — page source and a screenshot only — and completes fine: measured on an Android emulator, 13.6s against 5.5s with the capture skipped, no timeout. Gating on the driver instead left every native trace with **one** snapshot for the whole run, the one taken at its end, so all eleven actions of a sample spec replayed the final frame; it now carries ten, one per action. Because the context answers the question, a hybrid app is judged by the half it is currently in: its webview actions are still skipped, and those are the ones that carry no per-action element data, accessibility tree or settle screenshot. Command rows and their screenshots, console, network and the archive itself are unaffected, as is every desktop session.

**The device column now applies in both modes.** It was live-only, on the reasoning that the player's own layout worked — but the player had never actually rendered one: `#deviceCapture` requires a measurable image, and both of its sources read `command.screenshot`, which a trace's commands never carry. So a native trace was framed as a desktop browser. The player now falls back to the recorded viewport when there is no screenshot to measure — second, not first, because a native screenshot's pixels and its window size genuinely differ. In that layout the capture takes a full-height column with the action list and the dock stacked beside it, and the playback controls ride above the capture.
25 changes: 24 additions & 1 deletion packages/app/src/components/browser/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,30 @@ export class DevtoolsBrowser extends Element {
get #captureSize(): ImageSize | null {
const screenshot = this.#screenshotData ?? this.#latestAutoScreenshot
if (!screenshot) {
return null
// A REPLAYED capture has no screenshot to measure: both sources above
// read `command.screenshot`, and a trace's commands carry none — its
// images are separate resources. Without a fallback no native trace
// could ever draw device chrome, because the shape was undecidable, and
// the player framed a phone as a desktop browser.
//
// Only a PORTRAIT viewport is trusted for it. The reader substitutes a
// synthetic 1280x720 for a trace that recorded no viewport, and that
// shape is indistinguishable from a real one here — so a capture whose
// geometry was never measured would be drawn as a landscape,
// desktop-proportioned "device". Landscape costs nothing to refuse:
// `#deviceLayout` already sends that shape to the stacked layout, so a
// frame is not wanted there either.
//
// Still a fallback, not the primary: a native screenshot's pixels and
// its window size genuinely differ (a Pixel reports 1080x2219 for a
// 1080x2400 shot), so the image is the truer answer when there is one.
const viewport = this.metadata?.viewport
const portrait = Boolean(
viewport?.width && viewport?.height && viewport.height > viewport.width
)
return portrait
? { width: viewport!.width, height: viewport!.height }
: null
Comment thread
vishnuv688 marked this conversation as resolved.
}
if (this.#captureShape?.screenshot !== screenshot) {
this.#captureShape = { screenshot, size: imageDimensions(screenshot) }
Expand Down
140 changes: 126 additions & 14 deletions packages/app/src/components/workbench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,12 @@ export class DevtoolsWorkbench extends Element {
floor is min-content unless this is set, so switching to a wide tab
(the Network table) grew it and shoved the device column sideways —
only the drag handle may move that boundary. Scoped here rather than as
a utility class so it holds wherever the shadow root is styled from. */
section[data-device-row] > wdio-devtools-tabs {
a utility class so it holds wherever the shadow root is styled from.

A DESCENDANT selector, not a child one: the dock sits a level deeper
now that the action list and the dock share a column beside the
capture, and as a direct-child rule this silently stopped applying. */
section[data-device-row] wdio-devtools-tabs {
min-width: 0;
}
`
Expand All @@ -137,12 +141,18 @@ export class DevtoolsWorkbench extends Element {
#dragVertical = new DragController(this, {
localStorageKey: 'toolbarHeight',
minPosition: minWorkbenchHeight,
maxPosition: () => window.innerHeight * 0.7,
maxPosition: () => this.#verticalSplitMax(),
initialPosition: () => window.innerHeight * BROWSER_HEIGHT_RATIO,
getContainerEl: () => this.#getVerticalWindow(),
direction: Direction.vertical
})

/** Both layouts render the row, so this is right in either. */
async #getDeviceRow() {
await this.updateComplete
return (this.deviceRow ?? this.verticalResizerWindow) as Element
}

async #getVerticalWindow() {
await this.updateComplete
return this.verticalResizerWindow as Element
Expand Down Expand Up @@ -223,6 +233,34 @@ export class DevtoolsWorkbench extends Element {
* The arithmetic is exact whenever the workbench fills the window, which is
* every case but an embedded panel.
*/
/**
* Ceiling for the vertical split.
*
* In the device layout the split lives in a COLUMN beside the capture, and
* that column is shorter than the window by the header, the playback
* controls and the timeline. A window-derived 70% therefore exceeded it, and
* because the controller's position is applied as a non-shrinking
* `flex-basis`, the action list could push the dock to zero and carry the
* handle outside the clipped row — leaving the split unreachable.
*
* Capped HERE rather than with a CSS `max-height`, which is what the earlier
* attempt did: the controller draws its grip from its own value and cannot
* see a cap the stylesheet applies, so the grip parts company with the
* boundary. Clamping the value keeps the two in step.
*/
/** Last composite column height, so a change in any of its inputs is seen. */
#lastColumnHeight = 0

#verticalSplitMax(): number {
if (!this.#liveDeviceLayout) {
return window.innerHeight * 0.7
}
return Math.max(
minWorkbenchHeight(),
this.#deviceColumnHeight() - minWorkbenchHeight()
)
}

#deviceColumnHeight(): number {
return Math.max(
minWorkbenchHeight(),
Expand Down Expand Up @@ -273,7 +311,13 @@ export class DevtoolsWorkbench extends Element {
// "fills the height" the drag buys backdrop and costs the dock.
maxPosition: () => this.#deviceFillWidth(),
initialPosition: () => this.#deviceFillWidth(),
getContainerEl: () => this.#getVerticalWindow(),
// The ROW it divides, not the vertical split. Those were the same element
// while the dock sat beside the capture; now the vertical split is the
// column holding the action list and the dock, so measuring it clamped the
// capture against the dock's own box — and a 15px scrollbar appearing in a
// wide dock tab moved the column, which is exactly what this pane's tests
// forbid (seen on Linux, invisible on macOS's overlay scrollbars).
getContainerEl: () => this.#getDeviceRow(),
direction: Direction.horizontal,
// The pane is on the right, so its handle sits on its inner edge and
// dragging left widens it.
Expand Down Expand Up @@ -340,6 +384,9 @@ export class DevtoolsWorkbench extends Element {
@query('section[data-vertical-resizer-window]')
verticalResizerWindow?: HTMLElement

@query('section[data-device-row]')
deviceRow?: HTMLElement

// Height of the screencast pane; the dock fills the rest of the right column.
// Collapsed dock → empty string so the browser flex-grows to fill.
#computeBrowserPaneStyle(): string {
Expand Down Expand Up @@ -576,11 +623,32 @@ export class DevtoolsWorkbench extends Element {
* capture outlives it. Guarded on the property, so not a per-render pass.
*/
protected updated(changed: PropertyValues<this>): void {
if (
changed.has('metadata') &&
this.#deviceLayout &&
this.#dragDevice.refreshBounds()
) {
if (!this.#deviceLayout) {
return
}
// The column's width derives from the capture's shape; the vertical
// split's ceiling derives from the column's HEIGHT, which the header,
// player mode and the DRAGGABLE timeline all feed. Metadata is therefore
// not the only input — enlarging the timeline, or entering player mode
// after a larger split was stored, leaves a non-shrinking flex-basis above
// its new maximum, and the dock collapses with its handle outside the
// clipped row.
//
// Watched through the composite height rather than each input, so a new
// contributor to it cannot be forgotten here. Still not a per-render pass:
// a pass that moves nothing requests no update.
const columnHeight = this.#deviceColumnHeight()
const inputsMoved =
changed.has('metadata') ||
changed.has('playerMode') ||
columnHeight !== this.#lastColumnHeight
this.#lastColumnHeight = columnHeight
if (!inputsMoved) {
return
}
const device = this.#dragDevice.refreshBounds()
const vertical = this.#dragVertical.refreshBounds()
if (device || vertical) {
this.requestUpdate()
}
}
Expand All @@ -594,6 +662,35 @@ export class DevtoolsWorkbench extends Element {
* spent its left edge on the suite tree, so a third column squeezed the dock
* into an unreadable strip and the tab row overflowed under the capture.
*/
/**
* The action list's share of the column beside the capture — the TOP pane,
* because `#dragVertical` is start-anchored and its stored position is the
* top pane's height. Sizing the DOCK from that value put the handle and the
* boundary in different places: dragging down moved the handle down while
* growing the dock upward.
*
* Sized against the COLUMN, never the window. The window-derived
* `#computeBrowserPaneStyle` gave this pane a fixed height that could exceed
* the space it had, the column then overflowed its row, and an ancestor grew
* a scrollbar — which on a classic-scrollbar platform shifts the capture
* sideways. `max-height` is a percentage for the same reason: whatever the
* drag has stored, the column cannot be made to overflow.
*/
#deviceColumnTopStyle(): string {
if (this.#toolbarCollapsed) {
return 'flex:1 1 auto; min-height:0;'
}
// `getPosition()` applied LITERALLY, because the controller's contract is
// an inline `flex-basis: Npx`: `getSlider` draws the grip at that value
// and `#adjustPosition` finds the pane it resizes by matching that exact
// string. Expressed any other way — `height:Npx`, or a percentage default
// — the handle and the boundary part company, measured at 240px apart.
const pos = this.#dragVertical.getPosition()
return pos
? `${pos}; flex-grow:0; flex-shrink:0; min-height:0;`
: 'flex:1 1 auto; min-height:0;'
Comment thread
vishnuv688 marked this conversation as resolved.
}

#renderLiveDeviceLayout() {
const width = basisPx(this.#dragDevice.getPosition())
return html`
Expand All @@ -610,7 +707,7 @@ export class DevtoolsWorkbench extends Element {
class="relative flex min-h-0 min-w-0 overflow-hidden ${
this.#workbenchSidebarCollapsed ? 'hidden' : ''
}"
style="${this.#computeBrowserPaneStyle()}"
style="${this.#deviceColumnTopStyle()}"
>
${this.#renderActionsSidebar()}
</section>
Expand All @@ -625,7 +722,12 @@ export class DevtoolsWorkbench extends Element {
? this.#dragVertical.getSlider('z-[999] pointer-events-auto')
: nothing
}
${this.#renderWorkbenchTabs()}
<section
class="relative flex flex-col min-h-0 overflow-hidden"
style="flex:1 1 auto; min-height:0;"
>
${this.#renderWorkbenchTabs()}
</section>
</section>
${
!this.#toolbarCollapsed
Expand All @@ -637,6 +739,15 @@ export class DevtoolsWorkbench extends Element {
class="relative flex flex-col min-w-0 min-h-0 overflow-hidden"
style="${this.#dragDevice.getPosition()}; flex:0 1 auto; width:${width}px; max-width:100%;"
>
${
// Playback belongs with what it plays, so in this layout the
// controls ride above the capture rather than above the dock.
this.playerMode
? html`<wdio-devtools-trace-player-controls
class="flex-none h-10 border-b-[1px] border-b-panelBorder"
></wdio-devtools-trace-player-controls>`
: nothing
}
${this.#renderBrowserPane(true)}
</section>
</section>
Expand Down Expand Up @@ -762,10 +873,11 @@ export class DevtoolsWorkbench extends Element {
`
}

/** The capture-as-right-column arrangement, live only — the player keeps the
* dock beside the capture. */
/** The capture-as-right-column arrangement. Applies to any device capture in
* either mode: a phone is the same tall frame whether it is being watched
* live or replayed, and the dock beside it was unreadable in both. */
get #liveDeviceLayout(): boolean {
return this.#deviceLayout && !this.playerMode
return this.#deviceLayout
}
}

Expand Down
Loading
Loading