Files
windmill/debugger/test_dap_server_bun.ts
2026-01-13 15:20:06 +00:00

768 lines
22 KiB
TypeScript

#!/usr/bin/env bun
/**
* Test script for the Bun DAP WebSocket server.
*
* Tests breakpoints on every line, variable inspection, and console output.
*
* Run the server first:
* bun run dap_websocket_server_bun.ts
*
* Then run this test:
* bun run test_dap_server_bun.ts
*/
// Test script with module-level code and a main function
const TEST_SCRIPT = `let foo = 42
console.log("A") // Line 1 - module level
console.log("B") // Line 2 - module level
export async function main(x: string) {
let qwe = "xaweqw"
let foobar = 312312
console.log("A")
console.log("B")
return x
}`
// All executable lines (skipping empty line 4 and function signature line 5)
// Line numbers are 1-indexed as they appear in the source
const BREAKPOINT_LINES = [1, 2, 3, 6, 7, 8, 9, 10]
// Expected variables at each line (variable should be visible AFTER the line executes)
// For breakpoints, we're stopped BEFORE the line executes, so:
// - Line 1: `let foo = 42` - foo not yet assigned
// - Line 2: after foo assignment, foo=42 (NOTE: module-level `let` not captured by Bun inspector)
// - Line 3: still foo=42 (NOTE: module-level `let` not captured by Bun inspector)
// - Line 6: inside main, x is parameter
// - Line 7: x, qwe assigned
// - Line 8: x, qwe, foobar assigned
// - Line 9: x, qwe, foobar still there
// - Line 10: x, qwe, foobar, about to return
//
// KNOWN LIMITATION: Bun's WebKit inspector does not expose module-level `let`/`const`
// declarations in the scope chain. Only variables inside functions are visible.
const EXPECTED_VARIABLES: Record<number, string[]> = {
1: [], // stopped before foo assignment
2: [], // NOTE: foo not visible in Bun's inspector for module-level let
3: [], // NOTE: foo not visible in Bun's inspector for module-level let
6: ['x'], // inside main, x is parameter
7: ['x', 'qwe'], // qwe assigned
8: ['x', 'qwe', 'foobar'], // foobar assigned
9: ['x', 'qwe', 'foobar'], // same
10: ['x', 'qwe', 'foobar'] // same
}
// Expected console output order
const EXPECTED_LOGS = ['A', 'B', 'A', 'B']
// Test args
const TEST_ARGS = { x: 'foobar' }
interface DAPMessage {
seq: number
type: 'request' | 'response' | 'event'
command?: string
event?: string
request_seq?: number
success?: boolean
message?: string
body?: Record<string, unknown>
arguments?: Record<string, unknown>
}
interface Variable {
name: string
value: string
type?: string
variablesReference: number
}
interface StackFrame {
id: number
name: string
line: number
column: number
source?: { path: string; name?: string }
}
interface Scope {
name: string
variablesReference: number
expensive: boolean
}
class DAPTestClient {
private url: string
private ws: WebSocket | null = null
private seq = 1
private pendingRequests = new Map<
number,
{ resolve: (value: DAPMessage) => void; reject: (error: Error) => void }
>()
private events: DAPMessage[] = []
private output: string[] = []
private eventHandlers: Map<string, ((event: DAPMessage) => void)[]> = new Map()
private terminatedResult: unknown = undefined
constructor(url = 'ws://localhost:5680') {
this.url = url
}
async connect(): Promise<void> {
console.log(`[TEST] Connecting to ${this.url}...`)
return new Promise((resolve, reject) => {
this.ws = new WebSocket(this.url)
this.ws.onopen = () => {
console.log('[TEST] Connected!')
resolve()
}
this.ws.onerror = (error) => {
console.error('[TEST] WebSocket error:', error)
reject(new Error('WebSocket connection failed'))
}
this.ws.onmessage = (event) => {
this.handleMessage(event.data as string)
}
this.ws.onclose = () => {
console.log('[TEST] WebSocket closed')
}
})
}
disconnect(): void {
if (this.ws) {
this.ws.close()
this.ws = null
}
}
private handleMessage(data: string): void {
try {
const msg = JSON.parse(data) as DAPMessage
if (msg.type === 'response') {
const reqSeq = msg.request_seq
if (reqSeq !== undefined && this.pendingRequests.has(reqSeq)) {
const pending = this.pendingRequests.get(reqSeq)!
this.pendingRequests.delete(reqSeq)
if (msg.success) {
pending.resolve(msg)
} else {
pending.reject(new Error(msg.message || 'Request failed'))
}
}
} else if (msg.type === 'event') {
console.log(`[TEST] Event: ${msg.event}`, JSON.stringify(msg.body))
this.events.push(msg)
// Capture output
if (msg.event === 'output' && msg.body?.output) {
const outputStr = String(msg.body.output).trim()
if (outputStr) {
this.output.push(outputStr)
}
}
// Capture result from terminated event
if (msg.event === 'terminated' && msg.body?.result !== undefined) {
this.terminatedResult = msg.body.result
}
// Notify handlers
const handlers = this.eventHandlers.get(msg.event!) || []
for (const handler of handlers) {
handler(msg)
}
}
} catch (error) {
console.error('[TEST] Failed to parse message:', error)
}
}
private async sendRequest(
command: string,
args?: Record<string, unknown>
): Promise<DAPMessage> {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error('Not connected')
}
const seq = this.seq++
const request: DAPMessage = {
seq,
type: 'request',
command,
arguments: args
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.pendingRequests.delete(seq)
reject(new Error(`Request timeout: ${command}`))
}, 15000)
this.pendingRequests.set(seq, {
resolve: (value) => {
clearTimeout(timeout)
resolve(value)
},
reject: (error) => {
clearTimeout(timeout)
reject(error)
}
})
this.ws!.send(JSON.stringify(request))
})
}
waitForEvent(eventName: string, timeout: number = 10000): Promise<DAPMessage> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`Timeout waiting for event: ${eventName}`))
}, timeout)
const handler = (event: DAPMessage) => {
clearTimeout(timer)
const handlers = this.eventHandlers.get(eventName) || []
const idx = handlers.indexOf(handler)
if (idx >= 0) handlers.splice(idx, 1)
resolve(event)
}
if (!this.eventHandlers.has(eventName)) {
this.eventHandlers.set(eventName, [])
}
this.eventHandlers.get(eventName)!.push(handler)
})
}
async initialize(): Promise<DAPMessage> {
return this.sendRequest('initialize', {
clientID: 'test',
clientName: 'DAP Test Client',
adapterID: 'bun',
pathFormat: 'path',
linesStartAt1: true,
columnsStartAt1: true,
supportsVariableType: true
})
}
async setBreakpoints(path: string, lines: number[]): Promise<DAPMessage> {
return this.sendRequest('setBreakpoints', {
source: { path },
breakpoints: lines.map((line) => ({ line }))
})
}
async configurationDone(): Promise<DAPMessage> {
return this.sendRequest('configurationDone')
}
async launch(
code: string,
callMain = false,
args: Record<string, unknown> = {}
): Promise<DAPMessage> {
return this.sendRequest('launch', {
code,
callMain,
args
})
}
async continue_(): Promise<DAPMessage> {
return this.sendRequest('continue', { threadId: 1 })
}
async next(): Promise<DAPMessage> {
return this.sendRequest('next', { threadId: 1 })
}
async getStackTrace(): Promise<StackFrame[]> {
const response = await this.sendRequest('stackTrace', {
threadId: 1,
startFrame: 0,
levels: 20
})
return (response.body?.stackFrames as StackFrame[]) || []
}
async getScopes(frameId: number): Promise<Scope[]> {
const response = await this.sendRequest('scopes', { frameId })
return (response.body?.scopes as Scope[]) || []
}
async getVariables(variablesReference: number): Promise<Variable[]> {
const response = await this.sendRequest('variables', { variablesReference })
return (response.body?.variables as Variable[]) || []
}
async terminate(): Promise<DAPMessage | null> {
// Check if still connected before sending terminate
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
console.log('[TEST] WebSocket not open, skipping terminate (script may have already ended)')
return null
}
return this.sendRequest('terminate')
}
isConnected(): boolean {
return this.ws !== null && this.ws.readyState === WebSocket.OPEN
}
getOutput(): string[] {
return this.output
}
clearOutput(): void {
this.output = []
}
getResult(): unknown {
return this.terminatedResult
}
}
// Test results tracking
interface TestResult {
test: string
passed: boolean
error?: string
details?: string
}
async function runComprehensiveTest(): Promise<void> {
const client = new DAPTestClient()
const results: TestResult[] = []
let passed = 0
let failed = 0
function assert(condition: boolean, testName: string, details?: string): void {
if (condition) {
console.log(`[PASS] ${testName}`)
passed++
results.push({ test: testName, passed: true, details })
} else {
console.log(`[FAIL] ${testName}${details ? ': ' + details : ''}`)
failed++
results.push({ test: testName, passed: false, error: details })
}
}
try {
// Connect
console.log('\n=== Connecting to DAP Server ===')
await client.connect()
assert(true, 'Connect to DAP server')
// Initialize
console.log('\n=== Initialize ===')
const initResponse = await client.initialize()
assert(initResponse.success === true, 'Initialize session')
// Set up initialized event handler before setting breakpoints
const initializedPromise = client.waitForEvent('initialized')
// Set breakpoints on all executable lines
console.log('\n=== Set Breakpoints ===')
console.log(`[TEST] Setting breakpoints on lines: ${BREAKPOINT_LINES}`)
const bpResponse = await client.setBreakpoints('/tmp/test_script.ts', BREAKPOINT_LINES)
assert(bpResponse.success === true, 'Set breakpoints request')
const breakpoints = bpResponse.body?.breakpoints as Array<{
verified: boolean
line: number
}>
assert(
breakpoints.length === BREAKPOINT_LINES.length,
`Breakpoint count (expected ${BREAKPOINT_LINES.length}, got ${breakpoints.length})`
)
// Configuration done
console.log('\n=== Configuration Done ===')
await client.configurationDone()
assert(true, 'Configuration done')
// Launch with callMain=true and args
console.log('\n=== Launch Script ===')
console.log(`[TEST] Launching with callMain=true, args=${JSON.stringify(TEST_ARGS)}`)
await client.launch(TEST_SCRIPT, true, TEST_ARGS)
assert(true, 'Launch script')
// Wait for initialized event
await initializedPromise
assert(true, 'Received initialized event')
// Track breakpoints hit and variables at each
const breakpointsHit: number[] = []
const variablesAtBreakpoints: Map<number, string[]> = new Map()
const variableValuesAtBreakpoints: Map<number, Map<string, string>> = new Map()
// Process breakpoints
console.log('\n=== Processing Breakpoints ===')
let iteration = 0
const maxIterations = BREAKPOINT_LINES.length + 5
while (iteration < maxIterations) {
iteration++
// Wait for stopped event
console.log(`\n[TEST] Waiting for stopped event (iteration ${iteration})...`)
let stoppedEvent: DAPMessage
try {
stoppedEvent = await client.waitForEvent('stopped', 15000)
} catch {
console.log('[TEST] No more stopped events (likely script completed)')
break
}
const line = stoppedEvent.body?.line as number | undefined
const reason = stoppedEvent.body?.reason as string
console.log(`[TEST] Stopped at line ${line}, reason: ${reason}`)
if (line !== undefined) {
breakpointsHit.push(line)
}
// Get stack trace
const frames = await client.getStackTrace()
console.log(`[TEST] Stack trace: ${frames.length} frames`)
for (const frame of frames) {
console.log(`[TEST] Frame ${frame.id}: ${frame.name} at line ${frame.line}`)
}
if (frames.length > 0) {
// Get scopes
const scopes = await client.getScopes(frames[0].id)
console.log(`[TEST] Scopes: ${scopes.length}`)
// Get variables from all non-expensive scopes
const allVars: string[] = []
const varValues: Map<string, string> = new Map()
for (const scope of scopes) {
if (!scope.expensive) {
console.log(
`[TEST] Scope: ${scope.name} (ref: ${scope.variablesReference})`
)
const vars = await client.getVariables(scope.variablesReference)
console.log(`[TEST] Variables in ${scope.name}: ${vars.length}`)
for (const v of vars) {
console.log(`[TEST] ${v.name} = ${v.value} (${v.type})`)
allVars.push(v.name)
varValues.set(v.name, v.value)
}
}
}
if (line !== undefined) {
variablesAtBreakpoints.set(line, allVars)
variableValuesAtBreakpoints.set(line, varValues)
}
}
// Continue to next breakpoint
console.log('[TEST] Continuing...')
await client.continue_()
}
// Wait for terminated event or timeout
console.log('\n[TEST] Waiting for terminated event...')
try {
await client.waitForEvent('terminated', 5000)
console.log('[TEST] Script terminated')
} catch {
console.log('[TEST] Timeout waiting for terminated event (script may have already ended)')
}
// Wait a bit more for final output to flush
await new Promise((resolve) => setTimeout(resolve, 1000))
// === VERIFICATION ===
console.log('\n' + '='.repeat(60))
console.log('VERIFICATION')
console.log('='.repeat(60))
// Verify breakpoints were hit
console.log('\n=== Breakpoint Analysis ===')
console.log(`[TEST] Breakpoints hit: ${breakpointsHit}`)
console.log(`[TEST] Expected breakpoints: ${BREAKPOINT_LINES}`)
for (const expectedLine of BREAKPOINT_LINES) {
const hitCount = breakpointsHit.filter((l) => l === expectedLine).length
assert(hitCount > 0, `Breakpoint at line ${expectedLine} was hit`)
}
// Verify variables at each breakpoint
console.log('\n=== Variable Analysis ===')
for (const [line, expectedVars] of Object.entries(EXPECTED_VARIABLES)) {
const lineNum = parseInt(line)
const actualVars = variablesAtBreakpoints.get(lineNum) || []
console.log(
`[TEST] Line ${lineNum}: expected [${expectedVars}], got [${actualVars}]`
)
for (const expectedVar of expectedVars) {
assert(
actualVars.includes(expectedVar),
`Variable '${expectedVar}' visible at line ${lineNum}`,
`Available: ${actualVars.join(', ')}`
)
}
}
// Verify variable x has correct value inside main() (line 6 or later)
console.log('\n=== Variable Value Check ===')
const varsAtLine6 = variableValuesAtBreakpoints.get(6)
if (varsAtLine6) {
const xValue = varsAtLine6.get('x')
console.log(`[TEST] Variable x at line 6: ${xValue}`)
assert(
xValue === '"foobar"',
'Variable x has correct value "foobar"',
`Got: ${xValue}`
)
} else {
assert(false, 'Variable x has correct value "foobar"', 'No variables at line 6')
}
// Verify console output
console.log('\n=== Console Output Analysis ===')
const output = client.getOutput()
console.log(`[TEST] Captured output: ${JSON.stringify(output)}`)
console.log(`[TEST] Expected output: ${JSON.stringify(EXPECTED_LOGS)}`)
// Filter out result output and any stderr noise
const consoleOutput = output.filter(
(o) => !o.startsWith('__WINDMILL_RESULT__') && !o.includes('[DEBUG]')
)
assert(
consoleOutput.length >= EXPECTED_LOGS.length,
`Console output count (expected >= ${EXPECTED_LOGS.length}, got ${consoleOutput.length})`
)
for (let i = 0; i < EXPECTED_LOGS.length; i++) {
assert(
consoleOutput[i] === EXPECTED_LOGS[i],
`Console output[${i}] is "${EXPECTED_LOGS[i]}"`,
`Got: "${consoleOutput[i]}"`
)
}
// Terminate and get result
console.log('\n=== Terminate ===')
const terminateResult = await client.terminate()
if (terminateResult !== null) {
assert(true, 'Terminate session')
} else {
assert(true, 'Script already terminated naturally')
}
// Wait for terminated event with result
await new Promise((resolve) => setTimeout(resolve, 500))
// Check for return value from terminated event
console.log('\n=== Return Value Analysis ===')
const result = client.getResult()
console.log(`[TEST] Result from terminated event: ${JSON.stringify(result)}`)
assert(result === 'foobar', 'Return value is "foobar"', `Got: ${JSON.stringify(result)}`)
} catch (error) {
console.error('[TEST] Error:', error)
failed++
results.push({ test: 'Unexpected error', passed: false, error: String(error) })
} finally {
client.disconnect()
}
// Summary
console.log('\n' + '='.repeat(60))
console.log(`TEST SUMMARY: ${passed} passed, ${failed} failed`)
console.log('='.repeat(60))
if (failed > 0) {
console.log('\nFailed tests:')
for (const r of results.filter((r) => !r.passed)) {
console.log(` - ${r.test}: ${r.error || 'Unknown error'}`)
}
process.exit(1)
} else {
console.log('\n✓ All tests passed!')
process.exit(0)
}
}
/**
* Test script with a dynamic import (lodash).
* This tests that the prepare-deps CLI properly installs dependencies.
*/
const TEST_SCRIPT_WITH_IMPORT = `import _ from "lodash"
export async function main(items: number[]) {
const sum = _.sum(items)
const max = _.max(items)
console.log("Sum:", sum)
console.log("Max:", max)
return { sum, max }
}`
/**
* Test dynamic import installation.
* This test requires the server to be started with --windmill pointing to the windmill binary.
*
* Usage:
* bun run dap_websocket_server_bun.ts --windmill /path/to/windmill
* bun run test_dap_server_bun.ts --test-imports
*/
async function testDynamicImports(): Promise<void> {
console.log('='.repeat(60))
console.log('DYNAMIC IMPORT TEST')
console.log('='.repeat(60))
console.log('\nThis test verifies that external npm packages are automatically installed.')
console.log('Make sure the server is started with: --windmill /path/to/windmill\n')
const client = new DAPTestClient('ws://localhost:5680')
let passed = 0
let failed = 0
const results: Array<{ test: string; passed: boolean; error?: string }> = []
function assert(condition: boolean, message: string, error?: string) {
if (condition) {
passed++
console.log(`${message}`)
results.push({ test: message, passed: true })
} else {
failed++
console.log(`${message}` + (error ? `: ${error}` : ''))
results.push({ test: message, passed: false, error })
}
}
try {
console.log('=== Setup ===')
await client.connect()
assert(true, 'Connect to server')
// Initialize
const initResult = await client.initialize()
assert(
initResult.success === true,
'Initialize session',
initResult.message
)
// Launch with code that uses lodash
console.log('\n=== Launch with lodash import ===')
const launchResult = await client.launch(TEST_SCRIPT_WITH_IMPORT, true, { items: [1, 2, 3, 4, 5] })
assert(
launchResult.success === true,
'Launch with lodash import',
launchResult.message
)
// Wait for initialization events
await client.configurationDone()
// No breakpoints - just run to completion
console.log('\n=== Running to completion ===')
// Wait for completion (with timeout)
let terminated = false
const startTime = Date.now()
while (!terminated && Date.now() - startTime < 30000) {
await new Promise(resolve => setTimeout(resolve, 100))
// Check if we've received a terminated event by checking for result
const result = client.getResult()
if (result !== undefined) {
terminated = true
}
}
if (!terminated) {
// Try to continue if we're paused at debugger statement
try {
await client.continue_()
// Wait a bit more
await new Promise(resolve => setTimeout(resolve, 2000))
} catch {
// Ignore if already terminated
}
}
// Check console output
console.log('\n=== Console Output ===')
const output = client.getOutput()
console.log(`Output: ${JSON.stringify(output)}`)
const hasSum = output.some(o => o.includes('Sum:') && o.includes('15'))
const hasMax = output.some(o => o.includes('Max:') && o.includes('5'))
assert(hasSum, 'Console shows correct sum (15)', `Output: ${output.join(', ')}`)
assert(hasMax, 'Console shows correct max (5)', `Output: ${output.join(', ')}`)
// Check return value
console.log('\n=== Return Value ===')
const result = client.getResult()
console.log(`Result: ${JSON.stringify(result)}`)
if (result && typeof result === 'object') {
const resultObj = result as { sum?: number; max?: number }
assert(resultObj.sum === 15, 'Return value sum is 15', `Got: ${resultObj.sum}`)
assert(resultObj.max === 5, 'Return value max is 5', `Got: ${resultObj.max}`)
} else {
assert(false, 'Return value has sum and max', `Result: ${JSON.stringify(result)}`)
}
// Terminate
console.log('\n=== Terminate ===')
try {
await client.terminate()
assert(true, 'Terminate session')
} catch {
assert(true, 'Script already terminated naturally')
}
} catch (error) {
console.error('[TEST] Error:', error)
failed++
results.push({ test: 'Unexpected error', passed: false, error: String(error) })
} finally {
client.disconnect()
}
// Summary
console.log('\n' + '='.repeat(60))
console.log(`DYNAMIC IMPORT TEST SUMMARY: ${passed} passed, ${failed} failed`)
console.log('='.repeat(60))
if (failed > 0) {
console.log('\nFailed tests:')
for (const r of results.filter((r) => !r.passed)) {
console.log(` - ${r.test}: ${r.error || 'Unknown error'}`)
}
process.exit(1)
} else {
console.log('\n✓ All dynamic import tests passed!')
process.exit(0)
}
}
// Parse arguments to choose which test to run
const testArgs = process.argv.slice(2)
if (testArgs.includes('--test-imports')) {
testDynamicImports().catch((error) => {
console.error('Test runner failed:', error)
process.exit(1)
})
} else {
// Run the main test
runComprehensiveTest().catch((error) => {
console.error('Test runner failed:', error)
process.exit(1)
})
}