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 {