runa/vscode/integration/host.ts

341 lines
14 KiB
TypeScript
Raw Normal View History

import assert from "node:assert/strict";
import * as vscode from "vscode";
import { control, spawnCounter, startImage } from "./server";
import { RevisionDocuments } from "../src/sourceProvider";
export async function run(): Promise<void> {
const image = await startImage();
try {
await vscode.commands.executeCommand("runa.connect", image.address);
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.inspect", selection);
const controlled = vscode.window.activeTextEditor?.document;
assert.ok(controlled);
await vscode.commands.executeCommand("runa.send", selection, '{"op":"add","value":7}');
const controlPaused = documentChanged(controlled, (text) =>
text.includes('"status": "paused"'),
);
await vscode.commands.executeCommand("runa.pause", selection);
await vscode.commands.executeCommand("runa.tick", 100);
await controlPaused;
let snapshot = JSON.parse(controlled.getText()).actor;
assert.equal(snapshot.status.status, "paused");
assert.equal(snapshot.state.data, 5);
assert.equal(snapshot.queued.length, 1);
const controlExecuted = documentChanged(
controlled,
(text) => JSON.parse(text).actor.state.data === 12,
);
await vscode.commands.executeCommand("runa.resume", selection);
await vscode.commands.executeCommand("runa.tick", 100);
await controlExecuted;
snapshot = JSON.parse(controlled.getText()).actor;
assert.equal(snapshot.state.data, 12);
assert.equal(snapshot.queued.length, 0);
await vscode.commands.executeCommand("runa.stop", selection);
await vscode.commands.executeCommand("runa.inspect", selection);
assert.notEqual(JSON.parse(controlled.getText()).actor.status.status, "stopped");
const controlStopped = documentChanged(controlled, (text) =>
text.includes('"status": "stopped"'),
);
// Graceful stop completes only when the scheduler advances.
await vscode.commands.executeCommand("runa.tick", 10);
await controlStopped;
assert.equal(JSON.parse(controlled.getText()).actor.status.status, "stopped");
const controlRestarted = documentChanged(
controlled,
(text) => JSON.parse(text).actor.incarnation === 2,
);
await vscode.commands.executeCommand("runa.restart", selection);
await controlRestarted;
snapshot = JSON.parse(controlled.getText()).actor;
assert.equal(snapshot.incarnation, 2);
assert.equal(snapshot.state.data, 12);
const controlKilled = documentChanged(controlled, (text) =>
text.includes('"status": "stopped"'),
);
await vscode.commands.executeCommand("runa.kill", selection);
await controlKilled;
const controlReloaded = documentChanged(
controlled,
(text) => JSON.parse(text).actor.incarnation === 3,
);
await vscode.commands.executeCommand("runa.restart", selection);
await controlReloaded;
await vscode.commands.executeCommand("runa.reload", selection);
await vscode.commands.executeCommand("runa.tick", 10);
const actorRef = await spawnCounter(image.address);
await vscode.commands.executeCommand("runa.connect", image.address);
await vscode.commands.executeCommand("runa.refresh");
await vscode.commands.executeCommand("runa.inspect", { actor_ref: actorRef });
let document = vscode.window.activeTextEditor?.document;
assert.ok(document);
assert.equal(document.uri.scheme, "runa");
assert.equal(document.languageId, "json");
assert.match(document.getText(), /9223372036854775807/);
assert.match(document.getText(), /"connected": true/);
const firstUri = document.uri.toString();
const refreshed = documentChanged(document, (text) =>
text.includes("queued from another client"),
);
assert.deepEqual(
await control(image.address, {
operation: "send",
parameters: {
actor_ref: actorRef,
message: { type: "string", data: "queued from another client" },
},
}),
{ version: 4, request_id: "integration-control", result: { Ok: { kind: "acknowledged" } } },
);
await refreshed;
assert.match(document.getText(), /queued from another client/);
const paused = documentChanged(document, (text) => text.includes('"status": "paused"'));
await control(image.address, {
operation: "system",
parameters: { actor_ref: actorRef, message: { kind: "pause" } },
});
await paused;
const failed = documentChanged(document, (text) => text.includes('"status": "failed"'));
await control(image.address, {
operation: "system",
parameters: { actor_ref: actorRef, message: { kind: "resume" } },
});
await control(image.address, {
operation: "tick",
parameters: { ticks: 100, instruction_budget: 10 },
});
await failed;
const disconnected = documentChanged(document, (text) => text.includes('"connected": false'));
await vscode.commands.executeCommand("runa.disconnect");
await disconnected;
assert.match(document.getText(), /"connected": false/);
await vscode.commands.executeCommand("runa.connect", image.address);
await vscode.commands.executeCommand("runa.inspect", { actor_ref: actorRef });
document = vscode.window.activeTextEditor?.document;
assert.ok(document);
assert.notEqual(document.uri.toString(), firstUri);
assert.match(document.getText(), /queued from another client/);
const old = await vscode.workspace.openTextDocument(vscode.Uri.parse(firstUri));
assert.match(old.getText(), /"connected": false/);
console.log(
"Runa extension host: spawn, send, system messages, ticks, inspection, automatic refresh, reconnect passed",
);
} finally {
await vscode.commands.executeCommand("runa.disconnect");
await image.stop();
}
}
async function editSources(address: string): Promise<void> {
const folder = vscode.workspace.workspaceFolders?.find(
(candidate) => candidate.uri.scheme === "runa-image",
);
assert.ok(folder, "connect mounts the image source folder");
assert.equal(folder.name, "source");
assert.equal(folder.uri.toString(), `runa-image://${address}/source`);
assert.deepEqual(await vscode.workspace.fs.readDirectory(folder.uri), [
["actors", vscode.FileType.Directory],
]);
const actors = vscode.Uri.joinPath(folder.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 actor = await vscode.commands.executeCommand<string>(
"runa.spawnSourceWithState",
uri,
"40",
);
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,
1,
);
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: mounted folder, 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);
});
}