Skip to content
Draft
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
2 changes: 1 addition & 1 deletion example/wdio.conf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export const config: Options.Testrunner = {
capabilities: [
{
browserName: 'chrome',
browserVersion: '144.0.7559.60', // specify chromium browser version for testing
browserVersion: '146.0.7680.72', // specify chromium browser version for testing
'goog:chromeOptions': {
args: [
'--headless',
Expand Down
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"scripts": {
"build": "pnpm --parallel build",
"demo": "wdio run ./example/wdio.conf.ts",
"demo:nightwatch": "pnpm --filter @wdio/nightwatch-devtools example",
"dev": "pnpm --parallel dev",
"preview": "pnpm --parallel preview",
"test": "vitest run",
Expand All @@ -17,7 +18,10 @@
"pnpm": {
"overrides": {
"vite": "^7.3.0"
}
},
"ignoredBuiltDependencies": [
"chromedriver"
]
},
"devDependencies": {
"@types/node": "^25.0.3",
Expand Down
115 changes: 90 additions & 25 deletions packages/app/src/components/browser/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import {
mutationContext,
type TraceMutation,
metadataContext,
type Metadata
type Metadata,
commandContext
} from '../../controller/DataManager.js'

import '~icons/mdi/world.js'
Expand All @@ -20,15 +21,16 @@ import '../placeholder.js'
const MUTATION_SELECTOR = '__mutation-highlight__'

function transform(node: any): VNode<{}> {
if (typeof node !== 'object') {
if (typeof node !== 'object' || node === null) {
// Plain string/number text node — return as-is for Preact to render as text.
return node as VNode<{}>
}

const { children, ...props } = node.props
const { children, ...props } = node.props ?? {}
/**
* ToDo(Christian): fix way we collect data on added nodes in script
*/
if (!node.type && children.type) {
if (!node.type && children?.type) {
return transform(children)
}

Expand All @@ -44,13 +46,18 @@ const COMPONENT = 'wdio-devtools-browser'
export class DevtoolsBrowser extends Element {
#vdom = document.createDocumentFragment()
#activeUrl?: string
/** Base64 PNG of the screenshot for the currently selected command, or null. */
#screenshotData: string | null = null

@consume({ context: metadataContext, subscribe: true })
metadata: Metadata | undefined = undefined

@consume({ context: mutationContext, subscribe: true })
mutations: TraceMutation[] = []

@consume({ context: commandContext, subscribe: true })
commands: CommandLog[] = []

static styles = [
...Element.styles,
css`
Expand Down Expand Up @@ -112,6 +119,31 @@ export class DevtoolsBrowser extends Element {
border-radius: 0 0 0.5rem 0.5rem;
min-height: 0;
}

.screenshot-overlay {
position: absolute;
inset: 0;
background: #111;
display: flex;
align-items: flex-start;
justify-content: center;
border-radius: 0 0 0.5rem 0.5rem;
overflow: hidden;
}

.screenshot-overlay img {
max-width: 100%;
height: auto;
display: block;
}

.iframe-wrapper {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
`
]

Expand Down Expand Up @@ -148,9 +180,16 @@ export class DevtoolsBrowser extends Element {
return
}

// viewport may not be serialized yet (race between metadata message and
// first resize event), or may arrive without dimensions — fall back to
// sensible defaults so we never throw.
const viewportWidth = (metadata.viewport as any)?.width || 1280
const viewportHeight = (metadata.viewport as any)?.height || 800
if (!viewportWidth || !viewportHeight) {
return
}

this.iframe.removeAttribute('style')
const viewportWidth = metadata.viewport.width
const viewportHeight = metadata.viewport.height
const frameSize = this.getBoundingClientRect()
const headerSize = this.header.getBoundingClientRect()

Expand Down Expand Up @@ -180,21 +219,13 @@ export class DevtoolsBrowser extends Element {
async #renderCommandScreenshot(command?: CommandLog) {
const screenshot = command?.screenshot
if (!screenshot) {
// Clicking a command that has no screenshot clears any previous overlay.
this.#screenshotData = null
this.requestUpdate()
return
}

if (!this.iframe) {
await this.updateComplete
}
if (!this.iframe) {
return
}

this.iframe.srcdoc = `
<body style="margin:0;background:#111;display:flex;justify-content:center;align-items:flex-start;">
<img src="data:image/png;base64,${screenshot}" style="max-width:100%;height:auto;display:block;" />
</body>
`
this.#screenshotData = screenshot
this.requestUpdate()
}

async #renderNewDocument(doc: SimplifiedVNode, baseUrl: string) {
Expand Down Expand Up @@ -270,7 +301,11 @@ export class DevtoolsBrowser extends Element {

#handleChildListMutation(mutation: TraceMutation) {
if (mutation.addedNodes.length === 1 && !mutation.target) {
const baseUrl = this.metadata?.url || 'unknown'
// Prefer the URL embedded in the mutation itself (set by the injected script
// at capture time), then fall back to the already-resolved active URL, and
// finally to the context metadata URL. This avoids a race where metadata
// arrives after the first childList mutation fires #renderNewDocument.
const baseUrl = mutation.url || this.#activeUrl || this.metadata?.url || 'unknown'
this.#renderNewDocument(
mutation.addedNodes[0] as SimplifiedVNode,
baseUrl
Expand Down Expand Up @@ -389,6 +424,15 @@ export class DevtoolsBrowser extends Element {
this.requestUpdate()
}

/** Latest screenshot from any command — auto-updates the preview as tests run. */
get #latestAutoScreenshot(): string | null {
if (!this.commands?.length) return null
for (let i = this.commands.length - 1; i >= 0; i--) {
if (this.commands[i].screenshot) return this.commands[i].screenshot!
}
return null
}

render() {
/**
* render a browser state if it hasn't before
Expand All @@ -398,6 +442,12 @@ export class DevtoolsBrowser extends Element {
this.#renderBrowserState()
}

const hasMutations = this.mutations && this.mutations.length
// Explicit user selection takes priority; fall back to latest auto-screenshot
// so the preview always shows the most recently executed command's state
// (important for Nightwatch mode where there are no DOM mutations).
const displayScreenshot = this.#screenshotData ?? this.#latestAutoScreenshot

return html`
<section
class="w-full h-full bg-sideBarBackground rounded-lg border-2 border-panelBorder shadow-xl"
Expand All @@ -417,11 +467,26 @@ export class DevtoolsBrowser extends Element {
<span class="truncate">${this.#activeUrl}</span>
</div>
</header>
${this.mutations && this.mutations.length
? html`<iframe class="origin-top-left"></iframe>`
: html`<wdio-devtools-placeholder
style="height: 100%"
></wdio-devtools-placeholder>`}
${hasMutations
? html`
<div class="iframe-wrapper">
<iframe class="origin-top-left"></iframe>
${displayScreenshot
? html`<div class="screenshot-overlay">
<img src="data:image/png;base64,${displayScreenshot}" />
</div>`
: ''}
</div>`
: displayScreenshot
? html`
<div class="iframe-wrapper">
<div class="screenshot-overlay" style="position:relative;flex:1;min-height:0;">
<img src="data:image/png;base64,${displayScreenshot}" />
</div>
</div>`
: html`<wdio-devtools-placeholder
style="height: 100%"
></wdio-devtools-placeholder>`}
</section>
`
}
Expand Down
8 changes: 8 additions & 0 deletions packages/app/src/components/sidebar/constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
import { TestState } from './types.js'

export const STATE_MAP: Record<string, TestState> = {
running: TestState.RUNNING,
failed: TestState.FAILED,
passed: TestState.PASSED,
skipped: TestState.SKIPPED
}
import type { RunCapabilities } from './types.js'

export const DEFAULT_CAPABILITIES: RunCapabilities = {
Expand Down
92 changes: 77 additions & 15 deletions packages/app/src/components/sidebar/explorer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Element } from '@core/element'
import { html, css, nothing, type TemplateResult } from 'lit'
import { customElement } from 'lit/decorators.js'
import { customElement, property } from 'lit/decorators.js'
import { consume } from '@lit/context'
import type { TestStats, SuiteStats } from '@wdio/reporter'
import type { Metadata } from '@wdio/devtools-service/types'
Expand All @@ -17,7 +17,11 @@ import type {
TestRunDetail
} from './types.js'
import { TestState } from './types.js'
import { DEFAULT_CAPABILITIES, FRAMEWORK_CAPABILITIES } from './constants.js'
import {
DEFAULT_CAPABILITIES,
FRAMEWORK_CAPABILITIES,
STATE_MAP
} from './constants.js'

import '~icons/mdi/play.js'
import '~icons/mdi/stop.js'
Expand Down Expand Up @@ -63,6 +67,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
]

@consume({ context: suiteContext, subscribe: true })
@property({ type: Array })
suites: Record<string, SuiteStats>[] | undefined = undefined

@consume({ context: metadataContext, subscribe: true })
Expand All @@ -71,6 +76,10 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
@consume({ context: isTestRunningContext, subscribe: true })
isTestRunning = false

updated(changedProperties: Map<string | number | symbol, unknown>) {
super.updated(changedProperties)
}

connectedCallback(): void {
super.connectedCallback()
window.addEventListener('app-test-filter', this.#filterListener)
Expand Down Expand Up @@ -285,6 +294,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
feature-file="${entry.featureFile || ''}"
feature-line="${entry.featureLine ?? ''}"
suite-type="${entry.suiteType || ''}"
?has-children="${entry.children && entry.children.length > 0}"
.runDisabled=${this.#isRunDisabled(entry)}
.runDisabledReason=${this.#getRunDisabledReason(entry)}
>
Expand Down Expand Up @@ -326,18 +336,70 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
)
}

#isRunning(entry: TestStats | SuiteStats): boolean {
if ('tests' in entry) {
// Check if any immediate test is running
if (entry.tests.some((t) => !t.end)) {
return true
}
// Check if any nested suite is running
if (entry.suites.some((s) => this.#isRunning(s))) {
return true
}
return false
}
// For individual tests, check if end is not set
return !entry.end
}

#hasFailed(entry: TestStats | SuiteStats): boolean {
if ('tests' in entry) {
// Check if any immediate test failed
if (entry.tests.find((t) => t.state === 'failed')) {
return true
}
// Check if any nested suite has failures
if (entry.suites.some((s) => this.#hasFailed(s))) {
return true
}
return false
}
// For individual tests
return entry.state === 'failed'
}

#computeEntryState(entry: TestStats | SuiteStats): TestState {
const state = (entry as any).state

// Check explicit state first
const mappedState = STATE_MAP[state]
if (mappedState) {
return mappedState
}

// For suites, compute state from children
if ('tests' in entry) {
if (this.#isRunning(entry)) {
return TestState.RUNNING
}
if (this.#hasFailed(entry)) {
return TestState.FAILED
}
return TestState.PASSED
}

// For individual tests, check if still running
return !entry.end ? TestState.RUNNING : TestState.PASSED
}

#getTestEntry(entry: TestStats | SuiteStats): TestEntry {
if ('tests' in entry) {
const entries = [...entry.tests, ...entry.suites]
return {
uid: entry.uid,
label: entry.title,
type: 'suite',
state: entry.tests.some((t) => !t.end)
? TestState.RUNNING
: entry.tests.find((t) => t.state === 'failed')
? TestState.FAILED
: TestState.PASSED,
state: this.#computeEntryState(entry),
callSource: (entry as any).callSource,
specFile: (entry as any).file,
fullTitle: entry.title,
Expand All @@ -353,11 +415,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
uid: entry.uid,
label: entry.title,
type: 'test',
state: !entry.end
? TestState.RUNNING
: entry.state === 'failed'
? TestState.FAILED
: TestState.PASSED,
state: this.#computeEntryState(entry),
callSource: (entry as any).callSource,
specFile: (entry as any).file,
fullTitle: (entry as any).fullTitle || entry.title,
Expand Down Expand Up @@ -421,9 +479,13 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
(suite) => suite.uid,
(suite) => this.#renderEntry(suite)
)
: html`<p class="text-disabledForeground text-sm px-4 py-2">
No tests found
</p>`}
: html`<div class="text-sm px-4 py-2">
<p class="text-disabledForeground">No tests to display</p>
<p class="text-xs text-disabledForeground mt-2">
Debug: suites=${this.suites?.length || 0},
rootSuites=${uniqueSuites.length}, filtered=${suites.length}
</p>
</div>`}
</wdio-test-suite>
`
}
Expand Down
Loading
Loading