Add Debugger class tests using CDP session directly

## Debugger Tests Summary

### 1. should use Debugger class to set breakpoints and inspect variables

Tests the core debugging workflow when paused at a debugger statement:

- Creates a Debugger instance with a CDP session
- Verifies isPaused() returns false initially
- Executes code with a debugger statement that pauses execution
- Verifies isPaused() returns true when paused
- Calls getLocation() and verifies:
  - callstack[0].functionName is 'testFunction'
  - sourceContext contains the debugger keyword
- Calls inspectVariables({ scope: 'local' }) and verifies local variables:
  { localVar: "hello", numberVar: 42 }
- Calls evaluate({ expression }) in paused context and verifies it can access local variables
- Calls resume() and verifies isPaused() returns false

### 2. should list scripts with Debugger class

Tests script discovery after page load:

- Navigates to news.ycombinator.com (has external JS files)
- Enables debugger, reloads page to trigger Debugger.scriptParsed events
- Calls listScripts() and verifies:
  - Returns array with scriptId and url properties
  - At least one script exists
- Calls listScripts({ search: 'hn' }) and verifies filtering works

### 3. should manage breakpoints with Debugger class

Tests breakpoint CRUD operations:

- Verifies listBreakpoints() returns empty array initially
- Calls setBreakpoint({ file, line }) and verifies:
  - Returns a breakpoint ID string
  - listBreakpoints() now has 1 entry with correct id, file, line
- Calls deleteBreakpoint({ breakpointId }) and verifies:
  - listBreakpoints() returns empty array again

### 4. should step through code with Debugger class

Tests stepping methods and call stack navigation:

- Executes nested functions (outer calls inner) with debugger in inner
- When paused, verifies getLocation().callstack:
  - Has at least 2 frames
  - Frame 0 is inner, frame 1 is outer
- Calls stepOver() and verifies line number advances
- Calls stepOut() and verifies:
  - Now in outer function (stepped out of inner)
- Calls resume() to finish
This commit is contained in:
Tommy D. Rossi
2025-12-25 21:33:33 +01:00
parent b888301c98
commit 91c30bbd3e
+188 -81
View File
@@ -11,6 +11,7 @@ import type { ExtensionState } from 'mcp-extension/src/types.js'
import type { Protocol } from 'devtools-protocol' import type { Protocol } from 'devtools-protocol'
import { imageSize } from 'image-size' import { imageSize } from 'image-size'
import { getCDPSessionForPage } from './cdp-session.js' import { getCDPSessionForPage } from './cdp-session.js'
import { Debugger } from './debugger.js'
import { startPlayWriterCDPRelayServer, type RelayServer } from './extension/cdp-relay.js' import { startPlayWriterCDPRelayServer, type RelayServer } from './extension/cdp-relay.js'
import { createFileLogger } from './create-logger.js' import { createFileLogger } from './create-logger.js'
import type { CDPCommand } from './cdp-types.js' import type { CDPCommand } from './cdp-types.js'
@@ -1782,7 +1783,7 @@ describe('CDP Session Tests', () => {
return testCtx.browserContext return testCtx.browserContext
} }
it('should enable debugger and pause on debugger statement via CDP session', async () => { it('should use Debugger class to set breakpoints and inspect variables', async () => {
const browserContext = getBrowserContext() const browserContext = getBrowserContext()
const serviceWorker = await getExtensionServiceWorker(browserContext) const serviceWorker = await getExtensionServiceWorker(browserContext)
@@ -1801,11 +1802,15 @@ describe('CDP Session Tests', () => {
const wsUrl = getCdpUrl() const wsUrl = getCdpUrl()
const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl }) const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
await cdpSession.send('Debugger.enable') const dbg = new Debugger({ cdp: cdpSession })
const pausedPromise = new Promise<Protocol.Debugger.PausedEvent>((resolve) => { await dbg.enable()
cdpSession.on('Debugger.paused', (params) => {
resolve(params as Protocol.Debugger.PausedEvent) expect(dbg.isPaused()).toBe(false)
const pausedPromise = new Promise<void>((resolve) => {
cdpSession.on('Debugger.paused', () => {
resolve()
}) })
}) })
@@ -1813,106 +1818,208 @@ describe('CDP Session Tests', () => {
(function testFunction() { (function testFunction() {
const localVar = 'hello'; const localVar = 'hello';
const numberVar = 42; const numberVar = 42;
const objVar = { key: 'value', nested: { a: 1 } };
debugger; debugger;
return localVar + numberVar; return localVar + numberVar;
})() })()
`) `)
const pausedEvent = await Promise.race([ await Promise.race([
pausedPromise, pausedPromise,
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('Debugger.paused timeout')), 5000)) new Promise<never>((_, reject) => setTimeout(() => reject(new Error('Debugger.paused timeout')), 5000))
]) ])
const stackTrace = pausedEvent.callFrames.map(frame => ({ expect(dbg.isPaused()).toBe(true)
functionName: frame.functionName || '(anonymous)',
lineNumber: frame.location.lineNumber,
columnNumber: frame.location.columnNumber,
}))
expect({ const location = await dbg.getLocation()
reason: pausedEvent.reason, expect(location.callstack[0].functionName).toBe('testFunction')
stackTrace: stackTrace.slice(0, 3), expect(location.sourceContext).toContain('debugger')
}).toMatchInlineSnapshot(`
const vars = await dbg.inspectVariables({ scope: 'local' })
expect(vars.local).toMatchInlineSnapshot(`
{ {
"reason": "other", "localVar": "hello",
"stackTrace": [ "numberVar": 42,
{
"columnNumber": 16,
"functionName": "testFunction",
"lineNumber": 4,
},
{
"columnNumber": 14,
"functionName": "(anonymous)",
"lineNumber": 6,
},
{
"columnNumber": 29,
"functionName": "evaluate",
"lineNumber": 289,
},
],
} }
`) `)
const topFrame = pausedEvent.callFrames[0] const evalResult = await dbg.evaluate({ expression: 'localVar + " world"' })
const scopeChain = topFrame.scopeChain expect(evalResult.value).toBe('hello world')
const localScope = scopeChain.find(s => s.type === 'local') await dbg.resume()
const localVars: Record<string, unknown> = {} expect(dbg.isPaused()).toBe(false)
if (localScope?.object.objectId) { cdpSession.close()
const propsResult = await cdpSession.send('Runtime.getProperties', { await browser.close()
objectId: localScope.object.objectId, await page.close()
ownProperties: true, }, 60000)
})
for (const prop of propsResult.result) { it('should list scripts with Debugger class', async () => {
if (prop.value) { const browserContext = getBrowserContext()
localVars[prop.name] = prop.value.type === 'object' const serviceWorker = await getExtensionServiceWorker(browserContext)
? `[object ${prop.value.className || prop.value.subtype || 'Object'}]`
: prop.value.value const page = await browserContext.newPage()
} await page.goto('https://news.ycombinator.com/')
} await page.bringToFront()
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 500))
const browser = await chromium.connectOverCDP(getCdpUrl())
const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('news.ycombinator.com'))
expect(cdpPage).toBeDefined()
const wsUrl = getCdpUrl()
const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
const dbg = new Debugger({ cdp: cdpSession })
await dbg.enable()
await cdpPage!.reload({ waitUntil: 'networkidle' })
for (let i = 0; i < 20; i++) {
if (dbg.listScripts().length > 0) break
await new Promise(r => setTimeout(r, 100))
} }
expect({ const scripts = dbg.listScripts()
scopeTypes: scopeChain.map(s => s.type), expect(scripts.length).toBeGreaterThan(0)
localVariables: localVars, expect(scripts[0]).toHaveProperty('scriptId')
}).toMatchInlineSnapshot(` expect(scripts[0]).toHaveProperty('url')
{
"localVariables": {
"localVar": "hello",
"numberVar": 42,
"objVar": "[object Object]",
},
"scopeTypes": [
"local",
"global",
],
}
`)
const evalResult = await cdpSession.send('Debugger.evaluateOnCallFrame', { const hnScripts = dbg.listScripts({ search: 'hn' })
callFrameId: topFrame.callFrameId, expect(hnScripts.length).toBeGreaterThan(0)
expression: 'localVar + " world " + numberVar',
cdpSession.close()
await browser.close()
await page.close()
}, 60000)
it('should manage breakpoints with Debugger class', async () => {
const browserContext = getBrowserContext()
const serviceWorker = await getExtensionServiceWorker(browserContext)
const page = await browserContext.newPage()
await page.setContent(`
<html>
<head>
<script src="data:text/javascript,function testFunc() { return 42; }"></script>
</head>
<body></body>
</html>
`)
await page.bringToFront()
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 500))
const browser = await chromium.connectOverCDP(getCdpUrl())
let cdpPage
for (const p of browser.contexts()[0].pages()) {
const html = await p.content()
if (html.includes('testFunc')) {
cdpPage = p
break
}
}
expect(cdpPage).toBeDefined()
const wsUrl = getCdpUrl()
const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
const dbg = new Debugger({ cdp: cdpSession })
await dbg.enable()
expect(dbg.listBreakpoints()).toHaveLength(0)
const bpId = await dbg.setBreakpoint({ file: 'https://example.com/test.js', line: 1 })
expect(typeof bpId).toBe('string')
expect(dbg.listBreakpoints()).toHaveLength(1)
expect(dbg.listBreakpoints()[0]).toMatchObject({
id: bpId,
file: 'https://example.com/test.js',
line: 1,
}) })
expect({ await dbg.deleteBreakpoint({ breakpointId: bpId })
evaluatedExpression: 'localVar + " world " + numberVar', expect(dbg.listBreakpoints()).toHaveLength(0)
result: evalResult.result.value,
type: evalResult.result.type, cdpSession.close()
}).toMatchInlineSnapshot(` await browser.close()
{ await page.close()
"evaluatedExpression": "localVar + " world " + numberVar", }, 60000)
"result": "hello world 42",
"type": "string", it('should step through code with Debugger class', async () => {
} const browserContext = getBrowserContext()
const serviceWorker = await getExtensionServiceWorker(browserContext)
const page = await browserContext.newPage()
await page.goto('https://example.com/')
await page.bringToFront()
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 500))
const browser = await chromium.connectOverCDP(getCdpUrl())
const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'))
expect(cdpPage).toBeDefined()
const wsUrl = getCdpUrl()
const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
const dbg = new Debugger({ cdp: cdpSession })
await dbg.enable()
const pausedPromise = new Promise<void>((resolve) => {
cdpSession.on('Debugger.paused', () => resolve())
})
cdpPage!.evaluate(`
(function outer() {
function inner() {
const x = 1;
debugger;
const y = 2;
return x + y;
}
const result = inner();
return result;
})()
`) `)
await cdpSession.send('Debugger.resume') await pausedPromise
await cdpSession.send('Debugger.disable') expect(dbg.isPaused()).toBe(true)
const location1 = await dbg.getLocation()
expect(location1.callstack.length).toBeGreaterThanOrEqual(2)
expect(location1.callstack[0].functionName).toBe('inner')
expect(location1.callstack[1].functionName).toBe('outer')
const stepOverPromise = new Promise<void>((resolve) => {
cdpSession.on('Debugger.paused', () => resolve())
})
await dbg.stepOver()
await stepOverPromise
const location2 = await dbg.getLocation()
expect(location2.lineNumber).toBeGreaterThan(location1.lineNumber)
const stepOutPromise = new Promise<void>((resolve) => {
cdpSession.on('Debugger.paused', () => resolve())
})
await dbg.stepOut()
await stepOutPromise
const location3 = await dbg.getLocation()
expect(location3.callstack[0].functionName).toBe('outer')
await dbg.resume()
cdpSession.close() cdpSession.close()
await browser.close() await browser.close()
await page.close() await page.close()