diff --git a/extensions/vscode/src/dap/adapter.ts b/extensions/vscode/src/dap/adapter.ts index ed1e2595..38589e03 100644 --- a/extensions/vscode/src/dap/adapter.ts +++ b/extensions/vscode/src/dap/adapter.ts @@ -78,6 +78,8 @@ type BreakpointSyncLogRecord = { error?: string; }; +const BREAKPOINT_SYNC_TEST_LOG_ENV = 'SOROBAN_DEBUG_BREAKPOINT_SYNC_TEST_LOG'; + export class SorobanDebugSession extends DebugSession { private static readonly FIRST_CONTINUE_STOP_REASON: 'breakpoint' = 'breakpoint'; private logManager: LogManager | undefined; @@ -865,6 +867,7 @@ export class SorobanDebugSession extends DebugSession { LogPhase.DAP, `BREAKPOINT_SYNC ${JSON.stringify(setRecord)}` ); + this.emitBreakpointSyncTestLog(setRecord); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); errors.set( @@ -890,6 +893,15 @@ export class SorobanDebugSession extends DebugSession { return errors; } + private emitBreakpointSyncTestLog(record: BreakpointSyncLogRecord): void { + if (process.env[BREAKPOINT_SYNC_TEST_LOG_ENV] !== '1' || record.action !== 'set' || !record.success) { + return; + } + + // Temporary stderr log for e2e regression coverage of heuristic breakpoint sync. + process.stderr.write(`BREAKPOINT_SYNC_TEST ${JSON.stringify(record)}\n`); + } + private async refreshState(): Promise { if (!this.debuggerProcess) { return; diff --git a/extensions/vscode/src/test/runTest.ts b/extensions/vscode/src/test/runTest.ts index cf3b5799..0ceb97fc 100644 --- a/extensions/vscode/src/test/runTest.ts +++ b/extensions/vscode/src/test/runTest.ts @@ -27,6 +27,8 @@ type DebugMessage = { response?: { type: string; [key: string]: unknown }; }; +const BREAKPOINT_SYNC_TEST_LOG_ENV = "SOROBAN_DEBUG_BREAKPOINT_SYNC_TEST_LOG"; + async function startMockDebuggerServer(options: { evaluateDelayMs: number; }): Promise<{ port: number; close: () => Promise }> { @@ -163,6 +165,36 @@ async function wait(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } +async function waitForProcessLog( + getOutput: () => string, + pattern: RegExp, + timeoutMs = 5_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const output = getOutput(); + if (pattern.test(output)) { + return output; + } + await wait(25); + } + + throw new Error(`Timed out waiting for process log matching ${pattern}`); +} + +async function assertProcessLogAbsent( + getOutput: () => string, + pattern: RegExp, + waitMs = 500, +): Promise { + await wait(waitMs); + assert.doesNotMatch( + getOutput(), + pattern, + `Did not expect process log matching ${pattern}`, + ); +} + async function main(): Promise { if (!(await isLoopbackAvailable())) { console.warn( @@ -756,7 +788,7 @@ async function main(): Promise { const exportedFunctions = await debuggerProcess.getContractFunctions(); const resolvedBreakpoints = resolveSourceBreakpoints( sourcePath, - [10], + [14], exportedFunctions, ); assert.equal( @@ -771,10 +803,36 @@ async function main(): Promise { "Expected heuristic mapping to still set a function breakpoint", ); + const nonExportedBreakpoints = resolveSourceBreakpoints( + sourcePath, + [10], + exportedFunctions, + ); + assert.equal( + nonExportedBreakpoints[0].verified, + false, + "Expected non-exported function mapping to be unverified", + ); + assert.equal( + nonExportedBreakpoints[0].functionName, + "helper", + "Expected non-exported function line to map to helper", + ); + assert.equal( + nonExportedBreakpoints[0].reasonCode, + "HEURISTIC_NOT_EXPORTED", + "Expected non-exported function reason code", + ); + assert.equal( + nonExportedBreakpoints[0].setBreakpoint, + false, + "Expected non-exported function mapping to skip runtime breakpoint install", + ); + // Test HEURISTIC_NO_FUNCTION behavior for lines outside any function const noFunctionBreakpoints = resolveSourceBreakpoints( sourcePath, - [1, 2, 13], // Lines outside any function in lib.rs + [1, 2, 12, 16], // Lines outside any function in lib.rs exportedFunctions, ); @@ -934,9 +992,15 @@ async function runDapHappyPathE2E( fixtures: { contractPath: string; sourcePath: string; binaryPath: string }, ): Promise { const proc = spawn(process.execPath, [debugAdapterPath], { + env: { ...process.env, [BREAKPOINT_SYNC_TEST_LOG_ENV]: "1" }, stdio: ["pipe", "pipe", "pipe"], }); const client = new DapClient(proc); + let stderrOutput = ""; + proc.stderr.setEncoding("utf8"); + proc.stderr.on("data", (chunk: string) => { + stderrOutput += chunk; + }); try { const init = await client.request("initialize", { @@ -974,7 +1038,7 @@ async function runDapHappyPathE2E( const setBps = await client.request("setBreakpoints", { source: { path: fixtures.sourcePath }, - breakpoints: [{ line: 10 }], + breakpoints: [{ line: 14 }], }); assert.equal( setBps.success, @@ -986,6 +1050,44 @@ async function runDapHappyPathE2E( false, "Expected heuristic source mapping to be unverified", ); + const breakpointSyncLog = await waitForProcessLog( + () => stderrOutput, + /BREAKPOINT_SYNC_TEST .*"action":"set".*"functionName":"echo".*"success":true/, + ); + assert.match( + breakpointSyncLog, + /BREAKPOINT_SYNC_TEST/, + "Expected adapter test log to confirm runtime breakpoint installation", + ); + + const privateBps = await client.request("setBreakpoints", { + source: { path: fixtures.sourcePath }, + breakpoints: [{ line: 10 }], + }); + assert.equal( + privateBps.success, + true, + `setBreakpoints for non-exported function failed: ${privateBps.message || ""}`, + ); + assert.equal( + privateBps.body?.breakpoints?.[0]?.verified, + false, + "Expected non-exported function source mapping to be unverified", + ); + assert.equal( + privateBps.body?.breakpoints?.[0]?.reasonCode, + "HEURISTIC_NOT_EXPORTED", + "Expected non-exported reason code on source breakpoint response", + ); + assert.match( + String(privateBps.body?.breakpoints?.[0]?.message || ""), + /HEURISTIC_NOT_EXPORTED/, + "Expected non-exported breakpoint message to include reason code", + ); + await assertProcessLogAbsent( + () => stderrOutput, + /BREAKPOINT_SYNC_TEST .*"action":"set".*"functionName":"helper".*"success":true/, + ); const configDone = await client.request("configurationDone", {}); assert.equal( diff --git a/extensions/vscode/src/test/suites.ts b/extensions/vscode/src/test/suites.ts index 5aaa2eec..2d71a3bf 100644 --- a/extensions/vscode/src/test/suites.ts +++ b/extensions/vscode/src/test/suites.ts @@ -28,6 +28,8 @@ type TestFixtures = { binaryPath: string; }; +const BREAKPOINT_SYNC_TEST_LOG_ENV = "SOROBAN_DEBUG_BREAKPOINT_SYNC_TEST_LOG"; + async function startMockDebuggerServer(options: { evaluateDelayMs: number; }): Promise<{ port: number; close: () => Promise }> { @@ -164,6 +166,36 @@ async function wait(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } +async function waitForProcessLog( + getOutput: () => string, + pattern: RegExp, + timeoutMs = 5_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const output = getOutput(); + if (pattern.test(output)) { + return output; + } + await wait(25); + } + + throw new Error(`Timed out waiting for process log matching ${pattern}`); +} + +async function assertProcessLogAbsent( + getOutput: () => string, + pattern: RegExp, + waitMs = 500, +): Promise { + await wait(waitMs); + assert.doesNotMatch( + getOutput(), + pattern, + `Did not expect process log matching ${pattern}`, + ); +} + function resolveFixtures(): TestFixtures { const extensionRoot = process.cwd(); const repoRoot = path.resolve(extensionRoot, "..", ".."); @@ -546,7 +578,7 @@ export async function runSmokeSuite(): Promise { const exportedFunctions = await debuggerProcess.getContractFunctions(); const resolvedBreakpoints = resolveSourceBreakpoints( fixtures.sourcePath, - [10], + [14], exportedFunctions, ); assert.equal( @@ -561,10 +593,36 @@ export async function runSmokeSuite(): Promise { "Expected heuristic mapping to still set a function breakpoint", ); + const nonExportedBreakpoints = resolveSourceBreakpoints( + fixtures.sourcePath, + [10], + exportedFunctions, + ); + assert.equal( + nonExportedBreakpoints[0].verified, + false, + "Expected non-exported function mapping to be unverified", + ); + assert.equal( + nonExportedBreakpoints[0].functionName, + "helper", + "Expected non-exported function line to map to helper", + ); + assert.equal( + nonExportedBreakpoints[0].reasonCode, + "HEURISTIC_NOT_EXPORTED", + "Expected non-exported function reason code", + ); + assert.equal( + nonExportedBreakpoints[0].setBreakpoint, + false, + "Expected non-exported function mapping to skip runtime breakpoint install", + ); + // Test HEURISTIC_NO_FUNCTION behavior for lines outside any function const noFunctionBreakpoints = resolveSourceBreakpoints( fixtures.sourcePath, - [1, 2, 13], // Lines outside any function in lib.rs + [1, 2, 12, 16], // Lines outside any function in lib.rs exportedFunctions, ); @@ -740,9 +798,15 @@ async function runDapHappyPathE2E( fixtures: Pick, ): Promise { const proc = spawn(process.execPath, [debugAdapterPath], { + env: { ...process.env, [BREAKPOINT_SYNC_TEST_LOG_ENV]: "1" }, stdio: ["pipe", "pipe", "pipe"], }); const client = new DapClient(proc); + let stderrOutput = ""; + proc.stderr.setEncoding("utf8"); + proc.stderr.on("data", (chunk: string) => { + stderrOutput += chunk; + }); try { const init = await client.request("initialize", { @@ -780,7 +844,7 @@ async function runDapHappyPathE2E( const setBps = await client.request("setBreakpoints", { source: { path: fixtures.sourcePath }, - breakpoints: [{ line: 10 }], + breakpoints: [{ line: 14 }], }); assert.equal( setBps.success, @@ -802,6 +866,44 @@ async function runDapHappyPathE2E( /HEURISTIC_NO_DWARF/, "Expected breakpoint message to include heuristic reason code", ); + const breakpointSyncLog = await waitForProcessLog( + () => stderrOutput, + /BREAKPOINT_SYNC_TEST .*"action":"set".*"functionName":"echo".*"success":true/, + ); + assert.match( + breakpointSyncLog, + /BREAKPOINT_SYNC_TEST/, + "Expected adapter test log to confirm runtime breakpoint installation", + ); + + const privateBps = await client.request("setBreakpoints", { + source: { path: fixtures.sourcePath }, + breakpoints: [{ line: 10 }], + }); + assert.equal( + privateBps.success, + true, + `setBreakpoints for non-exported function failed: ${privateBps.message || ""}`, + ); + assert.equal( + privateBps.body?.breakpoints?.[0]?.verified, + false, + "Expected non-exported function source mapping to be unverified", + ); + assert.equal( + privateBps.body?.breakpoints?.[0]?.reasonCode, + "HEURISTIC_NOT_EXPORTED", + "Expected non-exported reason code on source breakpoint response", + ); + assert.match( + String(privateBps.body?.breakpoints?.[0]?.message || ""), + /HEURISTIC_NOT_EXPORTED/, + "Expected non-exported breakpoint message to include reason code", + ); + await assertProcessLogAbsent( + () => stderrOutput, + /BREAKPOINT_SYNC_TEST .*"action":"set".*"functionName":"helper".*"success":true/, + ); const configDone = await client.request("configurationDone", {}); assert.equal( diff --git a/tests/fixtures/contracts/echo/src/lib.rs b/tests/fixtures/contracts/echo/src/lib.rs index 9ac00734..fdd1035b 100644 --- a/tests/fixtures/contracts/echo/src/lib.rs +++ b/tests/fixtures/contracts/echo/src/lib.rs @@ -6,7 +6,11 @@ pub struct Echo; #[contractimpl] impl Echo { - pub fn echo(_env: Env, v: Val) -> Val { + fn helper(v: Val) -> Val { v } + + pub fn echo(_env: Env, v: Val) -> Val { + Self::helper(v) + } }