diff --git a/packages/app/package.json b/packages/app/package.json
index 07a354d..6a41d2c 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@wdio/devtools-app",
- "version": "1.0.0",
+ "version": "1.0.1",
"description": "Browser devtools extension for debugging WebdriverIO tests.",
"type": "module",
"exports": "./src/index.ts",
diff --git a/packages/app/src/components/workbench/actions.ts b/packages/app/src/components/workbench/actions.ts
index b52e9cb..eef55c8 100644
--- a/packages/app/src/components/workbench/actions.ts
+++ b/packages/app/src/components/workbench/actions.ts
@@ -48,12 +48,13 @@ export class DevtoolsActions extends Element {
(a, b) => a.timestamp - b.timestamp
)
- if (!entries.length || !mutations.length) {
+ if (!entries.length) {
return html``
}
+ const baselineTimestamp = entries[0]?.timestamp ?? 0
return entries.map((entry) => {
- const elapsedTime = entry.timestamp - mutations[0].timestamp
+ const elapsedTime = entry.timestamp - baselineTimestamp
if ('command' in entry) {
return html`
diff --git a/packages/app/src/components/workbench/list.ts b/packages/app/src/components/workbench/list.ts
index 06a3a39..314a49b 100644
--- a/packages/app/src/components/workbench/list.ts
+++ b/packages/app/src/components/workbench/list.ts
@@ -95,17 +95,18 @@ export class DevtoolsList extends Element {
}
render() {
- const isArrayList = Array.isArray(this.list)
+ const list = this.list ?? {}
+ const isArrayList = Array.isArray(list)
- if (this.list === null) {
+ if (list === null) {
return null
}
- if (isArrayList && (this.list as unknown[]).length === 0) {
+ if (isArrayList && (list as unknown[]).length === 0) {
return null
}
if (
!isArrayList &&
- Object.keys(this.list as Record).length === 0
+ Object.keys(list as Record).length === 0
) {
return null
}
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 453e5eb..f7b1b64 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "@wdio/devtools-backend",
- "version": "1.0.0",
+ "version": "1.0.1",
"description": "Backend service to spin up WebdriverIO Devtools",
"author": "Christian Bromann ",
"license": "MIT",
diff --git a/packages/service/package.json b/packages/service/package.json
index 1ccc08e..3a85ea4 100644
--- a/packages/service/package.json
+++ b/packages/service/package.json
@@ -1,6 +1,6 @@
{
"name": "@wdio/devtools-service",
- "version": "10.0.0",
+ "version": "10.0.1",
"description": "Hook up WebdriverIO with DevTools",
"author": "Christian Bromann ",
"type": "module",
diff --git a/packages/service/src/constants.ts b/packages/service/src/constants.ts
index cf4a494..cd7be9c 100644
--- a/packages/service/src/constants.ts
+++ b/packages/service/src/constants.ts
@@ -50,7 +50,8 @@ export const CONTEXT_CHANGE_COMMANDS = [
/**
* Existing pattern (kept for any external consumers)
*/
-export const SPEC_FILE_PATTERN = /(test|spec|features)[\\/].*\.(js|ts)$/i
+export const SPEC_FILE_PATTERN =
+ /\/(test|spec|features|pageobjects|@wdio\/expect-webdriverio)\//i
/**
* Parser options
diff --git a/packages/service/src/index.ts b/packages/service/src/index.ts
index c1fd585..947fef9 100644
--- a/packages/service/src/index.ts
+++ b/packages/service/src/index.ts
@@ -23,6 +23,11 @@ export const launcher = DevToolsAppLauncher
const log = logger('@wdio/devtools-service')
+type CommandFrame = {
+ command: string
+ callSource?: string
+}
+
/**
* Setup WebdriverIO Devtools hook for standalone instances
*/
@@ -87,7 +92,7 @@ export default class DevToolsHookService implements Services.ServiceInstance {
* This is used to capture the command stack to ensure that we only capture
* commands that are top-level user commands.
*/
- #commandStack: string[] = []
+ #commandStack: CommandFrame[] = []
// This is used to capture the last command signature to avoid duplicate captures
#lastCommandSig: string | null = null
@@ -101,13 +106,34 @@ export default class DevToolsHookService implements Services.ServiceInstance {
// This is used to track if the injection script is currently being injected
#injecting = false
- before(
+ async before(
caps: Capabilities.W3CCapabilities,
__: string[],
browser: WebdriverIO.Browser
) {
this.#browser = browser
+ /**
+ * create a new session capturer instance with the devtools options
+ */
+ const wdioCaps = caps as Capabilities.W3CCapabilities & {
+ 'wdio:devtoolsOptions'?: any
+ }
+ this.#sessionCapturer = new SessionCapturer(
+ wdioCaps['wdio:devtoolsOptions']
+ )
+
+ /**
+ * Block until injection completes BEFORE any test commands
+ */
+ try {
+ await this.#injectScriptSync(browser)
+ } catch (err) {
+ log.error(
+ `Failed to inject script at session start: ${(err as Error).message}`
+ )
+ }
+
/**
* propagate session metadata at the beginning of the session
*/
@@ -121,17 +147,6 @@ export default class DevToolsHookService implements Services.ServiceInstance {
capabilities: browser.capabilities as Capabilities.W3CCapabilities
})
)
- this.#ensureInjected('session-start')
-
- /**
- * create a new session capturer instance with the devtools options
- */
- const wdioCaps = caps as Capabilities.W3CCapabilities & {
- 'wdio:devtoolsOptions'?: any
- }
- this.#sessionCapturer = new SessionCapturer(
- wdioCaps['wdio:devtoolsOptions']
- )
}
// The method signature is corrected to use W3CCapabilities
@@ -216,14 +231,37 @@ export default class DevToolsHookService implements Services.ServiceInstance {
this.#commandStack.length === 0 &&
!INTERNAL_COMMANDS.includes(command)
) {
+ const rawFile = source.getFileName() ?? undefined
+ let absPath = rawFile
+
+ if (rawFile?.startsWith('file://')) {
+ try {
+ const url = new URL(rawFile)
+ absPath = decodeURIComponent(url.pathname)
+ } catch {
+ absPath = rawFile
+ }
+ }
+
+ if (absPath?.includes('?')) {
+ absPath = absPath.split('?')[0]
+ }
+
+ const line = source.getLineNumber() ?? undefined
+ const column = source.getColumnNumber() ?? undefined
+ const callSource =
+ absPath !== undefined
+ ? `${absPath}:${line ?? 0}:${column ?? 0}`
+ : undefined
+
const cmdSig = JSON.stringify({
command,
args,
- src: source.getFileName() + ':' + source.getLineNumber()
+ src: callSource
})
if (this.#lastCommandSig !== cmdSig) {
- this.#commandStack.push(command)
+ this.#commandStack.push({ command, callSource })
this.#lastCommandSig = cmdSig
}
}
@@ -243,7 +281,8 @@ export default class DevToolsHookService implements Services.ServiceInstance {
/* Ensure that the command is captured only if it matches the last command in the stack.
* This prevents capturing commands that are not top-level user commands.
*/
- if (this.#commandStack[this.#commandStack.length - 1] === command) {
+ const frame = this.#commandStack[this.#commandStack.length - 1]
+ if (frame?.command === command) {
this.#commandStack.pop()
if (this.#browser) {
return this.#sessionCapturer.afterCommand(
@@ -251,7 +290,8 @@ export default class DevToolsHookService implements Services.ServiceInstance {
command,
args,
result,
- error
+ error,
+ frame.callSource
)
}
}
@@ -295,16 +335,27 @@ export default class DevToolsHookService implements Services.ServiceInstance {
log.info(`DevTools trace saved to ${traceFilePath}`)
}
- async #ensureInjected(reason: string) {
- if (!this.#browser) {
- return
+ /**
+ * Synchronous injection that blocks until complete
+ */
+ async #injectScriptSync(browser: WebdriverIO.Browser) {
+ if (!browser.isBidi) {
+ throw new SevereServiceError(
+ `Can not set up devtools for session with id "${browser.sessionId}" because it doesn't support WebDriver Bidi`
+ )
}
- if (this.#injecting) {
+
+ await this.#sessionCapturer.injectScript(getBrowserObject(browser))
+ log.info('✓ Devtools preload script active')
+ }
+
+ async #ensureInjected(reason: string) {
+ // Keep this for re-injection after context changes
+ if (!this.#browser || this.#injecting) {
return
}
try {
this.#injecting = true
- // Cheap marker check (no heavy stack work)
const markerPresent = await this.#browser.execute(() => {
return Boolean((window as any).__WDIO_DEVTOOLS_MARK)
})
diff --git a/packages/service/src/session.ts b/packages/service/src/session.ts
index ec3815b..c2043ba 100644
--- a/packages/service/src/session.ts
+++ b/packages/service/src/session.ts
@@ -60,9 +60,9 @@ export class SessionCapturer {
command: keyof WebDriverCommands,
args: any[],
result: any,
- error: Error | undefined
+ error: Error | undefined,
+ callSource?: string
) {
- const timestamp = Date.now()
const sourceFile =
parse(new Error(''))
.filter((frame) => Boolean(frame.getFileName()))
@@ -99,8 +99,8 @@ export class SessionCapturer {
args,
result,
error,
- timestamp,
- callSource: absPath
+ timestamp: Date.now(),
+ callSource: callSource ?? absPath
}
try {
newCommand.screenshot = await browser.takeScreenshot()
@@ -120,6 +120,7 @@ export class SessionCapturer {
async injectScript(browser: WebdriverIO.Browser) {
if (this.#isInjected) {
+ log.info('Script already injected, skipping')
return
}
@@ -130,6 +131,7 @@ export class SessionCapturer {
}
this.#isInjected = true
+ log.info('Injecting devtools script...')
const script = await resolve('@wdio/devtools-script', import.meta.url)
const source = (await fs.readFile(url.fileURLToPath(script))).toString()
const functionDeclaration = `async () => { ${source} }`
@@ -137,31 +139,48 @@ export class SessionCapturer {
await browser.scriptAddPreloadScript({
functionDeclaration
})
+ log.info('✓ Script injected successfully')
}
async #captureTrace(browser: WebdriverIO.Browser) {
- /**
- * only capture trace if script was injected
- */
if (!this.#isInjected) {
+ log.warn('Script not injected, skipping trace capture')
return
}
- const { mutations, traceLogs, consoleLogs, metadata } =
- await browser.execute(() => window.wdioTraceCollector.getTraceData())
- this.metadata = metadata
+ try {
+ const collectorExists = await browser.execute(
+ () => typeof window.wdioTraceCollector !== 'undefined'
+ )
- if (Array.isArray(mutations)) {
- this.mutations.push(...(mutations as TraceMutation[]))
- this.sendUpstream('mutations', mutations)
- }
- if (Array.isArray(traceLogs)) {
- this.traceLogs.push(...traceLogs)
- this.sendUpstream('logs', traceLogs)
- }
- if (Array.isArray(consoleLogs)) {
- this.consoleLogs.push(...(consoleLogs as ConsoleLogs[]))
- this.sendUpstream('consoleLogs', consoleLogs)
+ if (!collectorExists) {
+ log.warn(
+ 'wdioTraceCollector not loaded yet - page loaded before preload script took effect'
+ )
+ return
+ }
+
+ const { mutations, traceLogs, consoleLogs, metadata } =
+ await browser.execute(() => window.wdioTraceCollector.getTraceData())
+ this.metadata = metadata
+
+ if (Array.isArray(mutations)) {
+ this.mutations.push(...(mutations as TraceMutation[]))
+ this.sendUpstream('mutations', mutations)
+ }
+ if (Array.isArray(traceLogs)) {
+ this.traceLogs.push(...traceLogs)
+ this.sendUpstream('logs', traceLogs)
+ }
+ if (Array.isArray(consoleLogs)) {
+ this.consoleLogs.push(...(consoleLogs as ConsoleLogs[]))
+ this.sendUpstream('consoleLogs', consoleLogs)
+ }
+
+ this.sendUpstream('metadata', metadata)
+ log.info(`✓ Sent metadata upstream, WS state: ${this.#ws?.readyState}`)
+ } catch (err) {
+ log.error(`Failed to capture trace: ${(err as Error).message}`)
}
}