#!/usr/bin/env node const assert = require("assert"); const cp = require("child_process"); const fs = require("fs"); const path = require("path"); const extension = require("../vscode-extension/extension"); class DapClient { constructor(spec) { this.child = cp.spawn(spec.command, spec.args, { cwd: spec.options && spec.options.cwd, env: spec.options && spec.options.env, detached: process.platform !== "win32" }); this.seq = 1; this.buffer = Buffer.alloc(0); this.messages = []; this.waiters = []; this.stderr = ""; this.child.stdout.on("data", (chunk) => { this.buffer = Buffer.concat([this.buffer, chunk]); this.parse(); }); this.child.stderr.on("data", (chunk) => { this.stderr += chunk.toString(); }); this.child.on("exit", () => this.flushWaiters()); } send(command, args = {}) { const seq = this.seq++; const message = { seq, type: "request", command, arguments: args }; const payload = Buffer.from(JSON.stringify(message)); this.child.stdin.write(`Content-Length: ${payload.length}\r\n\r\n`); this.child.stdin.write(payload); return seq; } async response(seq, command) { const message = await this.waitFor( (item) => item.type === "response" && item.request_seq === seq && item.command === command ); if (!message.success) { throw new Error( `DAP ${command} failed: ${message.message || JSON.stringify(message)}\n${this.stderr}` ); } return message; } terminate() { if (this.child.exitCode !== null) return; if (process.platform === "win32") { this.child.kill("SIGKILL"); return; } try { process.kill(-this.child.pid, "SIGKILL"); } catch (_) { this.child.kill("SIGKILL"); } } waitFor(predicate, timeoutMs = 240000) { const existing = this.messages.find(predicate); if (existing) return Promise.resolve(existing); return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.terminate(); reject(new Error(`timed out waiting for DAP message\n${this.stderr}`)); }, timeoutMs); this.waiters.push({ predicate, resolve, timer }); }); } parse() { while (true) { const headerEnd = this.buffer.indexOf("\r\n\r\n"); if (headerEnd < 0) return; const header = this.buffer.slice(0, headerEnd).toString(); const match = header.match(/Content-Length: (\d+)/i); if (!match) throw new Error(`bad DAP header: ${header}`); const length = Number(match[1]); const start = headerEnd + 4; const end = start + length; if (this.buffer.length < end) return; const payload = this.buffer.slice(start, end).toString(); this.buffer = this.buffer.slice(end); this.messages.push(JSON.parse(payload)); this.flushWaiters(); } } flushWaiters() { for (const waiter of [...this.waiters]) { const message = this.messages.find(waiter.predicate); if (!message) continue; clearTimeout(waiter.timer); this.waiters.splice(this.waiters.indexOf(waiter), 1); waiter.resolve(message); } } async close() { if (this.child.exitCode !== null) return; const seq = this.send("disconnect"); await this.response(seq, "disconnect"); this.child.stdin.end(); } } (async () => { const repo = path.resolve(__dirname, ".."); const project = path.join(repo, "examples/hello-build"); const buildSourceLines = fs .readFileSync(path.join(project, "src/lib.rs"), "utf8") .split(/\r?\n/); const sourceLine = (needle) => { const index = buildSourceLines.findIndex((line) => line.includes(needle)); assert(index >= 0, `flagship source must contain ${needle}`); return index + 1; }; const buildMainLine = sourceLine("async fn build()"); const launchConfig = extension.resolveClusterfluxDebugConfiguration( { uri: { fsPath: project } }, {} ); assert.strictEqual(launchConfig.type, "clusterflux"); assert.strictEqual(launchConfig.request, "launch"); assert.strictEqual(launchConfig.entry, "build"); assert.strictEqual(launchConfig.project, project); assert.strictEqual(launchConfig.runtimeBackend, "local-services"); const inspection = extension.refreshBundleBeforeLaunch(project, repo); assert.match(inspection.metadata.identity, /^sha256:/); // Keep the timed attach focused on the runtime boundary. A completely fresh // public checkout may otherwise spend most of that window compiling the // coordinator or node after the adapter has already started waiting. cp.execFileSync( "cargo", [ "build", "-q", "-p", "clusterflux-coordinator", "--bin", "clusterflux-coordinator", "-p", "clusterflux-node", "--bin", "clusterflux-node", ], { cwd: repo, stdio: "inherit" } ); const executableSuffix = process.platform === "win32" ? ".exe" : ""; const adapterSpec = extension.debugAdapterExecutableSpec(repo); adapterSpec.options = { ...(adapterSpec.options || {}), env: { ...process.env, CLUSTERFLUX_COORDINATOR_BIN: path.join( repo, "target", "debug", `clusterflux-coordinator${executableSuffix}` ), CLUSTERFLUX_NODE_BIN: path.join( repo, "target", "debug", `clusterflux-node${executableSuffix}` ), }, }; const client = new DapClient(adapterSpec); try { const initialize = client.send("initialize", { adapterID: "clusterflux", linesStartAt1: true, columnsStartAt1: true }); await client.response(initialize, "initialize"); const launch = client.send("launch", launchConfig); await client.response(launch, "launch"); await client.waitFor( (message) => message.type === "event" && message.event === "initialized" ); const breakpoints = client.send("setBreakpoints", { source: { path: path.join(project, "src/lib.rs") }, breakpoints: [{ line: buildMainLine }] }); const breakpointResponse = await client.response(breakpoints, "setBreakpoints"); assert.strictEqual(breakpointResponse.body.breakpoints[0].verified, false); assert.match( breakpointResponse.body.breakpoints[0].message, /Pending coordinator breakpoint installation/ ); const configurationDone = client.send("configurationDone"); await client.response(configurationDone, "configurationDone"); const installedBreakpoint = await client.waitFor( (message) => message.type === "event" && message.event === "breakpoint" && message.body?.breakpoint?.verified === true ); assert.strictEqual(installedBreakpoint.body.breakpoint.line, buildMainLine); const stopped = await client.waitFor( (message) => message.type === "event" && message.event === "stopped" ); assert.strictEqual(stopped.body.allThreadsStopped, true); assert.strictEqual(stopped.body.reason, "breakpoint"); const threadsRequest = client.send("threads"); const threads = (await client.response(threadsRequest, "threads")).body.threads; const mainThread = threads.find((thread) => thread.name.includes("build coordinator main")); assert(mainThread, "F5 launch must expose the real coordinator-main entrypoint thread"); const stackRequest = client.send("stackTrace", { threadId: mainThread.id, startFrame: 0, levels: 1 }); const stack = (await client.response(stackRequest, "stackTrace")).body.stackFrames; assert.strictEqual(stack[0].line, buildMainLine); assert.strictEqual(stack[0].source.path, path.join(project, "src/lib.rs")); assert.strictEqual(stack[0].source.sourceReference || 0, 0); const sourceRequest = client.send("source", { source: stack[0].source }); const source = (await client.response(sourceRequest, "source")).body; assert.match(source.content, /compile/); const scopesRequest = client.send("scopes", { frameId: stack[0].id }); const scopes = (await client.response(scopesRequest, "scopes")).body.scopes; const localsScope = scopes.find((scope) => scope.name === "Source Locals"); const argsScope = scopes.find((scope) => scope.name === "Task Args and Handles"); const runtimeScope = scopes.find((scope) => scope.name === "Clusterflux Runtime"); const outputScope = scopes.find((scope) => scope.name === "Recent Output"); assert(localsScope, "F5 launch must expose source locals scope"); assert(argsScope, "F5 launch must expose task args and handles"); assert(runtimeScope, "F5 launch must expose Clusterflux runtime state"); assert(outputScope, "F5 launch must expose recent output state"); const localsRequest = client.send("variables", { variablesReference: localsScope.variablesReference }); const locals = (await client.response(localsRequest, "variables")).body.variables; assert( locals.some( (variable) => variable.name === "unavailable-local-diagnostic" && String(variable.value).includes("cannot be inspected") ), "source locals scope must report unavailable real Rust locals explicitly" ); const runtimeRequest = client.send("variables", { variablesReference: runtimeScope.variablesReference }); const runtime = (await client.response(runtimeRequest, "variables")).body.variables; assert( runtime.some( (variable) => variable.name === "runtime_backend" && variable.value === "LocalServices" ), "extension-resolved F5 launch must use the real local-services backend" ); assert( runtime.some( (variable) => variable.name === "coordinator_task_events" && variable.value === 0 ), "a task frozen at its entry probe must not fabricate a terminal task event" ); assert( runtime.some( (variable) => variable.name === "command_status" && String(variable.value).includes( "frozen through local services at executing Wasm probe" ) ) ); assert( runtime.some( (variable) => variable.name === "state" && variable.value === "Frozen" ), "F5 must expose the node-acknowledged frozen participant state" ); assert(runtime.some((variable) => variable.name === "command_spec")); assert(runtime.some((variable) => variable.name === "stdout_tail")); assert(runtime.some((variable) => variable.name === "stderr_tail")); const outputRequest = client.send("variables", { variablesReference: outputScope.variablesReference }); const output = (await client.response(outputRequest, "variables")).body.variables; assert(output.some((variable) => variable.name === "stdout_tail")); assert(output.some((variable) => variable.name === "stderr_tail")); await client.close(); } catch (error) { client.terminate(); throw error; } console.log("VS Code F5 smoke passed"); })().catch((error) => { console.error(error.stack || error.message); process.exit(1); });