diff --git a/README.md b/README.md index 2cdff86..72b68e5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/packages/app/package.json b/packages/app/package.json index 9bd409a..5ab423d 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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": { diff --git a/packages/app/src/app.ts b/packages/app/src/app.ts index 1f1c190..1852322 100644 --- a/packages/app/src/app.ts +++ b/packages/app/src/app.ts @@ -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 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=. 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` @@ -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` + + ` + } return html` ${this.#mainContent()} diff --git a/packages/app/src/components/sidebar/explorer.ts b/packages/app/src/components/sidebar/explorer.ts index 358e470..08b639b 100644 --- a/packages/app/src/components/sidebar/explorer.ts +++ b/packages/app/src/components/sidebar/explorer.ts @@ -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' @@ -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, @@ -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 { @@ -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 }) { @@ -116,6 +126,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { }) ) + // Forward preserveBaseline so the backend knows whether to drop baselines. const payload = { ...detail, runAll: detail.uid === '*', @@ -123,7 +134,8 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { 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) } @@ -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).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('app-test-run', { + detail: { ...detail, preserveBaseline: true }, + bubbles: true, + composed: true + }) + ) + } + async #postToBackend(path: string, body: Record) { try { const response = await fetch(path, { diff --git a/packages/app/src/components/sidebar/test-suite.ts b/packages/app/src/components/sidebar/test-suite.ts index 934bf21..ed23795 100644 --- a/packages/app/src/components/sidebar/test-suite.ts +++ b/packages/app/src/components/sidebar/test-suite.ts @@ -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' @@ -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('app-test-preserve-rerun', { + detail, + bubbles: true, + composed: true + }) + ) + } + get hasPassed() { return this.state === TestState.PASSED } @@ -256,6 +282,20 @@ export class ExplorerTestEntry extends CollapseableEntry { : 'group-hover/button:text-chartsGreen'}" > + ${this.hasFailed && !this.runDisabled + ? html` + + ` + : nothing} ` : !this.runDisabled ? html` diff --git a/packages/app/src/components/sidebar/types.ts b/packages/app/src/components/sidebar/types.ts index 5cd7706..b116859 100644 --- a/packages/app/src/components/sidebar/types.ts +++ b/packages/app/src/components/sidebar/types.ts @@ -38,6 +38,7 @@ export interface TestRunDetail { featureFile?: string featureLine?: number suiteType?: string + preserveBaseline?: boolean } export enum TestState { diff --git a/packages/app/src/components/tabs.ts b/packages/app/src/components/tabs.ts index b49c863..90d9420 100644 --- a/packages/app/src/components/tabs.ts +++ b/packages/app/src/components/tabs.ts @@ -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 @@ -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) } @@ -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) { diff --git a/packages/app/src/components/workbench.ts b/packages/app/src/components/workbench.ts index f848745..7740083 100644 --- a/packages/app/src/components/workbench.ts +++ b/packages/app/src/components/workbench.ts @@ -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' @@ -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, @@ -43,6 +46,10 @@ export class DevtoolsWorkbench extends Element { @state() networkRequests: NetworkRequest[] | undefined = undefined + @consume({ context: baselineContext, subscribe: true }) + @state() + baselines: Map | undefined = undefined + static styles = [ ...Element.styles, css` @@ -215,6 +222,16 @@ export class DevtoolsWorkbench extends Element { > + ${(this.baselines?.size || 0) > 0 + ? html` + + + + ` + : nothing}