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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ Works with **WebdriverIO**, **[Nightwatch.js](./packages/nightwatch-devtools/REA

> For setup, configuration options, and prerequisites see the **[service README](./packages/service/README.md#screencast-recording)**.

### 🐞 Preserve & Rerun (Compare)
- **When the bug icon appears**: Only on test/suite rows in a `failed` state and the icon sits next to ▶ on hover, available wherever a plain rerun is supported (e.g. Cucumber scenarios at the scenario row, Mocha tests at the test or suite row)
- **Side-by-side diff**: Click the bug-play icon on a failed test to snapshot the failing run and rerun in one action and the Compare tab shows the two runs aligned by command, with the failure point and assertion error (Expected vs Received) called out
- **Diagnose flaky tests**: See exactly which command differed between a pass and a fail without re-reading logs
- **Pop out**: Open the comparison in a separate, themed window for a roomier view

> **Note:** Preserve & Rerun is currently supported for **WebdriverIO only**. Nightwatch.js and Selenium support is planned for a future release.

### 🔍︎ TestLens
- **Code Intelligence**: View test definitions directly in your editor
- **Run/Debug Actions**: Execute individual tests or suites with inline CodeLens actions
Expand Down
2 changes: 1 addition & 1 deletion packages/app/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@wdio/devtools-app",
"version": "1.4.1",
"version": "1.4.2",
"description": "Browser devtools extension for debugging WebdriverIO tests.",
"type": "module",
"repository": {
Expand Down
45 changes: 44 additions & 1 deletion packages/app/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,49 @@ import { TraceType, type TraceLog } from '@wdio/devtools-service/types'
import { Element } from '@core/element'
import { DataManagerController } from './controller/DataManager.js'
import { DragController, Direction } from './utils/DragController.js'
import { SIDEBAR_MIN_WIDTH } from './controller/constants.js'
import { SIDEBAR_MIN_WIDTH, DARK_MODE_KEY } from './controller/constants.js'
import { POPOUT_QUERY } from './components/workbench/compare/constants.js'

// Bootstrap the dark-mode class on <body> as early as possible so popout
// windows (which don't render the header) still get themed consistently
// with the main dashboard. The header still owns the toggle.
const darkModeInit = localStorage.getItem(DARK_MODE_KEY)
const isDarkMode =
typeof darkModeInit === 'string'
? darkModeInit === 'true'
: window.matchMedia('(prefers-color-scheme: dark)').matches
if (isDarkMode) {
document.body.classList.add('dark')
}
// Cross-window sync: when the user toggles dark mode in the main dashboard,
// the storage event fires in OTHER windows (popouts) and we mirror the
// theme change there too.
window.addEventListener('storage', (e) => {
if (e.key === DARK_MODE_KEY) {
document.body.classList.toggle('dark', e.newValue === 'true')
}
})

import './components/header.js'
import './components/sidebar.js'
import './components/workbench.js'
import './components/onboarding/start.js'
import './components/workbench/compare.js'

@customElement('wdio-devtools')
export class WebdriverIODevtoolsApplication extends Element {
dataManager = new DataManagerController(this)

// Popout mode: when opened via the Compare tab's "↗ Pop out" button the
// URL carries ?view=compare&uid=<testUid>. The app then renders only the
// Compare panel full-viewport (no header, no sidebar, no workbench tabs).
#popoutMode =
new URLSearchParams(window.location.search).get(POPOUT_QUERY.viewKey) ===
POPOUT_QUERY.viewValue
#popoutUid =
new URLSearchParams(window.location.search).get(POPOUT_QUERY.uidKey) ||
undefined

static styles = [
...Element.styles,
css`
Expand Down Expand Up @@ -56,9 +88,20 @@ export class WebdriverIODevtoolsApplication extends Element {
'clear-execution-data',
this.#clearExecutionData.bind(this)
)
// In popout mode, the URL carries the test uid the parent window was
// viewing. Push it into the context so the Compare component finds the
// matching baseline as soon as the WS reconnects in this new window.
if (this.#popoutMode && this.#popoutUid) {
this.dataManager.setSelectedTestUid(this.#popoutUid)
}
}

render() {
if (this.#popoutMode) {
return html`
<wdio-devtools-compare style="flex:1 1 auto;"></wdio-devtools-compare>
`
}
return html`
<wdio-devtools-header></wdio-devtools-header>
${this.#mainContent()}
Expand Down
60 changes: 59 additions & 1 deletion packages/app/src/components/sidebar/explorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
FRAMEWORK_CAPABILITIES,
STATE_MAP
} from './constants.js'
import { BASELINE_API } from '../workbench/compare/constants.js'

import '~icons/mdi/play.js'
import '~icons/mdi/stop.js'
Expand All @@ -40,6 +41,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
#filterListener = this.#filterTests.bind(this)
#runListener = this.#handleTestRun.bind(this)
#stopListener = this.#handleTestStop.bind(this)
#preserveRerunListener = this.#handlePreserveAndRerun.bind(this)

static styles = [
...Element.styles,
Expand Down Expand Up @@ -82,6 +84,10 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
window.addEventListener('app-test-filter', this.#filterListener)
this.addEventListener('app-test-run', this.#runListener as EventListener)
this.addEventListener('app-test-stop', this.#stopListener as EventListener)
this.addEventListener(
'app-test-preserve-rerun',
this.#preserveRerunListener as EventListener
)
}

disconnectedCallback(): void {
Expand All @@ -92,6 +98,10 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
'app-test-stop',
this.#stopListener as EventListener
)
this.removeEventListener(
'app-test-preserve-rerun',
this.#preserveRerunListener as EventListener
)
}

#filterTests({ detail }: { detail: DevtoolsSidebarFilter }) {
Expand All @@ -116,14 +126,16 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
})
)

// Forward preserveBaseline so the backend knows whether to drop baselines.
const payload = {
...detail,
runAll: detail.uid === '*',
framework: this.#getFramework(),
specFile: detail.specFile || this.#deriveSpecFile(detail),
configFile: this.#getConfigPath(),
rerunCommand: this.#getRerunCommand(),
launchCommand: this.#getLaunchCommand()
launchCommand: this.#getLaunchCommand(),
preserveBaseline: detail.preserveBaseline === true
}
await this.#postToBackend('/api/tests/run', payload)
}
Expand All @@ -133,6 +145,52 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
await this.#postToBackend('/api/tests/stop', {})
}

async #handlePreserveAndRerun(event: Event) {
event.stopPropagation()
const detail = (event as CustomEvent<TestRunDetail>).detail
if (this.#isRunDisabledDetail(detail)) {
this.#surfaceCapabilityWarning(detail)
return
}

// Snapshot the current run BEFORE the rerun clears live data.
try {
const response = await fetch(BASELINE_API.preserve, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
testUid: detail.uid,
scope: detail.entryType
})
})
if (!response.ok) {
const errorText = await response.text()
window.dispatchEvent(
new CustomEvent('app-logs', {
detail: `Failed to preserve baseline: ${errorText}`
})
)
return // skip rerun if preserve failed — no comparison value
}
} catch (error) {
window.dispatchEvent(
new CustomEvent('app-logs', {
detail: `Preserve error: ${(error as Error).message}`
})
)
return
}

// Flag the rerun so #handleTestRun doesn't wipe the baseline we just saved.
this.dispatchEvent(
new CustomEvent<TestRunDetail>('app-test-run', {
detail: { ...detail, preserveBaseline: true },
bubbles: true,
composed: true
})
)
}

async #postToBackend(path: string, body: Record<string, unknown>) {
try {
const response = await fetch(path, {
Expand Down
40 changes: 40 additions & 0 deletions packages/app/src/components/sidebar/test-suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import '~icons/mdi/window-close.js'
import '~icons/mdi/debug-step-over.js'
import '~icons/mdi/check.js'
import '~icons/mdi/checkbox-blank-circle-outline.js'
import '~icons/mdi/bug-play.js'

const TEST_SUITE = 'wdio-test-suite'

Expand Down Expand Up @@ -169,6 +170,31 @@ export class ExplorerTestEntry extends CollapseableEntry {
)
}

#preserveAndRerun(event: Event) {
event.stopPropagation()
if (!this.uid || this.runDisabled) {
return
}
const detail: TestRunDetail = {
uid: this.uid,
entryType: this.entryType,
specFile: this.specFile,
fullTitle: this.fullTitle,
label: this.labelText,
callSource: this.callSource,
featureFile: this.featureFile,
featureLine: this.featureLine,
suiteType: this.suiteType
}
this.dispatchEvent(
new CustomEvent<TestRunDetail>('app-test-preserve-rerun', {
detail,
bubbles: true,
composed: true
})
)
}

get hasPassed() {
return this.state === TestState.PASSED
}
Expand Down Expand Up @@ -256,6 +282,20 @@ export class ExplorerTestEntry extends CollapseableEntry {
: 'group-hover/button:text-chartsGreen'}"
></icon-mdi-play>
</button>
${this.hasFailed && !this.runDisabled
? html`
<button
class="p-1 rounded hover:bg-toolbarHoverBackground my-1 group/button"
title="Preserve current run and rerun for comparison"
@click="${(event: Event) =>
this.#preserveAndRerun(event)}"
>
<icon-mdi-bug-play
class="group-hover/button:text-chartsBlue"
></icon-mdi-bug-play>
</button>
`
: nothing}
`
: !this.runDisabled
? html`
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/components/sidebar/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export interface TestRunDetail {
featureFile?: string
featureLine?: number
suiteType?: string
preserveBaseline?: boolean
}

export enum TestState {
Expand Down
25 changes: 20 additions & 5 deletions packages/app/src/components/tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,19 @@ export class DevtoolsTabs extends Element {
}
}

#refreshTabList() {
this.#tabList =
this.tabs
.map((el) => el.getAttribute('label') as string)
.filter(Boolean) || []
this.requestUpdate()
}

connectedCallback() {
super.connectedCallback()
setTimeout(() => {
// wait till innerHTML is parsed
this.#tabList =
this.tabs
.map((el) => el.getAttribute('label') as string)
.filter(Boolean) || []
this.#refreshTabList()

/**
* get tab id either from local storage or a tab element that
Expand All @@ -120,7 +125,7 @@ export class DevtoolsTabs extends Element {
*/
if (!this.#activeTab) {
this.#activeTab = this.#tabList[0]
this.tabs[0].setAttribute('active', '')
this.tabs[0]?.setAttribute('active', '')
} else {
this.activateTab(this.#activeTab)
}
Expand All @@ -134,6 +139,16 @@ export class DevtoolsTabs extends Element {
})
}

firstUpdated() {
// Refresh the tab list whenever the light-DOM slot contents change —
// e.g. a conditionally-rendered tab like Compare mounting/unmounting
// after the user clicks Preserve & Rerun.
const slot = this.shadowRoot?.querySelector(
'slot:not([name])'
) as HTMLSlotElement | null
slot?.addEventListener('slotchange', () => this.#refreshTabList())
}

disconnectedCallback() {
super.disconnectedCallback()
if (this.#badgeCheckInterval) {
Expand Down
19 changes: 18 additions & 1 deletion packages/app/src/components/workbench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import { consume } from '@lit/context'
import { DragController, Direction } from '../utils/DragController.js'
import {
consoleLogContext,
networkRequestContext
networkRequestContext,
baselineContext
} from '../controller/context.js'
import type { PreservedAttempt } from '@wdio/devtools-service/types'

import '~icons/mdi/arrow-collapse-down.js'
import '~icons/mdi/arrow-collapse-up.js'
Expand All @@ -21,6 +23,7 @@ import './workbench/logs.js'
import './workbench/console.js'
import './workbench/metadata.js'
import './workbench/network.js'
import './workbench/compare.js'
import './browser/snapshot.js'
import {
MIN_WORKBENCH_HEIGHT,
Expand All @@ -43,6 +46,10 @@ export class DevtoolsWorkbench extends Element {
@state()
networkRequests: NetworkRequest[] | undefined = undefined

@consume({ context: baselineContext, subscribe: true })
@state()
baselines: Map<string, PreservedAttempt> | undefined = undefined

static styles = [
...Element.styles,
css`
Expand Down Expand Up @@ -215,6 +222,16 @@ export class DevtoolsWorkbench extends Element {
>
<wdio-devtools-network></wdio-devtools-network>
</wdio-devtools-tab>
${(this.baselines?.size || 0) > 0
? html`
<wdio-devtools-tab
label="Compare"
.badge="${this.baselines?.size || 0}"
>
<wdio-devtools-compare></wdio-devtools-compare>
</wdio-devtools-tab>
`
: nothing}
<nav class="ml-auto" slot="actions">
<button
@click="${() => this.#toggle('toolbar')}"
Expand Down
Loading
Loading