diff --git a/example/wdio.conf.ts b/example/wdio.conf.ts
index 7401cae..3cbc907 100644
--- a/example/wdio.conf.ts
+++ b/example/wdio.conf.ts
@@ -63,13 +63,13 @@ 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',
'--disable-gpu',
'--remote-allow-origins=*',
- '--window-size=1280,800'
+ '--window-size=1600,1200'
]
}
// }, {
diff --git a/package.json b/package.json
index 20ae232..1cf55c4 100644
--- a/package.json
+++ b/package.json
@@ -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",
@@ -17,7 +18,10 @@
"pnpm": {
"overrides": {
"vite": "^7.3.0"
- }
+ },
+ "ignoredBuiltDependencies": [
+ "chromedriver"
+ ]
},
"devDependencies": {
"@types/node": "^25.0.3",
diff --git a/packages/app/src/components/browser/snapshot.ts b/packages/app/src/components/browser/snapshot.ts
index d852f1a..94413e5 100644
--- a/packages/app/src/components/browser/snapshot.ts
+++ b/packages/app/src/components/browser/snapshot.ts
@@ -11,7 +11,8 @@ import {
mutationContext,
type TraceMutation,
metadataContext,
- type Metadata
+ type Metadata,
+ commandContext
} from '../../controller/DataManager.js'
import '~icons/mdi/world.js'
@@ -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)
}
@@ -44,6 +46,8 @@ 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
@@ -51,6 +55,9 @@ export class DevtoolsBrowser extends Element {
@consume({ context: mutationContext, subscribe: true })
mutations: TraceMutation[] = []
+ @consume({ context: commandContext, subscribe: true })
+ commands: CommandLog[] = []
+
static styles = [
...Element.styles,
css`
@@ -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;
+ }
`
]
@@ -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()
@@ -178,23 +217,8 @@ export class DevtoolsBrowser extends Element {
)
async #renderCommandScreenshot(command?: CommandLog) {
- const screenshot = command?.screenshot
- if (!screenshot) {
- return
- }
-
- if (!this.iframe) {
- await this.updateComplete
- }
- if (!this.iframe) {
- return
- }
-
- this.iframe.srcdoc = `
-
-
-
- `
+ this.#screenshotData = command?.screenshot ?? null
+ this.requestUpdate()
}
async #renderNewDocument(doc: SimplifiedVNode, baseUrl: string) {
@@ -270,7 +294,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
@@ -389,6 +417,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
@@ -398,6 +435,10 @@ export class DevtoolsBrowser extends Element {
this.#renderBrowserState()
}
+ const hasMutations = this.mutations && this.mutations.length
+ const autoScreenshot = hasMutations ? null : this.#latestAutoScreenshot
+ const displayScreenshot = this.#screenshotData ?? autoScreenshot
+
return html`
`
}
diff --git a/packages/app/src/components/sidebar/constants.ts b/packages/app/src/components/sidebar/constants.ts
index 85ff538..d5f9290 100644
--- a/packages/app/src/components/sidebar/constants.ts
+++ b/packages/app/src/components/sidebar/constants.ts
@@ -1,3 +1,11 @@
+import { TestState } from './types.js'
+
+export const STATE_MAP: Record = {
+ running: TestState.RUNNING,
+ failed: TestState.FAILED,
+ passed: TestState.PASSED,
+ skipped: TestState.SKIPPED
+}
import type { RunCapabilities } from './types.js'
export const DEFAULT_CAPABILITIES: RunCapabilities = {
diff --git a/packages/app/src/components/sidebar/explorer.ts b/packages/app/src/components/sidebar/explorer.ts
index 0d2835f..2f862c6 100644
--- a/packages/app/src/components/sidebar/explorer.ts
+++ b/packages/app/src/components/sidebar/explorer.ts
@@ -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'
@@ -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'
@@ -63,6 +67,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
]
@consume({ context: suiteContext, subscribe: true })
+ @property({ type: Array })
suites: Record[] | undefined = undefined
@consume({ context: metadataContext, subscribe: true })
@@ -71,6 +76,10 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
@consume({ context: isTestRunningContext, subscribe: true })
isTestRunning = false
+ updated(changedProperties: Map) {
+ super.updated(changedProperties)
+ }
+
connectedCallback(): void {
super.connectedCallback()
window.addEventListener('app-test-filter', this.#filterListener)
@@ -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)}
>
@@ -326,6 +336,62 @@ 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]
@@ -333,11 +399,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
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,
@@ -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,
@@ -421,9 +479,13 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
(suite) => suite.uid,
(suite) => this.#renderEntry(suite)
)
- : html`
- No tests found
-
`}
+ : html`
+
No tests to display
+
+ Debug: suites=${this.suites?.length || 0},
+ rootSuites=${uniqueSuites.length}, filtered=${suites.length}
+
+
`}
`
}
diff --git a/packages/app/src/components/sidebar/test-suite.ts b/packages/app/src/components/sidebar/test-suite.ts
index 428a267..934bf21 100644
--- a/packages/app/src/components/sidebar/test-suite.ts
+++ b/packages/app/src/components/sidebar/test-suite.ts
@@ -80,6 +80,9 @@ export class ExplorerTestEntry extends CollapseableEntry {
@property({ type: String, attribute: 'suite-type' })
suiteType?: string
+ @property({ type: Boolean, attribute: 'has-children' })
+ hasChildren = false
+
static styles = [
...Element.styles,
css`
@@ -206,8 +209,7 @@ export class ExplorerTestEntry extends CollapseableEntry {
}
render() {
- const hasNoChildren =
- this.querySelectorAll('[slot="children"]').length === 0
+ const hasNoChildren = !this.hasChildren
const isCollapsed = this.isCollapsed === 'true'
const runTooltip = this.runDisabled
? this.runDisabledReason ||
diff --git a/packages/app/src/components/workbench/actions.ts b/packages/app/src/components/workbench/actions.ts
index eef55c8..9a3be6c 100644
--- a/packages/app/src/components/workbench/actions.ts
+++ b/packages/app/src/components/workbench/actions.ts
@@ -44,7 +44,12 @@ export class DevtoolsActions extends Element {
render() {
const mutations = this.mutations || []
const commands = this.commands || []
- const entries = [...mutations, ...commands].sort(
+ // Only show document-load mutations (childList with a url) in the actions
+ // list — individual node add/remove mutations are too noisy.
+ const visibleMutations = mutations.filter(
+ (m) => m.type === 'childList' && Boolean(m.url)
+ )
+ const entries = [...visibleMutations, ...commands].sort(
(a, b) => a.timestamp - b.timestamp
)
diff --git a/packages/app/src/components/workbench/logs.ts b/packages/app/src/components/workbench/logs.ts
index 89f7445..ac9779c 100644
--- a/packages/app/src/components/workbench/logs.ts
+++ b/packages/app/src/components/workbench/logs.ts
@@ -120,13 +120,15 @@ export class DevtoolsSource extends Element {
{} as Record
)}"
>
-
+ ${this.command.result !== null && this.command.result !== undefined
+ ? html``
+ : ''}
`
}
}
diff --git a/packages/app/src/components/workbench/metadata.ts b/packages/app/src/components/workbench/metadata.ts
index f154be2..35fca1c 100644
--- a/packages/app/src/components/workbench/metadata.ts
+++ b/packages/app/src/components/workbench/metadata.ts
@@ -33,20 +33,50 @@ export class DevtoolsMetadata extends Element {
return html``
}
- const { url } = this.metadata
+ const m = this.metadata as any
+ const sessionInfo: Record = {}
+ if (m.sessionId) {
+ sessionInfo['Session ID'] = m.sessionId
+ }
+ if (m.testEnv) {
+ sessionInfo.Environment = m.testEnv
+ }
+ if (m.host) {
+ sessionInfo['WebDriver Host'] = m.host
+ }
+ if (m.modulePath) {
+ sessionInfo['Test File'] = m.modulePath
+ }
+ if (m.url) {
+ sessionInfo.URL = m.url
+ }
+
+ const caps = m.capabilities || {}
+ const desiredCaps = m.desiredCapabilities || {}
+
return html`
-
+ ${Object.keys(sessionInfo).length
+ ? html``
+ : ''}
-
+ ${Object.keys(desiredCaps).length
+ ? html``
+ : ''}
+ ${m.options && Object.keys(m.options).length
+ ? html``
+ : ''}
`
}
}
diff --git a/packages/app/src/controller/DataManager.ts b/packages/app/src/controller/DataManager.ts
index e4c546d..a9c8089 100644
--- a/packages/app/src/controller/DataManager.ts
+++ b/packages/app/src/controller/DataManager.ts
@@ -49,10 +49,24 @@ export const isTestRunningContext = createContext(
)
interface SocketMessage<
- T extends keyof TraceLog | 'testStopped' = keyof TraceLog | 'testStopped'
+ T extends
+ | keyof TraceLog
+ | 'testStopped'
+ | 'clearExecutionData'
+ | 'replaceCommand' =
+ | keyof TraceLog
+ | 'testStopped'
+ | 'clearExecutionData'
+ | 'replaceCommand'
> {
scope: T
- data: T extends keyof TraceLog ? TraceLog[T] : unknown
+ data: T extends keyof TraceLog
+ ? TraceLog[T]
+ : T extends 'clearExecutionData'
+ ? { uid?: string }
+ : T extends 'replaceCommand'
+ ? { oldTimestamp: number; command: CommandLog }
+ : unknown
}
export class DataManagerController implements ReactiveController {
@@ -270,6 +284,25 @@ export class DataManagerController implements ReactiveController {
return
}
+ // Handle clear execution data event (when tests change)
+ if (scope === 'clearExecutionData') {
+ const clearData = data as { uid?: string }
+ this.clearExecutionData(clearData.uid)
+ this.#host.requestUpdate()
+ return
+ }
+
+ // Handle in-place command replacement (retry deduplication)
+ if (scope === 'replaceCommand') {
+ const { oldTimestamp, command } = data as {
+ oldTimestamp: number
+ command: CommandLog
+ }
+ this.#handleReplaceCommand(oldTimestamp, command)
+ this.#host.requestUpdate()
+ return
+ }
+
// Check for new run BEFORE processing suites data
if (scope === 'suites') {
const shouldReset = this.#shouldResetForNewRun(data)
@@ -323,12 +356,41 @@ export class DataManagerController implements ReactiveController {
? suite.start.getTime()
: typeof suite.start === 'number'
? suite.start
- : 0
+ : typeof suite.start === 'string'
+ ? new Date(suite.start as string).getTime() || 0
+ : 0
- // New run detected if we see a newer start timestamp
+ if (suiteStartTime <= 0) {
+ continue
+ }
+
+ // New run detected if we see a newer start timestamp.
+ // Exception: if the existing suite for this uid has no end time, it is
+ // still an ongoing run (e.g. a Cucumber feature spanning multiple
+ // scenarios) — treat it as a continuation, not a new run.
if (suiteStartTime > this.#lastSeenRunTimestamp) {
+ const existingChunks = this.suitesContextProvider.value || []
+ let existingEnd: unknown = undefined
+ outer: for (const ec of existingChunks) {
+ for (const [uid, existing] of Object.entries(
+ ec as Record
+ )) {
+ if (uid === Object.keys(chunk)[0]) {
+ existingEnd = (existing as any)?.end
+ break outer
+ }
+ }
+ }
+ // Only reset if the previous run was already finished (had an end time).
+ // An ongoing run (end == null / undefined) is just a continuation.
+ const previousRunFinished =
+ existingEnd !== null && existingEnd !== undefined
+ if (previousRunFinished) {
+ this.#lastSeenRunTimestamp = suiteStartTime
+ return true
+ }
+ // Continuation — update tracking timestamp but do NOT reset
this.#lastSeenRunTimestamp = suiteStartTime
- return true
}
}
}
@@ -413,6 +475,20 @@ export class DataManagerController implements ReactiveController {
])
}
+ #handleReplaceCommand(oldTimestamp: number, newCommand: CommandLog) {
+ const current = this.commandsContextProvider.value || []
+ // Find the last entry with the matching timestamp (most recent retry)
+ const idx = current.map((c) => c.timestamp).lastIndexOf(oldTimestamp)
+ if (idx !== -1) {
+ const updated = [...current]
+ updated[idx] = newCommand
+ this.commandsContextProvider.setValue(updated)
+ } else {
+ // No matching entry found — just append
+ this.commandsContextProvider.setValue([...current, newCommand])
+ }
+ }
+
#handleConsoleLogsUpdate(data: string[]) {
this.consoleLogsContextProvider.setValue([
...(this.consoleLogsContextProvider.value || []),
diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts
index 89c2809..6e162b5 100644
--- a/packages/backend/src/index.ts
+++ b/packages/backend/src/index.ts
@@ -30,9 +30,19 @@ export function broadcastToClients(message: string) {
})
}
-export async function start(opts: DevtoolsBackendOptions = {}) {
+export async function start(
+ opts: DevtoolsBackendOptions = {}
+): Promise<{ server: FastifyInstance; port: number }> {
const host = opts.hostname || 'localhost'
- const port = opts.port || (await getPort({ port: DEFAULT_PORT }))
+ // Use getPort to find an available port, starting with the preferred port
+ const preferredPort = opts.port || DEFAULT_PORT
+ const port = await getPort({ port: preferredPort })
+
+ // Log if we had to use a different port
+ if (opts.port && port !== opts.port) {
+ log.warn(`Port ${opts.port} is already in use, using port ${port} instead`)
+ }
+
const appPath = await getDevtoolsApp()
server = Fastify({ logger: true })
@@ -91,6 +101,34 @@ export async function start(opts: DevtoolsBackendOptions = {}) {
log.info(
`received ${message.length} byte message from worker to ${clients.size} client${clients.size > 1 ? 's' : ''}`
)
+
+ // Parse message to check if it's a clearCommands message
+ try {
+ const parsed = JSON.parse(message.toString())
+
+ // If this is a clearCommands message, transform it to clear-execution-data format
+ if (parsed.scope === 'clearCommands') {
+ const testUid = parsed.data?.testUid
+ log.info(`Clearing commands for test: ${testUid || 'all'}`)
+
+ // Create a synthetic message that DataManager will understand
+ const clearMessage = JSON.stringify({
+ scope: 'clearExecutionData',
+ data: { uid: testUid }
+ })
+
+ clients.forEach((client) => {
+ if (client.readyState === WebSocket.OPEN) {
+ client.send(clearMessage)
+ }
+ })
+ return
+ }
+ } catch {
+ // Not JSON or parsing failed, forward as-is
+ }
+
+ // Forward all other messages as-is
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message.toString())
@@ -102,7 +140,7 @@ export async function start(opts: DevtoolsBackendOptions = {}) {
log.info(`Starting WebdriverIO Devtools application on port ${port}`)
await server.listen({ port, host })
- return server
+ return { server, port }
}
export async function stop() {
@@ -111,8 +149,19 @@ export async function stop() {
}
log.info('Shutting down WebdriverIO Devtools application')
- await server.close()
+
+ // Close all WebSocket connections first
+ clients.forEach((client) => {
+ if (
+ client.readyState === WebSocket.OPEN ||
+ client.readyState === WebSocket.CONNECTING
+ ) {
+ client.terminate()
+ }
+ })
clients.clear()
+
+ await server.close()
}
/**
diff --git a/packages/backend/tests/index.test.ts b/packages/backend/tests/index.test.ts
index 7e7d0a7..2cb81ee 100644
--- a/packages/backend/tests/index.test.ts
+++ b/packages/backend/tests/index.test.ts
@@ -29,7 +29,7 @@ describe('backend index', () => {
describe('API endpoints', () => {
it('should handle test run and stop requests with validation', async () => {
vi.mocked(utils.getDevtoolsApp).mockResolvedValue('/mock/app/path')
- const server = await start({ port: 0 })
+ const { server } = await start({ port: 0 })
const { testRunner } = await import('../src/runner.js')
const runSpy = vi.spyOn(testRunner, 'run').mockResolvedValue()
const stopSpy = vi.spyOn(testRunner, 'stop')
@@ -83,7 +83,7 @@ describe('backend index', () => {
it('should handle test run errors gracefully', async () => {
vi.mocked(utils.getDevtoolsApp).mockResolvedValue('/mock/app/path')
- const server = await start({ port: 0 })
+ const { server } = await start({ port: 0 })
const { testRunner } = await import('../src/runner.js')
vi.spyOn(testRunner, 'run').mockRejectedValue(
new Error('Test execution failed')
diff --git a/packages/nightwatch-devtools/.gitignore b/packages/nightwatch-devtools/.gitignore
new file mode 100644
index 0000000..f68e975
--- /dev/null
+++ b/packages/nightwatch-devtools/.gitignore
@@ -0,0 +1,36 @@
+# Build output
+dist/
+*.tsbuildinfo
+
+# Dependencies
+node_modules/
+
+# Test outputs
+tests_output/
+logs/
+example/logs/
+
+# Trace files
+*-trace-*.json
+nightwatch-trace-*.json
+
+# Log files
+*.log
+npm-debug.log*
+pnpm-debug.log*
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Temporary files
+*.tmp
+*.temp
+*.bak
diff --git a/packages/nightwatch-devtools/README.md b/packages/nightwatch-devtools/README.md
new file mode 100644
index 0000000..79932c0
--- /dev/null
+++ b/packages/nightwatch-devtools/README.md
@@ -0,0 +1,122 @@
+# @wdio/nightwatch-devtools
+
+> Nightwatch adapter for WebdriverIO DevTools - Visual debugging UI for your Nightwatch tests
+
+## What is this?
+
+Brings the powerful WebdriverIO DevTools visual debugging interface to Nightwatch tests with **zero test code changes**.
+
+See everything in real-time:
+- 📋 **Commands** - Every action executed
+- 🖥️ **Console** - Browser console logs
+- 🌐 **Network** - HTTP requests/responses
+- ✅ **Tests** - Suite structure and results
+- 📁 **Sources** - Test file contents
+- 📝 **Logs** - Framework debugging
+
+## Installation
+
+```bash
+npm install @wdio/nightwatch-devtools --save-dev
+# or
+pnpm add -D @wdio/nightwatch-devtools
+```
+
+## Usage
+
+Add to your Nightwatch config:
+
+```javascript
+// nightwatch.conf.js
+const nightwatchDevtools = require('@wdio/nightwatch-devtools').default;
+
+module.exports = {
+ src_folders: ['tests'],
+
+ test_settings: {
+ default: {
+ desiredCapabilities: {
+ browserName: 'chrome'
+ },
+ // Add DevTools globals with lifecycle hooks
+ globals: nightwatchDevtools()
+ }
+ }
+}
+```
+
+Run your tests:
+
+```bash
+nightwatch
+```
+
+The DevTools UI will automatically:
+1. Start backend server on port 3000
+2. Open in a new browser window
+3. Stream test data in real-time
+4. Stay open after tests finish (close manually to exit)
+
+## Example
+
+See [`example/`](./example) directory for a working sample with:
+- Sample test suite
+- Nightwatch configuration
+- Setup instructions
+
+Run it:
+```bash
+cd packages/nightwatch-devtools
+pnpm build
+pnpm example # Run tests with DevTools UI
+```
+
+## How It Works
+
+This is a **thin adapter** (~210 lines) that:
+
+1. ✅ Reuses `@wdio/devtools-backend` - Fastify server + WebSocket
+2. ✅ Reuses `@wdio/devtools-app` - Lit-based UI components
+3. ✅ Reuses `@wdio/devtools-script` - Browser capture
+4. ✅ Adds only Nightwatch lifecycle hooks: `before`, `beforeSuite`, `beforeEach`, `afterEach`, `after`
+
+Same backend, same UI, same capture as WDIO - just different framework hooks!
+
+## Options
+
+```javascript
+const nightwatchDevtools = require('@wdio/nightwatch-devtools').default;
+
+module.exports = {
+ test_settings: {
+ default: {
+ globals: nightwatchDevtools({
+ port: 3000, // DevTools server port (default: 3000)
+ hostname: 'localhost' // DevTools server hostname (default: 'localhost')
+ })
+ }
+ }
+}
+```
+
+## What Gets Captured
+
+✅ Test suites and hierarchy
+✅ Test pass/fail status
+✅ Execution timing
+✅ Error messages and stack traces
+✅ Browser console logs (automatic)
+✅ Network requests (automatic)
+✅ DOM mutations (automatic)
+
+Browser-side capture works automatically via `@wdio/devtools-script`.
+
+## Requirements
+
+- **Nightwatch**: >= 3.0.0
+- **Node.js**: >= 18.0.0
+- **Chrome/Chromium**: For tests and UI
+
+## License
+
+MIT
diff --git a/packages/nightwatch-devtools/example/README.md b/packages/nightwatch-devtools/example/README.md
new file mode 100644
index 0000000..b6e7b09
--- /dev/null
+++ b/packages/nightwatch-devtools/example/README.md
@@ -0,0 +1,135 @@
+# Nightwatch DevTools Example
+
+This example demonstrates the `@wdio/nightwatch-devtools` plugin in action.
+
+## Prerequisites
+
+Make sure you have Chrome/Chromium installed on your system. The example uses Nightwatch's built-in chromedriver manager.
+
+## Setup
+
+1. Build the plugin:
+```bash
+cd packages/nightwatch-devtools
+pnpm build
+```
+
+2. Install dependencies:
+```bash
+pnpm install
+```
+
+## Running the Example
+
+### Option 1: Automatic (Recommended)
+
+Run the example tests with DevTools UI:
+
+```bash
+pnpm example
+```
+
+### Option 2: Manual Setup
+
+If you encounter chromedriver issues, you can:
+
+1. **Install chromedriver globally:**
+```bash
+npm install -g chromedriver
+```
+
+2. **Or download Chrome for Testing:**
+Visit: https://googlechromelabs.github.io/chrome-for-testing/
+
+3. **Update nightwatch.conf.cjs** with your chromedriver path:
+```javascript
+webdriver: {
+ start_process: true,
+ server_path: '/path/to/chromedriver',
+ port: 9515
+}
+```
+
+## What Happens
+
+When you run the example, the plugin will:
+
+1. ✅ Start the DevTools backend server on port 3000
+2. ✅ Open the DevTools UI in a new browser window
+3. ✅ Run your Nightwatch tests
+4. ✅ Stream all commands, logs, and results to the UI in real-time
+5. ✅ Keep the UI open until you close the browser window
+
+## What You'll See in the DevTools UI
+
+- **Commands Tab**: Every Nightwatch command executed (url, click, assert, etc.)
+- **Console Tab**: Browser console logs
+- **Network Tab**: All HTTP requests made during tests
+- **Tests Tab**: Test suite structure and results (pass/fail)
+- **Metadata Tab**: Session information and test timing
+- **Sources Tab**: Test file sources
+- **Logs Tab**: Framework logs and debugging information
+
+## Zero Test Changes Required
+
+Notice the test files in `example/tests/` have **zero DevTools-specific code**. They're pure Nightwatch tests. The plugin automatically:
+- Hooks into Nightwatch's lifecycle
+- Captures all test data
+- Sends it to the DevTools backend
+- Updates the UI in real-time
+
+## Configuration
+
+### Minimal (Default)
+
+```javascript
+// nightwatch.conf.cjs
+module.exports = {
+ plugins: ['@wdio/nightwatch-devtools']
+}
+```
+
+### Custom Port
+
+```javascript
+module.exports = {
+ plugins: [
+ ['@wdio/nightwatch-devtools', {
+ port: 4000,
+ hostname: 'localhost'
+ }]
+ ]
+}
+```
+
+## Troubleshooting
+
+### "Failed to connect to ChromeDriver"
+
+Make sure chromedriver is installed:
+```bash
+pnpm install chromedriver
+# Then rebuild it
+pnpm rebuild chromedriver
+```
+
+Or install globally:
+```bash
+npm install -g chromedriver
+```
+
+### "Module not found"
+
+Make sure you've built the plugin:
+```bash
+pnpm build
+```
+
+### Port Already in Use
+
+Change the port in your config:
+```javascript
+plugins: [
+ ['@wdio/nightwatch-devtools', { port: 4000 }]
+]
+```
diff --git a/packages/nightwatch-devtools/example/nightwatch.conf.cjs b/packages/nightwatch-devtools/example/nightwatch.conf.cjs
new file mode 100644
index 0000000..0a15445
--- /dev/null
+++ b/packages/nightwatch-devtools/example/nightwatch.conf.cjs
@@ -0,0 +1,33 @@
+// Simple import - just require the package
+const nightwatchDevtools = require('@wdio/nightwatch-devtools').default
+
+module.exports = {
+ src_folders: ['example/tests'],
+ output_folder: false, // Skip generating nightwatch reports for this example
+ // Add custom reporter to capture commands
+ custom_commands_path: [],
+ custom_assertions_path: [],
+
+ webdriver: {
+ start_process: true,
+ server_path: '/opt/homebrew/bin/chromedriver',
+ port: 9515
+ },
+
+ test_settings: {
+ default: {
+ // Ensure all tests run even if one fails
+ skip_testcases_on_fail: false,
+
+ desiredCapabilities: {
+ browserName: 'chrome',
+ 'goog:chromeOptions': {
+ args: ['--headless', '--no-sandbox', '--disable-dev-shm-usage', '--window-size=1600,1200']
+ },
+ 'goog:loggingPrefs': { performance: 'ALL' }
+ },
+ // Simple configuration - just call the function to get globals
+ globals: nightwatchDevtools({ port: 3000 })
+ }
+ }
+}
diff --git a/packages/nightwatch-devtools/example/tests/login.js b/packages/nightwatch-devtools/example/tests/login.js
new file mode 100644
index 0000000..ca2ae78
--- /dev/null
+++ b/packages/nightwatch-devtools/example/tests/login.js
@@ -0,0 +1,43 @@
+describe('The Internet Guinea Pig Website', function () {
+ it('should log into the secure area with valid credentials', async function (browser) {
+ console.log('[TEST] Navigating to login page')
+ browser
+ .url('https://the-internet.herokuapp.com/login')
+ .waitForElementVisible('body')
+
+ console.log('[TEST] Attempting login with username: tomsmith')
+ await browser
+ .setValue('#username', 'tomsmith')
+ .setValue('#password', 'SuperSecretPassword!')
+ .click('button[type="submit"]')
+
+ console.log(
+ '[TEST] Verifying flash message: You logged into a secure area!'
+ )
+ await browser
+ .waitForElementVisible('#flash')
+ .assert.textContains('#flash', 'You logged into a secure area!')
+
+ console.log('[TEST] Flash message verified successfully')
+ })
+
+ it('should show error with invalid credentials', async function (browser) {
+ console.log('[TEST] Navigating to login page')
+ await browser
+ .url('https://the-internet.herokuapp.com/login')
+ .waitForElementVisible('body')
+
+ console.log('[TEST] Attempting login with username: foobar')
+ await browser
+ .setValue('#username', 'foobar')
+ .setValue('#password', 'barfoo')
+ .click('button[type="submit"]')
+
+ console.log('[TEST] Verifying flash message: Your username is invalid!')
+ await browser
+ .waitForElementVisible('.flash', 5000)
+ .assert.textContains('.flash', 'Your username is invalid!')
+
+ console.log('[TEST] Flash message verified successfully')
+ })
+})
diff --git a/packages/nightwatch-devtools/example/tests/sample.js b/packages/nightwatch-devtools/example/tests/sample.js
new file mode 100644
index 0000000..ff2e4b2
--- /dev/null
+++ b/packages/nightwatch-devtools/example/tests/sample.js
@@ -0,0 +1,21 @@
+describe('Sample Nightwatch Test with DevTools', function () {
+ it('should navigate to example.com and check title', async function (browser) {
+ await browser
+ .url('https://example.com')
+ .waitForElementVisible('body', 5000)
+ .assert.titleContains('Example')
+ .assert.visible('h1')
+
+ const result = await browser.getText('h1')
+ browser.assert.ok(result.includes('Example'), 'H1 contains "Example"')
+ })
+
+ it('should perform basic interactions', async function (browser) {
+ await browser
+ .url('https://www.google.com')
+ .waitForElementVisible('body', 5000)
+ .assert.visible('textarea[name="q"]')
+ .setValue('textarea[name="q"]', 'WebdriverIO DevTools')
+ .pause(1000)
+ })
+})
diff --git a/packages/nightwatch-devtools/nightwatch.conf.cjs b/packages/nightwatch-devtools/nightwatch.conf.cjs
new file mode 100644
index 0000000..7c54315
--- /dev/null
+++ b/packages/nightwatch-devtools/nightwatch.conf.cjs
@@ -0,0 +1,362 @@
+//
+// Refer to the online docs for more details:
+// https://nightwatchjs.org/guide/configuration/nightwatch-configuration-file.html
+//
+// _ _ _ _ _ _ _
+// | \ | |(_) | | | | | | | |
+// | \| | _ __ _ | |__ | |_ __ __ __ _ | |_ ___ | |__
+// | . ` || | / _` || '_ \ | __|\ \ /\ / / / _` || __| / __|| '_ \
+// | |\ || || (_| || | | || |_ \ V V / | (_| || |_ | (__ | | | |
+// \_| \_/|_| \__, ||_| |_| \__| \_/\_/ \__,_| \__| \___||_| |_|
+// __/ |
+// |___/
+//
+
+module.exports = {
+ // An array of folders (excluding subfolders) where your tests are located;
+ // if this is not specified, the test source must be passed as the second argument to the test runner.
+ src_folders: [],
+
+ // See https://nightwatchjs.org/guide/concepts/page-object-model.html
+ page_objects_path: ['node_modules/nightwatch/examples/pages/'],
+
+ // See https://nightwatchjs.org/guide/extending-nightwatch/adding-custom-commands.html
+ custom_commands_path: ['node_modules/nightwatch/examples/custom-commands/'],
+
+ // See https://nightwatchjs.org/guide/extending-nightwatch/adding-custom-assertions.html
+ custom_assertions_path: '',
+
+ // See https://nightwatchjs.org/guide/extending-nightwatch/adding-plugins.html
+ plugins: [],
+
+ // See https://nightwatchjs.org/guide/concepts/test-globals.html#external-test-globals
+ globals_path: '',
+
+ // Set this to true to disable bounding boxes on terminal output. Useful when running in some CI environments.
+ disable_output_boxes: false,
+
+ webdriver: {},
+
+ test_workers: {
+ enabled: true,
+ workers: 'auto'
+ },
+
+ test_settings: {
+ default: {
+ disable_error_log: false,
+ launch_url: 'https://nightwatchjs.org',
+
+ screenshots: {
+ enabled: false,
+ path: 'screens',
+ on_failure: true
+ },
+
+ desiredCapabilities: {
+ browserName: 'firefox'
+ },
+
+ webdriver: {
+ start_process: true,
+ server_path: ''
+ }
+ },
+
+ safari: {
+ desiredCapabilities: {
+ browserName: 'safari',
+ alwaysMatch: {
+ acceptInsecureCerts: false
+ }
+ },
+ webdriver: {
+ start_process: true,
+ server_path: ''
+ }
+ },
+
+ firefox: {
+ desiredCapabilities: {
+ browserName: 'firefox',
+ acceptInsecureCerts: true,
+ 'moz:firefoxOptions': {
+ args: [
+ // '-headless',
+ // '-verbose'
+ ]
+ }
+ },
+ webdriver: {
+ start_process: true,
+ server_path: '',
+ cli_args: [
+ // very verbose geckodriver logs
+ // '-vv'
+ ]
+ }
+ },
+
+ chrome: {
+ desiredCapabilities: {
+ browserName: 'chrome',
+ 'goog:chromeOptions': {
+ // More info on Chromedriver: https://sites.google.com/a/chromium.org/chromedriver/
+ //
+ // w3c:false tells Chromedriver to run using the legacy JSONWire protocol (not required in Chrome 78)
+ w3c: true,
+ args: [
+ //'--no-sandbox',
+ //'--ignore-certificate-errors',
+ //'--allow-insecure-localhost',
+ //'--headless'
+ ]
+ }
+ },
+
+ webdriver: {
+ start_process: true,
+ server_path: '',
+ cli_args: [
+ // '--verbose'
+ ]
+ }
+ },
+
+ edge: {
+ desiredCapabilities: {
+ browserName: 'MicrosoftEdge',
+ 'ms:edgeOptions': {
+ w3c: true,
+ // More info on EdgeDriver: https://docs.microsoft.com/en-us/microsoft-edge/webdriver-chromium/capabilities-edge-options
+ args: [
+ //'--headless'
+ ]
+ }
+ },
+
+ webdriver: {
+ start_process: true,
+ // Download msedgedriver from https://docs.microsoft.com/en-us/microsoft-edge/webdriver-chromium/
+ // and set the location below:
+ server_path: '',
+ cli_args: [
+ // '--verbose'
+ ]
+ }
+ },
+
+ //////////////////////////////////////////////////////////////////////////////////
+ // Configuration for when using cucumber-js (https://cucumber.io) |
+ // |
+ // It uses the bundled examples inside the nightwatch examples folder; feel free |
+ // to adapt this to your own project needs |
+ //////////////////////////////////////////////////////////////////////////////////
+ 'cucumber-js': {
+ src_folders: ['examples/cucumber-js/features/step_definitions'],
+
+ test_runner: {
+ // set cucumber as the runner
+ type: 'cucumber',
+
+ // define cucumber specific options
+ options: {
+ //set the feature path
+ feature_path:
+ 'node_modules/nightwatch/examples/cucumber-js/*/*.feature'
+
+ // start the webdriver session automatically (enabled by default)
+ // auto_start_session: true
+
+ // use parallel execution in Cucumber
+ // workers: 2 // set number of workers to use (can also be defined in the cli as --workers=2
+ }
+ }
+ },
+
+ //////////////////////////////////////////////////////////////////////////////////
+ // Configuration for when using the browserstack.com cloud service |
+ // |
+ // Please set the username and access key by setting the environment variables: |
+ // - BROWSERSTACK_USERNAME |
+ // - BROWSERSTACK_ACCESS_KEY |
+ // .env files are supported |
+ //////////////////////////////////////////////////////////////////////////////////
+ browserstack: {
+ selenium: {
+ host: 'hub.browserstack.com',
+ port: 443
+ },
+ // More info on configuring capabilities can be found on:
+ // https://www.browserstack.com/automate/capabilities?tag=selenium-4
+ desiredCapabilities: {
+ 'bstack:options': {
+ userName: '${BROWSERSTACK_USERNAME}',
+ accessKey: '${BROWSERSTACK_ACCESS_KEY}'
+ }
+ },
+
+ disable_error_log: true,
+ webdriver: {
+ timeout_options: {
+ timeout: 60000,
+ retry_attempts: 3
+ },
+ keep_alive: true,
+ start_process: false
+ }
+ },
+
+ 'browserstack.local': {
+ extends: 'browserstack',
+ desiredCapabilities: {
+ 'browserstack.local': true
+ }
+ },
+
+ 'browserstack.chrome': {
+ extends: 'browserstack',
+ desiredCapabilities: {
+ browserName: 'chrome',
+ chromeOptions: {
+ w3c: true
+ }
+ }
+ },
+
+ 'browserstack.firefox': {
+ extends: 'browserstack',
+ desiredCapabilities: {
+ browserName: 'firefox'
+ }
+ },
+
+ 'browserstack.ie': {
+ extends: 'browserstack',
+ desiredCapabilities: {
+ browserName: 'internet explorer',
+ browserVersion: '11.0'
+ }
+ },
+
+ 'browserstack.safari': {
+ extends: 'browserstack',
+ desiredCapabilities: {
+ browserName: 'safari'
+ }
+ },
+
+ 'browserstack.local_chrome': {
+ extends: 'browserstack.local',
+ desiredCapabilities: {
+ browserName: 'chrome'
+ }
+ },
+
+ 'browserstack.local_firefox': {
+ extends: 'browserstack.local',
+ desiredCapabilities: {
+ browserName: 'firefox'
+ }
+ },
+ //////////////////////////////////////////////////////////////////////////////////
+ // Configuration for when using the SauceLabs cloud service |
+ // |
+ // Please set the username and access key by setting the environment variables: |
+ // - SAUCE_USERNAME |
+ // - SAUCE_ACCESS_KEY |
+ //////////////////////////////////////////////////////////////////////////////////
+ saucelabs: {
+ selenium: {
+ host: 'ondemand.saucelabs.com',
+ port: 443
+ },
+ // More info on configuring capabilities can be found on:
+ // https://docs.saucelabs.com/dev/test-configuration-options/
+ desiredCapabilities: {
+ 'sauce:options': {
+ username: '${SAUCE_USERNAME}',
+ accessKey: '${SAUCE_ACCESS_KEY}',
+ screenResolution: '1280x1024'
+ // https://docs.saucelabs.com/dev/cli/sauce-connect-proxy/#--region
+ // region: 'us-west-1'
+ // https://docs.saucelabs.com/dev/test-configuration-options/#tunnelidentifier
+ // parentTunnel: '',
+ // tunnelIdentifier: '',
+ }
+ },
+ disable_error_log: false,
+ webdriver: {
+ start_process: false
+ }
+ },
+ 'saucelabs.chrome': {
+ extends: 'saucelabs',
+ desiredCapabilities: {
+ browserName: 'chrome',
+ browserVersion: 'latest',
+ javascriptEnabled: true,
+ acceptSslCerts: true,
+ timeZone: 'London',
+ chromeOptions: {
+ w3c: true
+ }
+ }
+ },
+ 'saucelabs.firefox': {
+ extends: 'saucelabs',
+ desiredCapabilities: {
+ browserName: 'firefox',
+ browserVersion: 'latest',
+ javascriptEnabled: true,
+ acceptSslCerts: true,
+ timeZone: 'London'
+ }
+ },
+ //////////////////////////////////////////////////////////////////////////////////
+ // Configuration for when using the Selenium service, either locally or remote, |
+ // like Selenium Grid |
+ //////////////////////////////////////////////////////////////////////////////////
+ selenium_server: {
+ // Selenium Server is running locally and is managed by Nightwatch
+ // Install the NPM package @nightwatch/selenium-server or download the selenium server jar file from https://github.com/SeleniumHQ/selenium/releases/, e.g.: selenium-server-4.1.1.jar
+ selenium: {
+ start_process: true,
+ port: 4444,
+ server_path: '', // Leave empty if @nightwatch/selenium-server is installed
+ command: 'standalone', // Selenium 4 only
+ cli_args: {
+ //'webdriver.gecko.driver': '',
+ //'webdriver.chrome.driver': ''
+ }
+ },
+ webdriver: {
+ start_process: false,
+ default_path_prefix: '/wd/hub'
+ }
+ },
+
+ 'selenium.chrome': {
+ extends: 'selenium_server',
+ desiredCapabilities: {
+ browserName: 'chrome',
+ chromeOptions: {
+ w3c: true
+ }
+ }
+ },
+
+ 'selenium.firefox': {
+ extends: 'selenium_server',
+ desiredCapabilities: {
+ browserName: 'firefox',
+ 'moz:firefoxOptions': {
+ args: [
+ // '-headless',
+ // '-verbose'
+ ]
+ }
+ }
+ }
+ }
+}
diff --git a/packages/nightwatch-devtools/package.json b/packages/nightwatch-devtools/package.json
new file mode 100644
index 0000000..75a2db1
--- /dev/null
+++ b/packages/nightwatch-devtools/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "@wdio/nightwatch-devtools",
+ "version": "0.1.0",
+ "description": "Nightwatch adapter for WebdriverIO DevTools - reuses existing backend, UI, and capture infrastructure",
+ "type": "module",
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "nightwatch": {
+ "plugin": true
+ },
+ "scripts": {
+ "build": "tsc",
+ "watch": "tsc --watch",
+ "clean": "rm -rf dist",
+ "example": "nightwatch -c example/nightwatch.conf.cjs"
+ },
+ "keywords": [
+ "nightwatch",
+ "devtools",
+ "debugging",
+ "testing"
+ ],
+ "author": "WebdriverIO Team",
+ "license": "MIT",
+ "dependencies": {
+ "@wdio/devtools-backend": "workspace:*",
+ "@wdio/logger": "^9.6.0",
+ "import-meta-resolve": "^4.2.0",
+ "stacktrace-parser": "^0.1.10",
+ "webdriverio": "^9.18.0",
+ "ws": "^8.18.3"
+ },
+ "devDependencies": {
+ "@types/node": "^22.10.5",
+ "@types/ws": "^8.18.1",
+ "chromedriver": "^133.0.0",
+ "nightwatch": "^3.0.0",
+ "typescript": "^5.9.2"
+ },
+ "peerDependencies": {
+ "nightwatch": ">=3.0.0"
+ }
+}
diff --git a/packages/nightwatch-devtools/src/constants.ts b/packages/nightwatch-devtools/src/constants.ts
new file mode 100644
index 0000000..71dea2d
--- /dev/null
+++ b/packages/nightwatch-devtools/src/constants.ts
@@ -0,0 +1,99 @@
+/**
+ * Internal Nightwatch commands to exclude from capture
+ */
+export const INTERNAL_COMMANDS_TO_IGNORE = [
+ 'isAppiumClient',
+ 'isSafari',
+ 'isChrome',
+ 'isFirefox',
+ 'isEdge',
+ 'isMobile',
+ 'isAndroid',
+ 'isIOS',
+ 'session',
+ 'sessions',
+ 'timeouts',
+ 'timeoutsAsyncScript',
+ 'timeoutsImplicitWait',
+ 'getLog',
+ 'getLogTypes',
+ 'screenshot',
+ 'availableContexts',
+ 'currentContext',
+ 'setChromeOptions',
+ 'setDeviceName',
+ 'perform',
+ 'execute',
+ 'executeAsync',
+ 'executeScript',
+ // Internal Nightwatch transport commands (used for log capture, not user actions)
+ 'sessionLog',
+ 'sessionLogTypes',
+ 'isLogAvailable',
+ 'end'
+] as const
+
+export const CONSOLE_METHODS = ['log', 'info', 'warn', 'error'] as const
+
+export const LOG_LEVEL_PATTERNS: ReadonlyArray<{
+ level: 'trace' | 'debug' | 'info' | 'warn' | 'error'
+ pattern: RegExp
+}> = [
+ { level: 'trace', pattern: /\btrace\b/i },
+ { level: 'debug', pattern: /\bdebug\b/i },
+ { level: 'info', pattern: /\binfo\b/i },
+ { level: 'warn', pattern: /\bwarn(ing)?\b/i },
+ { level: 'error', pattern: /\berror\b/i }
+] as const
+
+export const LOG_SOURCES = {
+ BROWSER: 'browser',
+ TEST: 'test',
+ TERMINAL: 'terminal'
+} as const
+
+export const ANSI_REGEX = /\x1b\[[?]?[0-9;]*[A-Za-z]/g
+
+export const DEFAULTS = {
+ CID: '0-0',
+ TEST_NAME: 'unknown',
+ FILE_NAME: 'unknown',
+ RETRIES: 0,
+ DURATION: 0
+} as const
+
+/** Timing constants (in milliseconds) */
+export const TIMING = {
+ UI_RENDER_DELAY: 150,
+ TEST_START_DELAY: 100,
+ SUITE_COMPLETE_DELAY: 200,
+ UI_CONNECTION_WAIT: 10000,
+ BROWSER_CLOSE_WAIT: 2000,
+ INITIAL_CONNECTION_WAIT: 500,
+ BROWSER_POLL_INTERVAL: 1000
+} as const
+
+export const TEST_STATE = {
+ PENDING: 'pending',
+ RUNNING: 'running',
+ PASSED: 'passed',
+ FAILED: 'failed',
+ SKIPPED: 'skipped'
+} as const
+
+/**
+ * Generic pattern matching Nightwatch commands whose result is a boolean.
+ */
+export const BOOLEAN_COMMAND_PATTERN =
+ /^waitFor|^is[A-Z]|^has[A-Z]|(Visible|Present|Enabled|Selected|NotVisible|NotPresent)$/
+
+export const NAVIGATION_COMMANDS = ['url', 'navigate', 'navigateTo'] as const
+
+/** Spinner progress frames — suppress from UI Console output. */
+export const SPINNER_RE = /^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/u
+
+/** Matches a path segment that indicates a test/spec directory (e.g. /tests/ or /spec/). */
+export const TEST_PATH_PATTERN = /\/(test|spec|tests)\//i
+
+/** Matches file names that follow the *.test.ts / *.spec.js naming convention. */
+export const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/i
diff --git a/packages/nightwatch-devtools/src/helpers/browserProxy.ts b/packages/nightwatch-devtools/src/helpers/browserProxy.ts
new file mode 100644
index 0000000..c99dad3
--- /dev/null
+++ b/packages/nightwatch-devtools/src/helpers/browserProxy.ts
@@ -0,0 +1,403 @@
+/**
+ * Browser Proxy
+ * Handles browser command interception and tracking
+ */
+
+import logger from '@wdio/logger'
+import {
+ INTERNAL_COMMANDS_TO_IGNORE,
+ BOOLEAN_COMMAND_PATTERN,
+ NAVIGATION_COMMANDS
+} from '../constants.js'
+import { getCallSourceFromStack } from './utils.js'
+import type { SessionCapturer } from '../session.js'
+import type { TestManager } from './testManager.js'
+import type { NightwatchBrowser, CommandStackFrame } from '../types.js'
+
+const log = logger('@wdio/nightwatch-devtools:browserProxy')
+
+export class BrowserProxy {
+ /** Tracks which browser *instances* have already been proxied to avoid double-wrapping. */
+ private proxiedBrowsers = new WeakSet