888 lines
36 KiB
TypeScript
888 lines
36 KiB
TypeScript
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";
|
|
|
|
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 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 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);
|
|
});
|
|
}
|