Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/app/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
5 changes: 3 additions & 2 deletions packages/app/src/components/workbench/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`<wdio-devtools-placeholder></wdio-devtools-placeholder>`
}
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`
Expand Down
9 changes: 5 additions & 4 deletions packages/app/src/components/workbench/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).length === 0
Object.keys(list as Record<string, unknown>).length === 0
) {
return null
}
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/package.json
Original file line number Diff line number Diff line change
@@ -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 <mail@bromann.dev>",
"license": "MIT",
Expand Down
2 changes: 1 addition & 1 deletion packages/service/package.json
Original file line number Diff line number Diff line change
@@ -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 <mail@bromann.dev>",
"type": "module",
Expand Down
3 changes: 2 additions & 1 deletion packages/service/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
95 changes: 73 additions & 22 deletions packages/service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
Expand All @@ -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
*/
Expand All @@ -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
Expand Down Expand Up @@ -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
}
}
Expand All @@ -243,15 +281,17 @@ 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(
this.#browser,
command,
args,
result,
error
error,
frame.callSource
)
}
}
Expand Down Expand Up @@ -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)
})
Expand Down
61 changes: 40 additions & 21 deletions packages/service/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down Expand Up @@ -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()
Expand All @@ -120,6 +120,7 @@ export class SessionCapturer {

async injectScript(browser: WebdriverIO.Browser) {
if (this.#isInjected) {
log.info('Script already injected, skipping')
return
}

Expand All @@ -130,38 +131,56 @@ 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} }`

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

Expand Down