diff --git a/README.md b/README.md index 3f08c5e..9a7526a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,98 @@ -# devtools -Browser devtools extension for debugging WebdriverIO tests +# WebdriverIO DevTools + +A powerful browser devtools extension for debugging, visualizing, and controlling WebdriverIO test executions in real-time. + +## Features + +### 🎯 Interactive Test Execution +- **Selective Test Rerun**: Click play buttons on individual test cases, test suites, or Cucumber scenario examples to re-execute them instantly +- **Smart Browser Reuse**: Tests rerun in the same browser window without opening new tabs, improving performance and user experience +- **Stop Test Execution**: Terminate running tests with proper process cleanup using the stop button +- **Test List Preservation**: All tests remain visible in the sidebar during reruns, maintaining full context + +### 🎭 Multi-Framework Support +- **Mocha**: Full support with grep-based filtering for test/suite execution +- **Jasmine**: Complete integration with grep-based filtering +- **Cucumber**: Scenario-level and example-specific execution with feature:line targeting + +### 📊 Real-Time Visualization +- **Live Browser Preview**: View the application under test in a scaled iframe with automatic screenshot updates +- **Actions Timeline**: Command-by-command execution log with timestamps and parameters +- **Test Hierarchy**: Nested test suite and test case tree view with status indicators +- **Live Status Updates**: Immediate spinner icons and visual feedback when tests start/stop + +### 🔍 Debugging Capabilities +- **Command Logging**: Detailed capture of all WebDriver commands with arguments and results +- **Screenshot Capture**: Automatic screenshots after each command for visual debugging +- **Source Code Mapping**: View the exact line of code that triggered each command +- **Console Logs**: Capture and display application console output +- **Error Tracking**: Full error messages and stack traces for failed tests + +### 🎮 Execution Controls +- **Global Test Running State**: All play buttons automatically disable during test execution to prevent conflicts +- **Immediate Feedback**: Spinner icons update instantly when tests start +- **Actions Tab Auto-Clear**: Execution data automatically clears and refreshes on reruns +- **Metadata Tracking**: Test duration, status, and execution timestamps + +### 🏗️ Architecture +- **Frontend**: Lit web components with reactive state management (@lit/context) +- **Backend**: Fastify server with WebSocket streaming for real-time updates +- **Service**: WebdriverIO reporter integration with stable UID generation +- **Process Management**: Tree-kill for proper cleanup of spawned processes + +## Installation + +```bash +npm install @wdio/devtools-service +``` + +## Configuration + +Add the service to your `wdio.conf.js`: + +```javascript +export const config = { + // ... + services: ['devtools'] +} +``` + +## Usage + +1. Run your WebdriverIO tests with the devtools service enabled +2. Open `http://localhost:3000` in your browser +3. View real-time test execution with live browser preview +4. Click play buttons on any test or suite to rerun selectively +5. Click stop button to terminate running tests +6. Explore actions, metadata, and console logs in the workbench tabs + +## Development + +```bash +# Install dependencies +pnpm install + +# Build all packages +pnpm build + +# Run demo +pnpm demo +``` + +## Project Structure + +``` +packages/ +├── app/ # Frontend Lit-based UI application +├── backend/ # Fastify server with test runner management +├── service/ # WebdriverIO service and reporter +└── script/ # Browser-injected trace collection script +``` + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +MIT diff --git a/packages/app/package.json b/packages/app/package.json index 6a41d2c..ee9ef9e 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@wdio/devtools-app", - "version": "1.0.1", + "version": "1.1.0", "description": "Browser devtools extension for debugging WebdriverIO tests.", "type": "module", "exports": "./src/index.ts", diff --git a/packages/app/src/app.ts b/packages/app/src/app.ts index ddd5568..25f3d83 100644 --- a/packages/app/src/app.ts +++ b/packages/app/src/app.ts @@ -52,6 +52,10 @@ export class WebdriverIODevtoolsApplication extends Element { connectedCallback(): void { super.connectedCallback() window.addEventListener('load-trace', this.#loadTrace.bind(this)) + this.addEventListener( + 'clear-execution-data', + this.#clearExecutionData.bind(this) + ) } render() { @@ -66,6 +70,10 @@ export class WebdriverIODevtoolsApplication extends Element { this.requestUpdate() } + #clearExecutionData({ detail }: { detail?: { uid?: string } }) { + this.dataManager.clearExecutionData(detail?.uid) + } + #mainContent() { if (!this.dataManager.hasConnection) { return html`` diff --git a/packages/app/src/components/sidebar/constants.ts b/packages/app/src/components/sidebar/constants.ts new file mode 100644 index 0000000..85ff538 --- /dev/null +++ b/packages/app/src/components/sidebar/constants.ts @@ -0,0 +1,10 @@ +import type { RunCapabilities } from './types.js' + +export const DEFAULT_CAPABILITIES: RunCapabilities = { + canRunSuites: true, + canRunTests: true +} + +export const FRAMEWORK_CAPABILITIES: Record = { + cucumber: { canRunSuites: true, canRunTests: false } +} diff --git a/packages/app/src/components/sidebar/explorer.ts b/packages/app/src/components/sidebar/explorer.ts index bbeee9d..0d2835f 100644 --- a/packages/app/src/components/sidebar/explorer.ts +++ b/packages/app/src/components/sidebar/explorer.ts @@ -3,9 +3,21 @@ import { html, css, nothing, type TemplateResult } from 'lit' import { customElement } from 'lit/decorators.js' import { consume } from '@lit/context' import type { TestStats, SuiteStats } from '@wdio/reporter' +import type { Metadata } from '@wdio/devtools-service/types' import { repeat } from 'lit/directives/repeat.js' -import { TestState } from './test-suite.js' -import { suiteContext } from '../../controller/DataManager.js' +import { + suiteContext, + metadataContext, + isTestRunningContext +} from '../../controller/DataManager.js' +import type { + TestEntry, + RunCapabilities, + RunnerOptions, + TestRunDetail +} from './types.js' +import { TestState } from './types.js' +import { DEFAULT_CAPABILITIES, FRAMEWORK_CAPABILITIES } from './constants.js' import '~icons/mdi/play.js' import '~icons/mdi/stop.js' @@ -19,17 +31,12 @@ import type { DevtoolsSidebarFilter } from './filter.js' const EXPLORER = 'wdio-devtools-sidebar-explorer' -interface TestEntry { - uid: string - state?: string - label: string - callSource?: string - children: TestEntry[] -} - @customElement(EXPLORER) export class DevtoolsSidebarExplorer extends CollapseableEntry { #testFilter: DevtoolsSidebarFilter | undefined + #filterListener = this.#filterTests.bind(this) + #runListener = this.#handleTestRun.bind(this) + #stopListener = this.#handleTestStop.bind(this) static styles = [ ...Element.styles, @@ -58,9 +65,27 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { @consume({ context: suiteContext, subscribe: true }) suites: Record[] | undefined = undefined + @consume({ context: metadataContext, subscribe: true }) + metadata: Metadata | undefined = undefined + + @consume({ context: isTestRunningContext, subscribe: true }) + isTestRunning = false + connectedCallback(): void { super.connectedCallback() - window.addEventListener('app-test-filter', this.#filterTests.bind(this)) + 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) + } + + disconnectedCallback(): void { + super.disconnectedCallback() + window.removeEventListener('app-test-filter', this.#filterListener) + this.removeEventListener('app-test-run', this.#runListener as EventListener) + this.removeEventListener( + 'app-test-stop', + this.#stopListener as EventListener + ) } #filterTests({ detail }: { detail: DevtoolsSidebarFilter }) { @@ -68,11 +93,200 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { this.requestUpdate() } + async #handleTestRun(event: Event) { + event.stopPropagation() + const detail = (event as CustomEvent).detail + if (this.#isRunDisabledDetail(detail)) { + this.#surfaceCapabilityWarning(detail) + return + } + + // Clear execution data before triggering rerun + this.dispatchEvent( + new CustomEvent('clear-execution-data', { + detail: { uid: detail.uid }, + bubbles: true, + composed: true + }) + ) + + const payload = { + ...detail, + runAll: detail.uid === '*', + framework: this.#getFramework(), + specFile: detail.specFile || this.#deriveSpecFile(detail), + configFile: this.#getConfigPath() + } + await this.#postToBackend('/api/tests/run', payload) + } + + async #handleTestStop(event: Event) { + event.stopPropagation() + await this.#postToBackend('/api/tests/stop', {}) + } + + async #postToBackend(path: string, body: Record) { + try { + const response = await fetch(path, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify(body) + }) + if (!response.ok) { + const errorText = await response.text() + throw new Error(errorText || 'Unknown error') + } + } catch (error) { + console.error('Failed to communicate with backend', error) + window.dispatchEvent( + new CustomEvent('app-logs', { + detail: `Test runner error: ${(error as Error).message}` + }) + ) + } + } + + #deriveSpecFile(detail: TestRunDetail) { + if (detail.specFile) { + return detail.specFile + } + const source = detail.callSource + if (source?.startsWith('file://')) { + try { + return new URL(source).pathname + } catch { + return source + } + } + if (source) { + const match = source.match(/^(.*?):\d+:\d+$/) + if (match?.[1]) { + return match[1] + } + return source + } + + return undefined + } + + #runAllSuites() { + if (!this.#getRunCapabilities().canRunSuites) { + this.#surfaceCapabilityWarning({ + entryType: 'suite', + uid: '*' + } as TestRunDetail) + return + } + + // Clear execution data and mark all tests as running + this.dispatchEvent( + new CustomEvent('clear-execution-data', { + detail: { uid: '*' }, + bubbles: true, + composed: true + }) + ) + + void this.#postToBackend('/api/tests/run', { + uid: '*', + entryType: 'suite', + runAll: true, + framework: this.#getFramework(), + configFile: this.#getConfigPath() + }) + } + + #stopActiveRun() { + void this.#postToBackend('/api/tests/stop', { + uid: '*' + }) + } + + #getFramework(): string | undefined { + return this.#getRunnerOptions()?.framework + } + + #getRunnerOptions(): RunnerOptions | undefined { + return this.metadata?.options as RunnerOptions | undefined + } + + #getRunCapabilities(): RunCapabilities { + const options = this.#getRunnerOptions() + if (options?.runCapabilities) { + return { + ...DEFAULT_CAPABILITIES, + ...options.runCapabilities + } + } + const framework = options?.framework?.toLowerCase() ?? '' + return FRAMEWORK_CAPABILITIES[framework] || DEFAULT_CAPABILITIES + } + + #isRunDisabled(entry: TestEntry) { + const caps = this.#getRunCapabilities() + if (entry.type === 'test' && !caps.canRunTests) { + return true + } + if (entry.type === 'suite' && !caps.canRunSuites) { + return true + } + return false + } + + #isRunDisabledDetail(detail: TestRunDetail) { + const caps = this.#getRunCapabilities() + if (detail.entryType === 'test' && !caps.canRunTests) { + return true + } + if (detail.entryType === 'suite' && !caps.canRunSuites) { + return true + } + return false + } + + #surfaceCapabilityWarning(detail: TestRunDetail) { + const message = + detail.entryType === 'test' + ? 'Single-test execution is not supported by this framework.' + : 'Suite execution is disabled by this framework.' + window.dispatchEvent( + new CustomEvent('app-logs', { + detail: message + }) + ) + } + + #getRunDisabledReason(entry: TestEntry) { + if (!this.#isRunDisabled(entry)) { + return undefined + } + return entry.type === 'test' + ? 'Single-test execution is not supported by this framework.' + : 'Suite execution is not supported by this framework.' + } + + #getConfigPath(): string | undefined { + const options = this.#getRunnerOptions() + return options?.configFilePath || options?.configFile + } + #renderEntry(entry: TestEntry): TemplateResult { return html` ${entry.children && entry.children.length @@ -101,11 +315,9 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { return ( Boolean( ['all', 'none'].includes(this.#testFilter.filterStatus) || - (entry.state === TestState.PASSED && - this.#testFilter.filtersPassed) || - (entry.state === TestState.FAILED && - this.#testFilter.filtersFailed) || - (entry.state === TestState.SKIPPED && this.#testFilter.filtersSkipped) + (entry.state === TestState.PASSED && this.#testFilter.filtersPassed) || + (entry.state === TestState.FAILED && this.#testFilter.filtersFailed) || + (entry.state === TestState.SKIPPED && this.#testFilter.filtersSkipped) ) && (!this.#testFilter.filterQuery || entryLabelIncludingChildren @@ -120,12 +332,18 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { 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, callSource: (entry as any).callSource, + specFile: (entry as any).file, + fullTitle: entry.title, + featureFile: (entry as any).featureFile, + featureLine: (entry as any).featureLine, + suiteType: (entry as any).type, children: Object.values(entries) .map(this.#getTestEntry.bind(this)) .filter(this.#filterEntry.bind(this)) @@ -134,12 +352,17 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { return { uid: entry.uid, label: entry.title, + type: 'test', state: !entry.end ? TestState.RUNNING : entry.state === 'failed' ? TestState.FAILED : TestState.PASSED, callSource: (entry as any).callSource, + specFile: (entry as any).file, + fullTitle: (entry as any).fullTitle || entry.title, + featureFile: (entry as any).featureFile, + featureLine: (entry as any).featureLine, children: [] } } @@ -149,12 +372,10 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry { return } - // ✅ Only root suites (no parent = true top-level suite) const rootSuites = this.suites .flatMap((s) => Object.values(s)) .filter((suite) => !suite.parent) - // Deduplicate by uid (in case some frameworks still push duplicates) const uniqueSuites = Array.from( new Map(rootSuites.map((suite) => [suite.uid, suite])).values() ) @@ -171,11 +392,13 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {