diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68e8a098..0a53c5ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,13 +78,16 @@ jobs: node-version: 20 cache: npm cache-dependency-path: extensions/vscode/package-lock.json - # Using our new Makefile target here - - name: Build and Test Extension - run: make test-vscode - name: Install extension dependencies run: npm --prefix extensions/vscode ci + - name: Build extension dist + run: npm --prefix extensions/vscode run build + + - name: Check extension dist drift + run: npm --prefix extensions/vscode run check:dist + - name: Run extension smoke tests run: npm --prefix extensions/vscode run test:smoke diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index 6aad5144..042985f9 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -225,11 +225,13 @@ "scripts": { "vscode:prepublish": "npm run compile", "compile": "tsc -p ./", + "build:manifest": "node ./src/test/distDriftGuard.js --write-manifest", "watch": "tsc -watch -p ./", "pretest": "npm run compile", - "build": "npm run compile", + "build": "npm run compile && npm run build:manifest", + "check:dist": "node ./src/test/distDriftGuard.js", "test": "npm run test:smoke && npm run test:dap-e2e", - "ci": "npm run build && npm run test", + "ci": "npm run build && npm run check:dist && npm run test", "test:all": "node ./dist/test/runTest.js", "test:smoke": "node ./dist/test/runSmokeTest.js", "test:dap-e2e": "node ./dist/test/runDapE2E.js", 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/distDriftGuard.js b/extensions/vscode/src/test/distDriftGuard.js new file mode 100644 index 00000000..3319d410 --- /dev/null +++ b/extensions/vscode/src/test/distDriftGuard.js @@ -0,0 +1,139 @@ +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); + +const extensionRoot = path.resolve(__dirname, "..", ".."); +const srcRoot = path.join(extensionRoot, "src"); +const distRoot = path.join(extensionRoot, "dist"); +const manifestPath = path.join(distRoot, ".build-manifest.json"); + +function walkFiles(rootDir, predicate) { + if (!fs.existsSync(rootDir)) { + return []; + } + + const results = []; + const stack = [rootDir]; + + while (stack.length > 0) { + const currentDir = stack.pop(); + const entries = fs.readdirSync(currentDir, { withFileTypes: true }); + + for (const entry of entries) { + const absolutePath = path.join(currentDir, entry.name); + if (entry.isDirectory()) { + stack.push(absolutePath); + continue; + } + + if (predicate(absolutePath)) { + results.push(absolutePath); + } + } + } + + return results.sort(); +} + +function toPosixRelative(rootDir, absolutePath) { + return path.relative(rootDir, absolutePath).split(path.sep).join("/"); +} + +function collectTrackedSources() { + const sourceFiles = walkFiles(srcRoot, (absolutePath) => absolutePath.endsWith(".ts")); + const configFiles = ["package.json", "tsconfig.json"].map((relativePath) => + path.join(extensionRoot, relativePath), + ); + + return [...sourceFiles, ...configFiles] + .filter((absolutePath) => fs.existsSync(absolutePath)) + .sort(); +} + +function collectDistFiles() { + return walkFiles( + distRoot, + (absolutePath) => path.basename(absolutePath) !== ".build-manifest.json", + ).map((absolutePath) => toPosixRelative(distRoot, absolutePath)); +} + +function hashFiles(rootDir, files) { + const hash = crypto.createHash("sha256"); + for (const absolutePath of files) { + hash.update(toPosixRelative(rootDir, absolutePath)); + hash.update("\0"); + hash.update(fs.readFileSync(absolutePath)); + hash.update("\0"); + } + return hash.digest("hex"); +} + +function buildManifest() { + const trackedSources = collectTrackedSources(); + return { + version: 1, + sourceHash: hashFiles(extensionRoot, trackedSources), + trackedSources: trackedSources.map((absolutePath) => + toPosixRelative(extensionRoot, absolutePath), + ), + distFiles: collectDistFiles(), + }; +} + +function writeManifest() { + if (!fs.existsSync(distRoot)) { + throw new Error( + "dist/ is missing. Run the TypeScript compile step before writing the build manifest.", + ); + } + + const manifest = buildManifest(); + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + console.log(`Wrote dist drift manifest to ${manifestPath}`); +} + +function checkManifest() { + if (!fs.existsSync(manifestPath)) { + throw new Error("Missing dist build manifest. Run `npm run build` in extensions/vscode."); + } + + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + const currentManifest = buildManifest(); + + if (manifest.version !== currentManifest.version) { + throw new Error("Dist build manifest version mismatch. Rebuild the VS Code extension."); + } + + if (manifest.sourceHash !== currentManifest.sourceHash) { + throw new Error( + "VS Code extension dist drift detected: source inputs changed since the last build. Run `npm run build` in extensions/vscode.", + ); + } + + const missingDistFiles = manifest.distFiles.filter( + (relativePath) => !fs.existsSync(path.join(distRoot, relativePath)), + ); + if (missingDistFiles.length > 0) { + throw new Error( + `VS Code extension dist drift detected: missing generated files: ${missingDistFiles.join(", ")}`, + ); + } + + console.log("VS Code extension dist is up to date."); +} + +function main() { + if (process.argv.includes("--write-manifest")) { + writeManifest(); + return; + } + + checkManifest(); +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} 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) + } }