455 lines
19 KiB
TypeScript
455 lines
19 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";
|
|
|
|
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);
|
|
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");
|
|
assert.equal(await document.save(), true);
|
|
await canonical;
|
|
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/);
|
|
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));
|
|
}
|
|
|
|
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("Actor inspector did not refresh"));
|
|
}, 5000);
|
|
});
|
|
}
|