runa/vscode/integration/host.ts

1051 lines
44 KiB
TypeScript
Raw Normal View History

import assert from "node:assert/strict";
import * as vscode from "vscode";
import { control, spawnCounter, startImage } from "./server";
import { ImageSourceTree, RevisionDocuments } from "../src/sourceProvider";
import { actorFileUri } from "../src/actorFiles";
import type { activate } from "../src/extension";
import { SourceDiagnostics } from "../src/diagnostics";
import { SourceDiagnostic } from "../src/protocol";
import { ImageClient } from "../src/client";
import { parseValue } from "../src/values";
import { ExecutionDocuments, frameSelection } from "../src/executionProvider";
export async function run(): Promise<void> {
const image = await startImage();
const folders = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.toString());
try {
const extension = vscode.extensions.all.find(
(candidate) => candidate.packageJSON.name === "runa",
);
assert.ok(extension);
const contributions = extension.packageJSON.contributes;
assert.ok(
contributions.viewsContainers.activitybar.some(
(container: { id: string }) => container.id === "runa",
),
);
assert.deepEqual(
contributions.views.runa.map((view: { id: string }) => view.id),
["runa.sources", "runa.actors"],
);
assert.equal(contributions.views.explorer, undefined);
assert.ok(
contributions.menus["view/title"].some(
(item: { command: string; when: string }) =>
item.command === "runa.tick" && item.when.includes("runa.actors"),
),
);
await vscode.commands.executeCommand("workbench.view.extension.runa");
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);
await checkSourceDiagnostics(image.address);
await inspectExecution(image.address);
await sourceDebugging(image.address);
await editActorFiles(image.address);
const spawned = await vscode.commands.executeCommand<unknown>("runa.spawn", {
path: "actors/counter",
state: "5",
});
assert.equal(typeof spawned, "string");
const selection = { actor_ref: String(spawned) };
await vscode.commands.executeCommand("runa.copyActorReference", selection);
assert.equal(await vscode.env.clipboard.readText(), 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);
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);
await vscode.commands.executeCommand("runa.reload", selection);
await vscode.commands.executeCommand("runa.tick", 10);
await vscode.commands.executeCommand("runa.stop", selection);
assert.equal(JSON.parse(controlled.getText()).removed, undefined);
const controlStopped = documentChanged(controlled, (text) => JSON.parse(text).removed === true);
await vscode.commands.executeCommand("runa.tick", 10);
await controlStopped;
assert.equal(
(await vscode.workspace.fs.stat(controlled.uri)).permissions,
vscode.FilePermission.Readonly,
);
const doomed = await vscode.commands.executeCommand<string>("runa.spawn", {
path: "actors/counter",
state: "0",
});
await vscode.commands.executeCommand("runa.inspect", { actor_ref: doomed });
const killed = vscode.window.activeTextEditor?.document;
assert.ok(killed);
const controlKilled = documentChanged(killed, (text) => JSON.parse(text).removed === true);
await vscode.commands.executeCommand("runa.kill", { actor_ref: doomed });
await controlKilled;
assert.equal(
(await vscode.workspace.fs.stat(killed.uri)).permissions,
vscode.FilePermission.Readonly,
);
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: 5, 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/);
assert.equal((await vscode.workspace.fs.stat(document.uri)).permissions, undefined);
const updated = JSON.parse(document.getText());
updated.actor.state = { type: "integer", data: 77 };
updated.actor.reserved = null;
updated.actor.queued = [
{
type: "record",
data: { op: { type: "string", data: "add" }, value: { type: "integer", data: 2 } },
},
];
updated.actor.log_level = "warn";
updated.actor.incarnation = 999;
updated.connection.endpoint = "not the actual endpoint";
const canonical = documentChanged(document, (text) => {
const saved = JSON.parse(text);
return (
saved.actor.state.data === 77 &&
saved.actor.incarnation !== 999 &&
saved.actor.log_level === "warn" &&
saved.connection.endpoint === image.address
);
});
const edit = new vscode.WorkspaceEdit();
edit.replace(
document.uri,
new vscode.Range(document.positionAt(0), document.positionAt(document.getText().length)),
JSON.stringify(updated, null, 2),
);
assert.equal(await vscode.workspace.applyEdit(edit), true);
const unsaved = document.getText();
await vscode.commands.executeCommand("runa.refresh");
assert.equal(document.getText(), unsaved, "refresh must preserve unsaved inspection edits");
const window = vscode.window as { showErrorMessage: (...args: unknown[]) => unknown };
const showError = window.showErrorMessage;
const saveErrors: unknown[][] = [];
window.showErrorMessage = async (...args: unknown[]) => {
saveErrors.push(args);
return undefined;
};
try {
assert.equal(await document.save(), true);
await canonical;
assert.deepEqual(saveErrors, [], "successful inspection save must not report refresh errors");
assert.equal(document.isDirty, false);
} finally {
window.showErrorMessage = showError;
}
const recovered = documentChanged(document, (text) => {
const actor = JSON.parse(text).actor;
return (
actor.state.data === 79 && actor.log_level === "warn" && actor.status.status === "idle"
);
});
await vscode.commands.executeCommand("runa.logLevel", { actor_ref: actorRef }, "warn");
await vscode.commands.executeCommand("runa.restart", { actor_ref: actorRef });
await vscode.commands.executeCommand("runa.tick", 100);
await recovered;
await vscode.commands.executeCommand("runa.inspect", { actor_ref: actorRef });
const repaired = vscode.window.activeTextEditor?.document;
assert.ok(repaired);
assert.equal(JSON.parse(repaired.getText()).actor.state.data, 79, repaired.getText());
assert.equal(JSON.parse(repaired.getText()).actor.log_level, "warn");
assert.equal(JSON.parse(repaired.getText()).actor.status.status, "idle");
assert.equal(
(await vscode.workspace.fs.stat(repaired.uri)).permissions,
vscode.FilePermission.Readonly,
);
await assert.rejects(async () => {
await vscode.workspace.fs.writeFile(repaired.uri, Buffer.from(repaired.getText()));
}, /paused or failed/);
await checkDiagnosticRaces(image.address);
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 inspectExecution(address: string): Promise<void> {
const client = await ImageClient.connect(address, (error) => assert.equal(error, undefined));
try {
const source =
"pub fn handle(ctx, state, sender, message) {\n nested()\n}\nfn nested() {\n loop {}\n}";
const file = await client.writeSource("tests/execution", null, source);
const actor = await client.spawn(file.path, parseValue("null"));
await client.send(actor, parseValue("null"));
await client.tick(1, 16);
await client.system(actor, { kind: "pause" });
await client.tick(1, 1);
const loaded = await client.inspectExecution(actor);
const location = loaded.execution?.frames[0]?.range;
assert.ok(location);
await client.writeSource(
file.path,
file.source_id,
"pub fn handle(ctx, state, sender, message) { 42 }",
);
await client.system(actor, { kind: "reload" });
await vscode.commands.executeCommand("runa.inspectExecution", { actor_ref: actor }, 0);
const editor = vscode.window.activeTextEditor;
assert.ok(editor);
const document = editor.document;
assert.equal(document.uri.scheme, "runa-execution");
assert.equal(document.languageId, "rune");
const frame = loaded.execution?.frames[0];
assert.ok(frame);
assert.throws(
() =>
frameSelection(document, {
...frame,
range: {
start: { line: document.lineCount, character: 0 },
end: { line: document.lineCount, character: 0 },
},
}),
/outside the loaded source/,
);
assert.equal(frameSelection(document, { ...frame, range: null }), undefined);
const cache = new ExecutionDocuments();
const cached = cache.set(1, loaded);
assert.throws(
() => cache.set(1, { ...loaded, source: "changed" }),
/Immutable execution source changed/,
);
assert.equal(cache.provideTextDocumentContent(cached), source);
cache.remove(cached);
assert.throws(() => cache.provideTextDocumentContent(cached), /no longer cached/);
cache.dispose();
assert.equal(
document.getText(),
source,
"navigation opens loaded code, not newer shared source",
);
assert.equal(editor.selection.start.line, location.start.line);
assert.equal(editor.selection.start.character, location.start.character);
assert.equal(editor.selection.end.character, location.end.character);
await vscode.commands.executeCommand("type", { text: "// cannot edit\n" });
assert.equal(document.getText(), source);
assert.equal(document.isDirty, false);
await vscode.commands.executeCommand("runa.inspectExecution", { actor_ref: actor }, 1);
assert.equal(vscode.window.activeTextEditor?.document.uri.toString(), document.uri.toString());
assert.equal(
vscode.window.activeTextEditor?.selection.start.line,
1,
"caller selects call site, not return location",
);
await client.system(actor, { kind: "kill" });
assert.equal(document.getText(), source, "loaded document survives actor removal");
const firstUri = document.uri.toString();
await vscode.commands.executeCommand("runa.disconnect");
assert.equal(document.getText(), source, "loaded document survives disconnect");
await vscode.commands.executeCommand("runa.connect", address);
const second = await client.spawn(file.path, parseValue("null"));
const temporary = await client.actorBehavior(second);
await client.writeActorBehavior(second, temporary.source_id, source);
await client.send(second, parseValue("null"));
await client.tick(1, 16);
await vscode.commands.executeCommand("runa.inspectExecution", { actor_ref: second }, 0);
const reopened = vscode.window.activeTextEditor?.document;
assert.ok(reopened);
assert.notEqual(reopened.uri.toString(), firstUri, "loaded documents are connection-scoped");
assert.equal(reopened.getText(), source, "temporary implementations expose their exact source");
await client.system(second, { kind: "kill" });
console.log(
"Runa execution inspection: exact loaded source, caller navigation, read-only documents and session isolation passed",
);
} finally {
await client.close();
}
}
async function sourceDebugging(address: string): Promise<void> {
const client = await ImageClient.connect(address, (error) => assert.equal(error, undefined));
try {
const source =
"pub fn handle(ctx, state, sender, message) {\n let next = nested(state);\n next + 1\n}\nfn nested(value) {\n value + 1\n}";
const file = await client.writeSource("tests/source_debugging", null, source);
const actor = await client.spawn(file.path, parseValue("10"));
const selected = { actor_ref: actor };
await vscode.commands.executeCommand("runa.setBreakpoints", selected, "2");
const bps = (await client.inspectExecution(actor)).debugger?.breakpoints;
assert.equal(bps?.[0]?.line, 1);
assert.ok(bps[0].instruction_pointers.length);
await client.send(actor, parseValue("null"));
await client.tick(1, 1000);
assert.equal((await client.inspectActor(actor)).status.status, "paused");
assert.equal(await vscode.commands.executeCommand("runa.stepSource", selected), "step");
const editor = vscode.window.activeTextEditor;
assert.ok(editor);
assert.equal(editor.document.uri.scheme, "runa-execution");
assert.equal(editor.document.getText(), source);
const inspection = await client.inspectExecution(actor);
assert.equal(inspection.execution?.frames[0]?.function, "nested");
assert.equal(editor.selection.start.line, 5);
assert.deepEqual((await client.inspectActor(actor)).state, parseValue("10"));
await vscode.commands.executeCommand("runa.setBreakpoints", selected, "");
assert.deepEqual((await client.inspectExecution(actor)).debugger?.breakpoints, []);
assert.equal(await vscode.commands.executeCommand("runa.stepOut", selected), "step");
const caller = await client.inspectExecution(actor);
assert.equal(caller.execution?.call_depth, 0n);
assert.equal(caller.execution?.frames[0]?.function, "handle");
assert.equal(
vscode.window.activeTextEditor?.selection.start.line,
caller.execution?.frames[0]?.range?.start.line,
);
let completed = false;
for (let i = 0; i < 20; i++) {
if ((await vscode.commands.executeCommand("runa.stepSource", selected)) === "completed") {
completed = true;
break;
}
}
assert.ok(completed);
const snapshot = await client.inspectActor(actor);
assert.equal(snapshot.status.status, "paused");
assert.deepEqual(snapshot.state, parseValue("12"));
await client.system(actor, { kind: "restart" });
await vscode.commands.executeCommand("runa.setBreakpoints", selected, "2");
await client.send(actor, parseValue("null"));
await client.tick(1, 1000);
await vscode.commands.executeCommand("runa.setBreakpoints", selected, "");
assert.equal(await vscode.commands.executeCommand("runa.stepOver", selected), "step");
const over = await client.inspectExecution(actor);
assert.equal(over.execution?.call_depth, 0n);
assert.equal(over.execution?.frames[0]?.range?.start.line, 2);
assert.equal(vscode.window.activeTextEditor?.selection.start.line, 2);
assert.deepEqual((await client.inspectActor(actor)).state, parseValue("12"));
await client.system(actor, { kind: "kill" });
console.log(
"Runa source debugger: actor breakpoints, step into/over/out navigation, clearing and transactional completion passed",
);
} finally {
await client.close();
}
}
async function editSources(address: string): Promise<void> {
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<string>("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<string>("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<string>("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<string>("runa.commitSource", uri);
assert.match(String(revision), /^[A-Za-z0-9]{1,64}$/);
const opened = await vscode.commands.executeCommand<string>("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,
0,
);
const reopened = await vscode.commands.executeCommand<string>(
"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<void> {
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));
}
async function checkSourceDiagnostics(address: string): Promise<void> {
await vscode.commands.executeCommand("runa.newSource", undefined, "tests/diagnostics.rn");
const document = vscode.window.activeTextEditor?.document;
assert.ok(document);
const original = document.getText();
const invalid = 'pub fn handle(ctx, state, sender, message) {\r\n "é😀" + missing\r\n}';
await replaceText(document, invalid);
await diagnosticsChanged(document.uri, (diagnostics) =>
diagnostics.some((diagnostic) => diagnostic.severity === vscode.DiagnosticSeverity.Error),
);
const error = vscode.languages
.getDiagnostics(document.uri)
.find((diagnostic) => diagnostic.severity === vscode.DiagnosticSeverity.Error);
assert.ok(error);
assert.equal(error.source, "Rune");
assert.equal(error.range.start.line, 1);
assert.equal(error.range.start.character, 12);
assert.equal(error.range.end.character, 19);
assert.equal(document.isDirty, true);
assert.equal(
Buffer.from(await vscode.workspace.fs.readFile(document.uri)).toString(),
original,
"validation does not save unsaved source",
);
await replaceText(document, "pub fn handle(ctx, state, sender, message) { `hello`; state }");
await diagnosticsChanged(
document.uri,
(diagnostics) =>
diagnostics.some((diagnostic) => diagnostic.severity === vscode.DiagnosticSeverity.Warning) &&
diagnostics.every((diagnostic) => diagnostic.severity !== vscode.DiagnosticSeverity.Error),
);
await replaceText(document, original);
await diagnosticsChanged(document.uri, (diagnostics) => diagnostics.length === 0);
assert.ok(await document.save());
const ref = await vscode.commands.executeCommand<string>("runa.spawn", {
path: "tests/diagnostics",
state: "0",
});
assert.ok(ref);
const selection = { actor_ref: ref };
await vscode.commands.executeCommand("runa.editBehavior", selection);
const behavior = vscode.window.activeTextEditor?.document;
assert.ok(behavior);
await replaceText(behavior, invalid);
await diagnosticsChanged(behavior.uri, (diagnostics) =>
diagnostics.some((diagnostic) => diagnostic.severity === vscode.DiagnosticSeverity.Error),
);
assert.equal(
vscode.languages.getDiagnostics(document.uri).length,
0,
"temporary diagnostics belong to the actor document",
);
await assert.rejects(
async () => vscode.workspace.fs.writeFile(behavior.uri, Buffer.from(invalid)),
/Rune compilation failed/,
);
const inspection = JSON.stringify(
await control(address, { operation: "inspect_actor", parameters: { actor_ref: ref } }),
);
assert.match(inspection, /"temporary_behavior":false/);
await replaceText(behavior, original);
await diagnosticsChanged(behavior.uri, (diagnostics) => diagnostics.length === 0);
assert.ok(await behavior.save());
// Rapid successive edits cannot leave diagnostics for an older document version.
await replaceText(behavior, invalid);
await replaceText(behavior, original);
await new Promise((resolve) => setTimeout(resolve, 600));
assert.equal(vscode.languages.getDiagnostics(behavior.uri).length, 0);
await replaceText(behavior, invalid);
await diagnosticsChanged(behavior.uri, (diagnostics) => diagnostics.length > 0);
await vscode.commands.executeCommand("runa.disconnect");
assert.equal(vscode.languages.getDiagnostics(behavior.uri).length, 0);
await vscode.commands.executeCommand("runa.connect", address);
assert.equal(
vscode.languages.getDiagnostics(behavior.uri).length,
0,
"old-session actor documents are not validated",
);
await replaceText(behavior, original);
await vscode.commands.executeCommand("runa.kill", selection);
console.log(
"Runa compile diagnostics: unsaved source, warnings, UTF-16 locations, temporary isolation and reconnect passed",
);
}
function diagnosticsChanged(
uri: vscode.Uri,
expected: (diagnostics: readonly vscode.Diagnostic[]) => boolean,
): Promise<void> {
if (expected(vscode.languages.getDiagnostics(uri))) return Promise.resolve();
return new Promise((resolve, reject) => {
const subscription = vscode.languages.onDidChangeDiagnostics((event) => {
if (
event.uris.some((changed) => changed.toString() === uri.toString()) &&
expected(vscode.languages.getDiagnostics(uri))
) {
clearTimeout(timeout);
subscription.dispose();
resolve();
}
});
const timeout = setTimeout(() => {
subscription.dispose();
reject(
new Error(
`Diagnostics did not update for ${uri.toString()}: ${JSON.stringify(vscode.languages.getDiagnostics(uri))}`,
),
);
}, 5000);
});
}
async function checkDiagnosticRaces(address: string): Promise<void> {
await vscode.commands.executeCommand("runa.newSource", undefined, "tests/races.rn");
const document = vscode.window.activeTextEditor?.document;
assert.ok(document);
const original = document.getText();
await vscode.commands.executeCommand("runa.disconnect");
type Check = { source: string; resolve: (diagnostics: SourceDiagnostic[]) => void };
const checks: Check[] = [];
let started: (() => void) | undefined;
const client = {
checkSource: (source: string) =>
new Promise<SourceDiagnostic[]>((resolve) => {
checks.push({ source, resolve });
started?.();
}),
};
let connected = true;
const errors: unknown[] = [];
const diagnostics = new SourceDiagnostics(
() => (connected ? { client, endpoint: address, session: 100 } : undefined),
(error) => errors.push(error),
);
const nextCheck = () =>
new Promise<void>((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error("Diagnostic validation did not start")),
5000,
);
started = () => {
clearTimeout(timer);
started = undefined;
resolve();
};
});
const error: SourceDiagnostic = { severity: "error", message: "old response", range: null };
const observed: vscode.Diagnostic[] = [];
const subscription = vscode.languages.onDidChangeDiagnostics(() =>
observed.push(...vscode.languages.getDiagnostics(document.uri)),
);
try {
let checking = nextCheck();
await replaceText(document, "pub fn broken( {");
await checking;
assert.equal(checks.length, 1);
checking = nextCheck();
await replaceText(document, original);
checks[0]!.resolve([error]);
await checking;
assert.equal(checks[1]?.source, original);
checks[1]!.resolve([]);
await new Promise((resolve) => setTimeout(resolve, 50));
assert.ok(
observed.every((diagnostic) => diagnostic.message !== "old response"),
"an in-flight result cannot annotate a newer document",
);
checking = nextCheck();
await replaceText(document, "pub fn broken( {");
await checking;
diagnostics.clear();
connected = false;
checks[2]!.resolve([error]);
await new Promise((resolve) => setTimeout(resolve, 50));
assert.equal(
vscode.languages.getDiagnostics(document.uri).length,
0,
"an in-flight result cannot repopulate disconnected diagnostics",
);
assert.deepEqual(errors, []);
} finally {
subscription.dispose();
diagnostics.dispose();
for (const check of checks) check.resolve([]);
await replaceText(document, original);
}
console.log(
"Runa compile diagnostics: stale in-flight version and disconnect responses discarded",
);
}
async function editActorFiles(address: string): Promise<void> {
const extension = vscode.extensions.all.find(
(candidate) => candidate.packageJSON.name === "runa",
);
assert.ok(extension);
const { actorTree, sourceTree } = extension.exports as ReturnType<typeof activate>;
const inputWindow = vscode.window as { showInputBox: (...args: unknown[]) => unknown };
const input = inputWindow.showInputBox;
const [root] = sourceTree.getChildren();
assert.ok(root);
inputWindow.showInputBox = async () => "tests/files.rn";
try {
await vscode.commands.executeCommand("runa.newSource", root, [root]);
} finally {
inputWindow.showInputBox = input;
}
const sourceDocument = vscode.window.activeTextEditor?.document;
assert.ok(sourceDocument);
const original =
'pub fn handle(ctx, state, sender, message) { ctx.info("original"); state + message }\n';
await replaceText(sourceDocument, original);
assert.ok(await sourceDocument.save());
const ref = await vscode.commands.executeCommand<string>("runa.spawn", {
path: "tests/files",
state: "1",
});
assert.ok(ref);
const selected = { actor_ref: ref };
await vscode.commands.executeCommand("runa.refresh");
const parent = actorTree.getChildren().find((node) => node.actor_ref === ref);
assert.ok(parent);
assert.equal(
actorTree.getTreeItem(parent).collapsibleState,
vscode.TreeItemCollapsibleState.Collapsed,
);
assert.deepEqual(
actorTree.getChildren(parent).map((node) => ("file" in node ? node.file : "")),
["meta.json", "config.json", "state.json", "mailbox.json", "actor.log"],
);
const session = actorTree.session;
const uri = (name: string) => actorFileUri(session, ref, name);
const meta = await vscode.workspace.openTextDocument(uri("meta.json"));
await vscode.window.showTextDocument(meta, { preview: false });
assert.equal(JSON.parse(meta.getText()).behavior_path, "tests/files");
assert.equal(JSON.parse(meta.getText()).state, undefined);
assert.equal(
(await vscode.workspace.fs.stat(meta.uri)).permissions,
vscode.FilePermission.Readonly,
);
assert.equal(
(await vscode.workspace.fs.stat(uri("state.json"))).permissions,
vscode.FilePermission.Readonly,
);
await assert.rejects(
async () => vscode.workspace.fs.writeFile(meta.uri, Buffer.from("{}")),
/read-only/,
);
const config = await vscode.workspace.openTextDocument(uri("config.json"));
await vscode.window.showTextDocument(config);
await replaceText(config, '{"log_level":"trace"}');
assert.ok(await config.save(), "configuration is writable while running");
await vscode.commands.executeCommand("runa.pause", selected);
await vscode.commands.executeCommand("runa.refresh");
assert.equal((await vscode.workspace.fs.stat(uri("state.json"))).permissions, undefined);
const state = await vscode.workspace.openTextDocument(uri("state.json"));
assert.equal(state.getText().trim(), "1");
await vscode.workspace.fs.writeFile(
state.uri,
Buffer.from('{"nested":[9223372036854775807,{"value":true}]}'),
);
const prettyState = Buffer.from(await vscode.workspace.fs.readFile(state.uri)).toString();
assert.equal(
prettyState,
'{\n "nested": [\n 9223372036854775807,\n {\n "value": true\n }\n ]\n}\n',
);
await vscode.window.showTextDocument(state, { preview: false });
await replaceText(state, "40");
assert.ok(await state.save());
const mailbox = await vscode.workspace.openTextDocument(uri("mailbox.json"));
await vscode.window.showTextDocument(mailbox);
await replaceText(mailbox, '{"reserved":[2],"queued":[3]}');
assert.ok(await mailbox.save());
assert.equal(
Buffer.from(await vscode.workspace.fs.readFile(mailbox.uri)).toString(),
'{\n "reserved": [\n 2\n ],\n "queued": [\n 3\n ]\n}\n',
);
await assert.rejects(
async () =>
vscode.workspace.fs.writeFile(mailbox.uri, Buffer.from('{"reserved":[1,2],"queued":[]}')),
/zero or one/,
);
const executed = documentChanged(state, (text) => text.trim() === "45");
await vscode.commands.executeCommand("runa.restart", selected);
await vscode.commands.executeCommand("runa.tick", 20);
await executed;
const log = await vscode.workspace.openTextDocument(uri("actor.log"));
await vscode.window.showTextDocument(log, { preview: false });
assert.equal(log.languageId, "log");
assert.match(log.getText(), new RegExp(`^<134>1 - - runa ${ref} \\d+ - \\[INFO\\] original\\n`));
assert.equal(
(await vscode.workspace.fs.stat(log.uri)).permissions,
vscode.FilePermission.Readonly,
);
const working = original.replace("state + message", "state + message * 10");
await vscode.workspace.fs.writeFile(sourceDocument.uri, Buffer.from(working));
await vscode.commands.executeCommand("runa.editBehavior", selected);
const behavior = vscode.window.activeTextEditor?.document;
assert.ok(behavior);
assert.equal(behavior.uri.toString(), uri("behavior").toString());
assert.equal(
behavior.getText(),
original,
"edit starts from loaded behavior, not current working source",
);
const temporary = original
.replace('"original"', '"temporary"')
.replace("state + message", "state + message * 100");
await replaceText(behavior, temporary);
assert.ok(await behavior.save());
assert.equal(
Buffer.from(await vscode.workspace.fs.readFile(sourceDocument.uri)).toString(),
working,
);
await vscode.commands.executeCommand("runa.editBehavior", selected);
assert.equal(vscode.window.activeTextEditor?.document.uri.toString(), behavior.uri.toString());
await vscode.commands.executeCommand("runa.refresh");
const withTemporary = actorTree.getChildren().find((node) => node.actor_ref === ref);
assert.ok(withTemporary);
assert.ok(
actorTree.getChildren(withTemporary).some((node) => "file" in node && node.file === "behavior"),
);
const tempExecuted = documentChanged(state, (text) => text.trim() === "145");
const logged = documentChanged(log, (text) => text.includes("[INFO] temporary"));
await vscode.commands.executeCommand("runa.send", selected, "1");
await vscode.commands.executeCommand("runa.tick", 20);
await tempExecuted;
await logged;
const window = vscode.window as { showWarningMessage: (...args: unknown[]) => unknown };
const warning = window.showWarningMessage;
let answer: string | undefined;
window.showWarningMessage = async () => answer;
const renamed = vscode.Uri.from({
scheme: "runa-image",
authority: address,
path: "/source/moved/files.rn",
});
try {
await vscode.commands.executeCommand("runa.updateBehavior", selected);
assert.equal(
Buffer.from(await vscode.workspace.fs.readFile(sourceDocument.uri)).toString(),
working,
);
answer = "Update Behavior";
await vscode.commands.executeCommand("runa.updateBehavior", selected);
assert.equal(
Buffer.from(await vscode.workspace.fs.readFile(sourceDocument.uri)).toString(),
temporary,
);
answer = undefined;
await assert.rejects(
async () => vscode.workspace.fs.rename(sourceDocument.uri, renamed),
/[Cc]ancel/,
);
assert.equal(
Buffer.from(await vscode.workspace.fs.readFile(sourceDocument.uri)).toString(),
temporary,
);
answer = "Rename and Update Actors";
const renamedMeta = documentChanged(
meta,
(text) => JSON.parse(text).behavior_path === "moved/files",
);
inputWindow.showInputBox = async () => "moved/files.rn";
try {
await vscode.commands.executeCommand("runa.renameSource", { uri: sourceDocument.uri }, [
{ uri: sourceDocument.uri },
]);
} finally {
inputWindow.showInputBox = input;
}
await renamedMeta;
await vscode.commands.executeCommand("runa.goToBehavior", selected);
assert.equal(vscode.window.activeTextEditor?.document.uri.toString(), renamed.toString());
await assert.rejects(async () => vscode.workspace.fs.delete(renamed), /stop the actors first/);
await vscode.commands.executeCommand("runa.reload", selected);
await vscode.commands.executeCommand("runa.tick", 20);
await vscode.commands.executeCommand("runa.refresh");
const reloaded = actorTree.getChildren().find((node) => node.actor_ref === ref);
assert.ok(reloaded);
assert.ok(
!actorTree.getChildren(reloaded).some((node) => "file" in node && node.file === "behavior"),
);
await vscode.commands.executeCommand("runa.kill", selected);
await vscode.commands.executeCommand("runa.refresh");
assert.match(
Buffer.from(await vscode.workspace.fs.readFile(log.uri)).toString(),
/\[INFO\] temporary/,
);
assert.equal(
JSON.parse(Buffer.from(await vscode.workspace.fs.readFile(meta.uri)).toString()).removed,
true,
);
await vscode.workspace.fs.delete(renamed);
assert.equal(
(await vscode.workspace.fs.stat(state.uri)).permissions,
vscode.FilePermission.Readonly,
);
} finally {
window.showWarningMessage = warning;
}
console.log(
"Runa actor directories, live config, natural state/mailbox edits, logs, temporary behaviors and source operations passed",
);
}
function documentChanged(
document: vscode.TextDocument,
expected: (text: string) => boolean,
): Promise<void> {
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(
`Document did not refresh: ${document.uri.toString()} (closed=${document.isClosed}): ${document.getText()}`,
),
);
}, 5000);
});
}