#!/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 = { 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 arguments?: Record } 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 void)[]> = new Map() private terminatedResult: unknown = undefined constructor(url = 'ws://localhost:5680') { this.url = url } async connect(): Promise { 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 ): Promise { 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 { 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 { 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 { return this.sendRequest('setBreakpoints', { source: { path }, breakpoints: lines.map((line) => ({ line })) }) } async configurationDone(): Promise { return this.sendRequest('configurationDone') } async launch( code: string, callMain = false, args: Record = {} ): Promise { return this.sendRequest('launch', { code, callMain, args }) } async continue_(): Promise { return this.sendRequest('continue', { threadId: 1 }) } async next(): Promise { return this.sendRequest('next', { threadId: 1 }) } async getStackTrace(): Promise { const response = await this.sendRequest('stackTrace', { threadId: 1, startFrame: 0, levels: 20 }) return (response.body?.stackFrames as StackFrame[]) || [] } async getScopes(frameId: number): Promise { const response = await this.sendRequest('scopes', { frameId }) return (response.body?.scopes as Scope[]) || [] } async getVariables(variablesReference: number): Promise { const response = await this.sendRequest('variables', { variablesReference }) return (response.body?.variables as Variable[]) || [] } async terminate(): Promise { // 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 { 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 = new Map() const variableValuesAtBreakpoints: Map> = 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 = 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 { 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) }) }