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
14 changes: 14 additions & 0 deletions .changeset/live-native-run-is-visible.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@wdio/devtools-service": patch
"@wdio/devtools-app": patch
---

Make a live native mobile run visible on the dashboard. Three separate gaps left one looking empty, and each hid the next.

**Early messages were discarded in silence.** A session's metadata and its first suites are published while the driver is still being created — against Appium that is ~11 s before the worker socket opens — and `sendUpstream` dropped anything sent before the socket was open. `metadata.type` gates the test-suite pane and `metadata.device` gates the mobile layout, so a live run showed neither the test tree nor the device frame and simply looked like nothing had been captured. Messages published while the socket is CONNECTING are now buffered and flushed in publication order on open; a socket that dies before ever opening reports and releases what it held rather than retaining a run's worth of payloads. The buffer is bounded.

Drop reporting is re-entrancy guarded, because the fix uncovered a second trap: `patchConsole` forwards console output upstream, so an adapter's drop handler that logs re-enters `sendUpstream`, drops again and recurses until the stack blows — surfacing as `Maximum call stack size exceeded` raised inside the user's own spec, pointing nowhere near the capturer.

**A native command carried no image.** The per-command screenshot was skipped for every Appium session. A native session has no DOM to replay and no per-action snapshot outside trace mode, so the player had nothing to show for any command and the device pane fell back to desktop browser chrome. Native sessions now take one in **live mode only** — trace mode already screenshots the same command through `captureActionResult`, and two Appium round trips at ~1.2 s each is the cost #351 exists to remove. A mobile *browser* session is unchanged: it replays from its mutation stream.

**The capture had nowhere sensible to sit.** The trace player puts the dock beside the capture, which works when the whole window is the trace. A live dashboard has already 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. Live mode now stacks the action list and the dock in one column beside a full-height capture, with both drag handles working and the collapse reversible.
13 changes: 13 additions & 0 deletions .changeset/passing-not-assertions-read-as-passed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@wdio/devtools-service": patch
---

Record a passing `.not.*` assertion as passed. Every negated matcher that succeeded was rendered as a failed action row and collected into the Errors tab, inside a test the runner itself reported green — so a clean run showed a red row and an error it had not produced.

expect-webdriverio hands `afterAssertion` the **raw** matcher result: jest's convention is that `pass` answers the *positive* assertion and the framework inverts it for `.not`, so a passing `.not.toBeDisplayed()` arrives as `pass: false`. Nothing in the hook's parameters carries `isNot` — it lives on the matcher's own `this` — which leaves the formatted message as the only carrier that reaches an adapter.

Both signals are read off the generated **diff block**, never the prose. The first line is `Expect ${subject} ${not}to …` and a subject is user-controlled, so scanning it let a selector or an expected value containing "not to" reverse a positive assertion's outcome. A matcher that takes a value labels the diff `Expected [not]` when negated; the `.be` family renders no such label (`enhanceErrorBe` passes `useNotInLabel: false`) and encodes the negation in the generated expected value instead, which is trusted only when the user supplied none — `toHaveText('not foo')` prints the same shape.

A caller that already knows the outcome, such as the synthesized row for a matcher that hard-threw, skips the inversion entirely rather than having a decided failure re-read from its message.

Not mobile-specific: this affected every `.not.*` matcher on every run.
17 changes: 17 additions & 0 deletions .changeset/publish-a-real-viewport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@wdio/devtools-service": patch
"@wdio/selenium-devtools": patch
"@wdio/nightwatch-devtools": patch
---

Publish the viewport from every adapter. Selenium and Nightwatch published none at all, so `trace.metadata.viewport` was absent for every trace either produced and the exporter fell back to a hard-coded 1280x720 in three places. That fallback is what the player lays the DOM-replay iframe out at, so **every** Selenium and Nightwatch trace was replayed at 1280x720 regardless of the window the run actually used. Not a mobile problem: a desktop run at 2560x1440 was framed just as wrongly, which is presumably why it went unnoticed — the proportions are plausible.

The read has one home now, `resolveViewport` in `core`, because all three JS adapters need it. Two probes, only one of which exists at a time: a page measures itself through `visualViewport` — the only read carrying the real scale and offsets — and a native app has no page to ask, so the device's own window is the only answer. `isNativeAppSession` settles which, so the branch was already decided.

Comment thread
vishnuv688 marked this conversation as resolved.
Each adapter supplies its own probes, and the care is in how: Selenium reads through the **unpatched** `getDriverOriginals()` and Nightwatch over its raw WebDriver transport, because both implement these as ordinary commands — through the patched path every run would open with an `executeScript` or `getWindowRect` row of our own making, and Nightwatch's would additionally sit behind the command in flight on its own queue.

The script reads the `visualViewport` fields one by one rather than returning the object: it is a host object, and a driver that serializes it structurally hands back `{}`, which would read as a successful empty measurement rather than a failed one. A read that answers nothing usable omits the viewport rather than publishing a zero-sized one, and a failure degrades to no viewport rather than failing the session.

The Python adapter already published one, but only `width`/`height` from `innerWidth`/`innerHeight`, so it lost the scale and offsets the shared `Viewport` declares; it now takes the same `visualViewport` read as the others.

Also corrects the claim, in the comment that survived, that this field is metadata only. It is load-bearing geometry wherever there is a DOM to replay.
130 changes: 103 additions & 27 deletions packages/app/src/components/workbench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,64 @@ export class DevtoolsWorkbench extends Element {
}
}

/**
* Live device layout: the capture is a full-height column on the right, and
* everything else stacks to its left — the action list above, the dock below.
*
* Live mode only. The player puts the dock BESIDE the capture, which works
* there because the whole window is the trace. A live dashboard has already
* 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.
*/
#renderLiveDeviceLayout() {
const width = basisPx(this.#dragDevice.getPosition())
return html`
<section
data-device-row
class="relative flex flex-row flex-1 min-w-0 min-h-0 overflow-hidden"
>
<section
data-vertical-resizer-window
class="relative flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden"
>
<section
data-sidebar
class="relative flex min-h-0 min-w-0 overflow-hidden ${
this.#workbenchSidebarCollapsed ? 'hidden' : ''
}"
style="${this.#computeBrowserPaneStyle()}"
>
${this.#renderActionsSidebar()}
</section>
${
// Without this the collapse is one-way here: the toggle lives
// inside the sidebar that just went `hidden`, and the state is
// persisted, so the action list stayed gone across reloads.
this.#renderSidebarRestoreButton()
}
${
!this.#toolbarCollapsed && !this.#workbenchSidebarCollapsed
? this.#dragVertical.getSlider('z-[999] pointer-events-auto')
: nothing
}
${this.#renderWorkbenchTabs()}
</section>
${
!this.#toolbarCollapsed
? this.#dragDevice.getSlider('z-[999] pointer-events-auto')
: nothing
}
<section
data-device-pane
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%;"
>
${this.#renderBrowserPane(true)}
</section>
</section>
`
}

#renderStackedSplit() {
return html`
<section
Expand Down Expand Up @@ -658,39 +716,57 @@ export class DevtoolsWorkbench extends Element {
data-horizontal-resizer-window
class="flex relative w-full flex-1 min-h-0 overflow-hidden"
>
<section
data-sidebar
class="flex-none"
style="${this.#computeSidebarStyle()}"
>
${this.#renderActionsSidebar()}
</section>
${this.#renderSidebarRestoreButton()}
${
!this.#workbenchSidebarCollapsed
? this.#dragHorizontal.getSlider('z-30')
// The live device layout owns the whole row: it stacks the action
// list and the dock in one column beside the capture, so the sidebar
// is rendered inside it rather than as a sibling here.
this.#liveDeviceLayout ? this.#renderLiveDeviceLayout() : nothing
}
${this.#liveDeviceLayout ? nothing : this.#renderRowSplit()}
</section>
`
}

#renderRowSplit() {
return html`
<section
data-sidebar
class="flex-none"
style="${this.#computeSidebarStyle()}"
>
${this.#renderActionsSidebar()}
</section>
${this.#renderSidebarRestoreButton()}
${
!this.#workbenchSidebarCollapsed
? this.#dragHorizontal.getSlider('z-30')
: nothing
}
<section
data-vertical-resizer-window
class="relative flex flex-col flex-grow min-w-0 min-h-0 overflow-hidden"
>
${
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
}
<section
data-vertical-resizer-window
class="relative flex flex-col flex-grow min-w-0 min-h-0 overflow-hidden"
>
${
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.#deviceLayout
? this.#renderDeviceSplit()
: this.#renderStackedSplit()
}
</section>
${
this.#deviceLayout
? this.#renderDeviceSplit()
: this.#renderStackedSplit()
}
</section>
`
}

/** The capture-as-right-column arrangement, live only — the player keeps the
* dock beside the capture. */
get #liveDeviceLayout(): boolean {
return this.#deviceLayout && !this.playerMode
}
}

declare global {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export * from './screenshot-artifact.js'
export * from './video-slice.js'
export * from './with-timeout.js'
export * from './webdriver-http.js'
export * from './viewport.js'
export * from './assert-patcher.js'
export * from './element-snapshot.js'
export * from './element-scripts.js'
Expand Down
99 changes: 94 additions & 5 deletions packages/core/src/session-capturer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ export interface SessionCapturerOptions {
type ConsoleMethod = (typeof CONSOLE_METHODS)[number]

export abstract class SessionCapturerBase {
/** Ceiling on messages held while the socket connects. High enough that a
* normal bringup never reaches it, low enough that a dashboard which never
* answers cannot retain a run's worth of traffic. */
static readonly MAX_PENDING_UPSTREAM = 1000

// ── State (mostly private; subclasses access shared ws via `this.ws`) ────
/**
* Exposed as `protected` so subclasses with framework-specific close/wait
Expand Down Expand Up @@ -117,10 +122,17 @@ export abstract class SessionCapturerBase {
)
this.ws.on('open', () => {
this.#hasConnected = true
this.#flushPending()
this.onWsOpen()
})
this.ws.on('error', (err: unknown) => this.onWsError(err))
this.ws.on('close', () => this.onWsClose())
this.ws.on('error', (err: unknown) => {
this.#discardPending()
this.onWsError(err)
})
this.ws.on('close', () => {
this.#discardPending()
this.onWsClose()
})
this.ws.on('message', (raw: Buffer | string) => {
try {
const parsed = JSON.parse(raw.toString())
Expand All @@ -147,14 +159,91 @@ export abstract class SessionCapturerBase {
* {@link onUpstreamDrop}.
*/
sendUpstream(event: string, data: unknown): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
this.onUpstreamDrop(event, 'closed')
if (!this.ws) {
this.notifyDrop(event, 'closed')
return
}
// A socket that has not opened YET is not a lost dashboard: a session's
// metadata and its first suites are published while the driver is still
// being created, and against Appium that is ~11 s before the worker socket
// opens. Dropping them silently cost the live dashboard the whole run —
// `metadata.type` gates the test-suite pane and `metadata.device` the
// mobile layout, so both were simply absent with nothing logged.
if (this.ws.readyState === WebSocket.CONNECTING) {
this.#buffer(event, data)
return
}
if (this.ws.readyState !== WebSocket.OPEN) {
this.notifyDrop(event, 'closed')
return
}
try {
this.ws.send(JSON.stringify({ scope: event, data }))
} catch (err) {
this.onUpstreamDrop(event, 'send-error', err)
this.notifyDrop(event, 'send-error', err)
}
}

/** Messages published before the socket opened, in the order they were
* published. Capped: a dashboard that never connects must not grow this
* without bound for the length of a run. */
#pending: { event: string; data: unknown }[] = []

#buffer(event: string, data: unknown): void {
if (this.#pending.length >= SessionCapturerBase.MAX_PENDING_UPSTREAM) {
this.notifyDrop(event, 'closed')
return
}
this.#pending.push({ event, data })
}

#inDropHandler = false

/**
* Report a drop, at most one level deep.
*
* An adapter's handler naturally wants to log, and `patchConsole` forwards
* console output upstream — so a handler that logs re-enters `sendUpstream`,
* drops again and recurses until the stack blows. Measured as "Maximum call
* stack size exceeded" raised inside the user's own spec, which points
* nowhere near this code.
*/
protected notifyDrop(
event: string,
reason: 'closed' | 'send-error',
err?: unknown
): void {
if (this.#inDropHandler) {
return
}
this.#inDropHandler = true
try {
this.onUpstreamDrop(event, reason, err)
} finally {
this.#inDropHandler = false
}
}

/** A socket that dies before it ever opens is a dashboard that is not coming,
* so its buffer is reported as dropped and released. Without this the
* payloads — screenshots among them — were retained for the run's length and
* the adapter's drop warning never fired, which is the silence the buffer
* exists to end, not to relocate. */
#discardPending(): void {
const pending = this.#pending
this.#pending = []
for (const { event } of pending) {
this.notifyDrop(event, 'closed')
}
}

/** Publish what was buffered while connecting, oldest first — order matters,
* since the app folds each metadata message into the previous one. */
#flushPending(): void {
const pending = this.#pending
this.#pending = []
for (const { event, data } of pending) {
this.sendUpstream(event, data)
}
}

Expand Down
Loading
Loading