Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 4 additions & 2 deletions extensions/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions extensions/vscode/src/dap/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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<void> {
if (!this.debuggerProcess) {
return;
Expand Down
139 changes: 139 additions & 0 deletions extensions/vscode/src/test/distDriftGuard.js
Original file line number Diff line number Diff line change
@@ -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);
}
108 changes: 105 additions & 3 deletions extensions/vscode/src/test/runTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> }> {
Expand Down Expand Up @@ -163,6 +165,36 @@ async function wait(ms: number): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, ms));
}

async function waitForProcessLog(
getOutput: () => string,
pattern: RegExp,
timeoutMs = 5_000,
): Promise<string> {
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<void> {
await wait(waitMs);
assert.doesNotMatch(
getOutput(),
pattern,
`Did not expect process log matching ${pattern}`,
);
}

async function main(): Promise<void> {
if (!(await isLoopbackAvailable())) {
console.warn(
Expand Down Expand Up @@ -756,7 +788,7 @@ async function main(): Promise<void> {
const exportedFunctions = await debuggerProcess.getContractFunctions();
const resolvedBreakpoints = resolveSourceBreakpoints(
sourcePath,
[10],
[14],
exportedFunctions,
);
assert.equal(
Expand All @@ -771,10 +803,36 @@ async function main(): Promise<void> {
"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,
);

Expand Down Expand Up @@ -934,9 +992,15 @@ async function runDapHappyPathE2E(
fixtures: { contractPath: string; sourcePath: string; binaryPath: string },
): Promise<void> {
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", {
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down
Loading
Loading