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}`) } }