import assert from "node:assert/strict"; import * as vscode from "vscode"; import { control, spawnCounter, startImage } from "./server"; import { ImageSourceTree, RevisionDocuments } from "../src/sourceProvider"; export async function run(): Promise { const image = await startImage(); const folders = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.toString()); try { await vscode.commands.executeCommand("runa.connect", image.address); assert.ok(!(await vscode.commands.getCommands(true)).includes("runa.addSourceFolder")); assert.deepEqual( vscode.workspace.workspaceFolders?.map((folder) => folder.uri.toString()), folders, ); await editSources(image.address); const spawned = await vscode.commands.executeCommand("runa.spawn", { path: "actors/counter", state: "5", }); assert.equal(typeof spawned, "string"); const selection = { actor_ref: String(spawned) }; await vscode.commands.executeCommand("runa.inspect", selection); const controlled = vscode.window.activeTextEditor?.document; assert.ok(controlled); await vscode.commands.executeCommand("runa.send", selection, '{"op":"add","value":7}'); const controlPaused = documentChanged(controlled, (text) => text.includes('"status": "paused"'), ); await vscode.commands.executeCommand("runa.pause", selection); await vscode.commands.executeCommand("runa.tick", 100); await controlPaused; let snapshot = JSON.parse(controlled.getText()).actor; assert.equal(snapshot.status.status, "paused"); assert.equal(snapshot.state.data, 5); assert.equal(snapshot.queued.length, 1); const controlExecuted = documentChanged( controlled, (text) => JSON.parse(text).actor.state.data === 12, ); await vscode.commands.executeCommand("runa.resume", selection); await vscode.commands.executeCommand("runa.tick", 100); await controlExecuted; snapshot = JSON.parse(controlled.getText()).actor; assert.equal(snapshot.state.data, 12); assert.equal(snapshot.queued.length, 0); await vscode.commands.executeCommand("runa.stop", selection); await vscode.commands.executeCommand("runa.inspect", selection); assert.notEqual(JSON.parse(controlled.getText()).actor.status.status, "stopped"); const controlStopped = documentChanged(controlled, (text) => text.includes('"status": "stopped"'), ); // Graceful stop completes only when the scheduler advances. await vscode.commands.executeCommand("runa.tick", 10); await controlStopped; assert.equal(JSON.parse(controlled.getText()).actor.status.status, "stopped"); const controlRestarted = documentChanged( controlled, (text) => JSON.parse(text).actor.incarnation === 2, ); await vscode.commands.executeCommand("runa.restart", selection); await controlRestarted; snapshot = JSON.parse(controlled.getText()).actor; assert.equal(snapshot.incarnation, 2); assert.equal(snapshot.state.data, 12); const controlKilled = documentChanged(controlled, (text) => text.includes('"status": "stopped"'), ); await vscode.commands.executeCommand("runa.kill", selection); await controlKilled; const controlReloaded = documentChanged( controlled, (text) => JSON.parse(text).actor.incarnation === 3, ); await vscode.commands.executeCommand("runa.restart", selection); await controlReloaded; await vscode.commands.executeCommand("runa.reload", selection); await vscode.commands.executeCommand("runa.tick", 10); const actorRef = await spawnCounter(image.address); await vscode.commands.executeCommand("runa.connect", image.address); await vscode.commands.executeCommand("runa.refresh"); await vscode.commands.executeCommand("runa.inspect", { actor_ref: actorRef }); let document = vscode.window.activeTextEditor?.document; assert.ok(document); assert.equal(document.uri.scheme, "runa"); assert.equal(document.languageId, "json"); assert.match(document.getText(), /9223372036854775807/); assert.match(document.getText(), /"connected": true/); const firstUri = document.uri.toString(); const refreshed = documentChanged(document, (text) => text.includes("queued from another client"), ); assert.deepEqual( await control(image.address, { operation: "send", parameters: { actor_ref: actorRef, message: { type: "string", data: "queued from another client" }, }, }), { version: 4, request_id: "integration-control", result: { Ok: { kind: "acknowledged" } } }, ); await refreshed; assert.match(document.getText(), /queued from another client/); const paused = documentChanged(document, (text) => text.includes('"status": "paused"')); await control(image.address, { operation: "system", parameters: { actor_ref: actorRef, message: { kind: "pause" } }, }); await paused; const failed = documentChanged(document, (text) => text.includes('"status": "failed"')); await control(image.address, { operation: "system", parameters: { actor_ref: actorRef, message: { kind: "resume" } }, }); await control(image.address, { operation: "tick", parameters: { ticks: 100, instruction_budget: 10 }, }); await failed; const disconnected = documentChanged(document, (text) => text.includes('"connected": false')); await vscode.commands.executeCommand("runa.disconnect"); await disconnected; assert.match(document.getText(), /"connected": false/); await vscode.commands.executeCommand("runa.connect", image.address); await vscode.commands.executeCommand("runa.inspect", { actor_ref: actorRef }); document = vscode.window.activeTextEditor?.document; assert.ok(document); assert.notEqual(document.uri.toString(), firstUri); assert.match(document.getText(), /queued from another client/); const old = await vscode.workspace.openTextDocument(vscode.Uri.parse(firstUri)); assert.match(old.getText(), /"connected": false/); console.log( "Runa extension host: spawn, send, system messages, ticks, inspection, automatic refresh, reconnect passed", ); } finally { await vscode.commands.executeCommand("runa.disconnect"); await image.stop(); } } async function editSources(address: string): Promise { const extension = vscode.extensions.all.find( (candidate) => candidate.packageJSON.name === "runa", ); assert.ok(extension); const { sourceTree } = extension.exports as { sourceTree: ImageSourceTree }; const [root] = sourceTree.getChildren(); assert.ok(root); assert.equal(root.uri.toString(), `runa-image://${address}/source`); assert.deepEqual(await vscode.workspace.fs.readDirectory(root.uri), [ ["actors", vscode.FileType.Directory], ]); const [directory] = sourceTree.getChildren(root); assert.ok(directory); assert.equal(directory.directory, true); const [file] = sourceTree.getChildren(directory); assert.ok(file); assert.equal(sourceTree.getTreeItem(file).contextValue, "runa.sourceFile"); const actors = vscode.Uri.joinPath(root.uri, "actors"); assert.deepEqual(await vscode.workspace.fs.readDirectory(actors), [ ["counter.rn", vscode.FileType.File], ]); const uri = vscode.Uri.joinPath(actors, "counter.rn"); const document = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(document); const original = document.getText(); assert.match(original, /pub fn handle\(ctx, state, sender, message\)/); const edited = "pub fn handle(ctx, state, sender, message) {\n state + 2\n}\n"; await replaceText(document, edited); const window = vscode.window as { showWarningMessage: (...args: unknown[]) => unknown }; const warning = window.showWarningMessage; const prompts: unknown[][] = []; let answer: string | undefined; window.showWarningMessage = async (...args: unknown[]) => { prompts.push(args); return answer; }; try { // Unsaved edits are never committed or spawned without an explicit save. assert.equal(await vscode.commands.executeCommand("runa.commitSource", uri), undefined); assert.equal(await vscode.commands.executeCommand("runa.spawnSource", uri), undefined); assert.equal(prompts.length, 2); assert.deepEqual(prompts[0]?.[1], { modal: true }); assert.equal(document.isDirty, true); const unsaved = JSON.stringify( await control(address, { operation: "read_source", parameters: { path: "actors/counter" } }), ); assert.ok(unsaved.includes(JSON.stringify(original))); assert.match( JSON.stringify(await control(address, { operation: "list_actors" })), /"data":\[\]/, ); answer = "Save and Spawn"; const saved = await vscode.commands.executeCommand("runa.spawnSource", uri); assert.match(String(saved), /^[A-Za-z0-9_-]{21}$/); assert.equal(prompts.length, 3); assert.equal(document.isDirty, false, "the explicit choice saves before spawning"); } finally { window.showWarningMessage = warning; } assert.equal(document.isDirty, false); assert.equal(document.isDirty, false); let reply = JSON.stringify( await control(address, { operation: "read_source", parameters: { path: "actors/counter" } }), ); assert.ok(reply.includes(JSON.stringify(edited)), reply); reply = JSON.stringify( await control(address, { operation: "source_history", parameters: { path: "actors/counter" } }), ); assert.match(reply, /"kind":"source_history","data":\[\]/); // Spawn context-menu actions receive the explorer URI and use the saved, uncommitted source. const nullActor = await vscode.commands.executeCommand("runa.spawnSource", uri); assert.match(String(nullActor), /^[A-Za-z0-9_-]{21}$/); reply = JSON.stringify( await control(address, { operation: "inspect_actor", parameters: { actor_ref: nullActor } }), ); assert.match(reply, /"state":\{"type":"null"\}/); const inputWindow = vscode.window as { showInputBox: (...args: unknown[]) => unknown }; const originalInput = inputWindow.showInputBox; const inputs: unknown[][] = []; let stateAnswer: string | undefined; inputWindow.showInputBox = async (...args: unknown[]) => { inputs.push(args); return stateAnswer; }; let actor: string | undefined; try { assert.equal( await vscode.commands.executeCommand("runa.spawnSourceWithState", file, [file]), undefined, ); stateAnswer = "40"; actor = await vscode.commands.executeCommand("runa.spawnSourceWithState", file, [file]); assert.equal(inputs.length, 2, "context menus always prompt for state"); assert.match(JSON.stringify(inputs[1]), /Initial state for actors\/counter/); } finally { inputWindow.showInputBox = originalInput; } assert.match(String(actor), /^[A-Za-z0-9_-]{21}$/); await vscode.commands.executeCommand("runa.send", { actor_ref: actor }, "null"); await vscode.commands.executeCommand("runa.tick", 10); reply = JSON.stringify( await control(address, { operation: "inspect_actor", parameters: { actor_ref: actor } }), ); assert.match(reply, /"state":\{"type":"integer","data":42\}/); const revision = await vscode.commands.executeCommand("runa.commitSource", uri); assert.match(String(revision), /^[A-Za-z0-9]{1,64}$/); const opened = await vscode.commands.executeCommand("runa.sourceHistory", uri, revision); const historical = vscode.window.activeTextEditor?.document; assert.ok(historical); assert.equal(historical.uri.toString(), opened); assert.equal(historical.uri.scheme, "runa-source"); assert.ok(historical.uri.path.endsWith(`/source/actors/counter@${revision}.rn`)); assert.equal(historical.getText(), edited); await vscode.commands.executeCommand("type", { text: "// cannot edit\n" }); assert.equal(historical.getText(), edited); // Saving again changes only the working source; history is unchanged. await vscode.window.showTextDocument(document); await replaceText(document, original); assert.ok(await document.save()); assert.equal(historical.getText(), edited); prompts.length = 0; answer = undefined; window.showWarningMessage = async (...args: unknown[]) => { prompts.push(args); return answer; }; try { await vscode.commands.executeCommand("runa.rollbackSource", uri, revision); assert.equal(prompts.length, 1); assert.deepEqual(prompts[0]?.[1], { modal: true }); assert.equal(document.getText(), original, "cancelled rollback changes nothing"); answer = "Roll Back"; const reloaded = documentChanged(document, (text) => text === edited); await vscode.commands.executeCommand("runa.rollbackSource", uri, revision); await reloaded; } finally { window.showWarningMessage = warning; } assert.equal(document.isDirty, false); reply = JSON.stringify( await control(address, { operation: "source_history", parameters: { path: "actors/counter" } }), ); assert.equal((reply.match(/"revision"/g) ?? []).length, 1); // Saves from another client reload clean editors through image notifications. const current = JSON.parse( JSON.stringify( await control(address, { operation: "read_source", parameters: { path: "actors/counter" } }), ), ).result.Ok.data; const external = documentChanged(document, (text) => text === original); await control(address, { operation: "write_source", parameters: { path: "actors/counter", expected_source_id: current.source_id, source: original }, }); await external; // Revision documents stay isolated per connection session. await vscode.commands.executeCommand("runa.disconnect"); assert.equal(historical.getText(), edited); await vscode.commands.executeCommand("runa.connect", address); assert.equal( vscode.workspace.workspaceFolders?.filter((candidate) => candidate.uri.scheme === "runa-image") .length, 0, ); const reopened = await vscode.commands.executeCommand( "runa.sourceHistory", uri, revision, ); assert.ok(reopened); assert.notEqual(reopened, opened); assert.equal(vscode.window.activeTextEditor?.document.getText(), edited); const revisions = new RevisionDocuments(); try { const cached = revisions.set(1, "actors/counter", "abc", edited); assert.equal(revisions.provideTextDocumentContent(cached), edited); assert.notEqual( revisions.set(2, "actors/counter", "abc", original).toString(), cached.toString(), ); assert.throws(() => revisions.set(1, "actors/counter", "abc", original), /Immutable/); revisions.remove(cached); assert.throws(() => revisions.provideTextDocumentContent(cached), /no longer cached/); } finally { revisions.dispose(); } console.log( "Runa sources: automatic tree, state input, save, uncommitted spawn, commit, history, rollback, session isolation passed", ); } async function replaceText(document: vscode.TextDocument, text: string): Promise { const edit = new vscode.WorkspaceEdit(); edit.replace( document.uri, new vscode.Range(document.positionAt(0), document.positionAt(document.getText().length)), text, ); assert.ok(await vscode.workspace.applyEdit(edit)); } function documentChanged( document: vscode.TextDocument, expected: (text: string) => boolean, ): Promise { return new Promise((resolve, reject) => { const subscription = vscode.workspace.onDidChangeTextDocument((event) => { if ( event.document.uri.toString() === document.uri.toString() && expected(event.document.getText()) ) { clearTimeout(timeout); subscription.dispose(); resolve(); } }); const timeout = setTimeout(() => { subscription.dispose(); reject(new Error("Actor inspector did not refresh")); }, 5000); }); }