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 { 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("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("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 { const extension = vscode.extensions.all.find( (candidate) => candidate.packageJSON.name === "runa", ); assert.ok(extension); const { sourceTree } = extension.exports as { sourceTree: ImageSourceTree }; const [root] = sourceTree.getChildren(); assert.ok(root); assert.equal(root.uri.toString(), `runa-image://${address}/source`); assert.deepEqual(await vscode.workspace.fs.readDirectory(root.uri), [ ["actors", vscode.FileType.Directory], ]); const [directory] = sourceTree.getChildren(root); assert.ok(directory); assert.equal(directory.directory, true); const [file] = sourceTree.getChildren(directory); assert.ok(file); assert.equal(sourceTree.getTreeItem(file).contextValue, "runa.sourceFile"); const actors = vscode.Uri.joinPath(root.uri, "actors"); assert.deepEqual(await vscode.workspace.fs.readDirectory(actors), [ ["counter.rn", vscode.FileType.File], ]); const uri = vscode.Uri.joinPath(actors, "counter.rn"); const document = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(document); const original = document.getText(); assert.match(original, /pub fn handle\(ctx, state, sender, message\)/); const edited = "pub fn handle(ctx, state, sender, message) {\n state + 2\n}\n"; await replaceText(document, edited); const window = vscode.window as { showWarningMessage: (...args: unknown[]) => unknown }; const warning = window.showWarningMessage; const prompts: unknown[][] = []; let answer: string | undefined; window.showWarningMessage = async (...args: unknown[]) => { prompts.push(args); return answer; }; try { // Unsaved edits are never committed or spawned without an explicit save. assert.equal(await vscode.commands.executeCommand("runa.commitSource", uri), undefined); assert.equal(await vscode.commands.executeCommand("runa.spawnSource", uri), undefined); assert.equal(prompts.length, 2); assert.deepEqual(prompts[0]?.[1], { modal: true }); assert.equal(document.isDirty, true); const unsaved = JSON.stringify( await control(address, { operation: "read_source", parameters: { path: "actors/counter" } }), ); assert.ok(unsaved.includes(JSON.stringify(original))); assert.match( JSON.stringify(await control(address, { operation: "list_actors" })), /"data":\[\]/, ); answer = "Save and Spawn"; const saved = await vscode.commands.executeCommand("runa.spawnSource", uri); assert.match(String(saved), /^[A-Za-z0-9_-]{21}$/); assert.equal(prompts.length, 3); assert.equal(document.isDirty, false, "the explicit choice saves before spawning"); } finally { window.showWarningMessage = warning; } assert.equal(document.isDirty, false); assert.equal(document.isDirty, false); let reply = JSON.stringify( await control(address, { operation: "read_source", parameters: { path: "actors/counter" } }), ); assert.ok(reply.includes(JSON.stringify(edited)), reply); reply = JSON.stringify( await control(address, { operation: "source_history", parameters: { path: "actors/counter" } }), ); assert.match(reply, /"kind":"source_history","data":\[\]/); // Spawn context-menu actions receive the explorer URI and use the saved, uncommitted source. const nullActor = await vscode.commands.executeCommand("runa.spawnSource", uri); assert.match(String(nullActor), /^[A-Za-z0-9_-]{21}$/); reply = JSON.stringify( await control(address, { operation: "inspect_actor", parameters: { actor_ref: nullActor } }), ); assert.match(reply, /"state":\{"type":"null"\}/); const inputWindow = vscode.window as { showInputBox: (...args: unknown[]) => unknown }; const originalInput = inputWindow.showInputBox; const inputs: unknown[][] = []; let stateAnswer: string | undefined; inputWindow.showInputBox = async (...args: unknown[]) => { inputs.push(args); return stateAnswer; }; let actor: string | undefined; try { assert.equal( await vscode.commands.executeCommand("runa.spawnSourceWithState", file, [file]), undefined, ); stateAnswer = "40"; actor = await vscode.commands.executeCommand("runa.spawnSourceWithState", file, [file]); assert.equal(inputs.length, 2, "context menus always prompt for state"); assert.match(JSON.stringify(inputs[1]), /Initial state for actors\/counter/); } finally { inputWindow.showInputBox = originalInput; } assert.match(String(actor), /^[A-Za-z0-9_-]{21}$/); await vscode.commands.executeCommand("runa.send", { actor_ref: actor }, "null"); await vscode.commands.executeCommand("runa.tick", 10); reply = JSON.stringify( await control(address, { operation: "inspect_actor", parameters: { actor_ref: actor } }), ); assert.match(reply, /"state":\{"type":"integer","data":42\}/); const revision = await vscode.commands.executeCommand("runa.commitSource", uri); assert.match(String(revision), /^[A-Za-z0-9]{1,64}$/); const opened = await vscode.commands.executeCommand("runa.sourceHistory", uri, revision); const historical = vscode.window.activeTextEditor?.document; assert.ok(historical); assert.equal(historical.uri.toString(), opened); assert.equal(historical.uri.scheme, "runa-source"); assert.ok(historical.uri.path.endsWith(`/source/actors/counter@${revision}.rn`)); assert.equal(historical.getText(), edited); await vscode.commands.executeCommand("type", { text: "// cannot edit\n" }); assert.equal(historical.getText(), edited); // Saving again changes only the working source; history is unchanged. await vscode.window.showTextDocument(document); await replaceText(document, original); assert.ok(await document.save()); assert.equal(historical.getText(), edited); prompts.length = 0; answer = undefined; window.showWarningMessage = async (...args: unknown[]) => { prompts.push(args); return answer; }; try { await vscode.commands.executeCommand("runa.rollbackSource", uri, revision); assert.equal(prompts.length, 1); assert.deepEqual(prompts[0]?.[1], { modal: true }); assert.equal(document.getText(), original, "cancelled rollback changes nothing"); answer = "Roll Back"; const reloaded = documentChanged(document, (text) => text === edited); await vscode.commands.executeCommand("runa.rollbackSource", uri, revision); await reloaded; } finally { window.showWarningMessage = warning; } assert.equal(document.isDirty, false); reply = JSON.stringify( await control(address, { operation: "source_history", parameters: { path: "actors/counter" } }), ); assert.equal((reply.match(/"revision"/g) ?? []).length, 1); // Saves from another client reload clean editors through image notifications. const current = JSON.parse( JSON.stringify( await control(address, { operation: "read_source", parameters: { path: "actors/counter" } }), ), ).result.Ok.data; const external = documentChanged(document, (text) => text === original); await control(address, { operation: "write_source", parameters: { path: "actors/counter", expected_source_id: current.source_id, source: original }, }); await external; // Revision documents stay isolated per connection session. await vscode.commands.executeCommand("runa.disconnect"); assert.equal(historical.getText(), edited); await vscode.commands.executeCommand("runa.connect", address); assert.equal( vscode.workspace.workspaceFolders?.filter((candidate) => candidate.uri.scheme === "runa-image") .length ?? 0, 0, ); const reopened = await vscode.commands.executeCommand( "runa.sourceHistory", uri, revision, ); assert.ok(reopened); assert.notEqual(reopened, opened); assert.equal(vscode.window.activeTextEditor?.document.getText(), edited); const revisions = new RevisionDocuments(); try { const cached = revisions.set(1, "actors/counter", "abc", edited); assert.equal(revisions.provideTextDocumentContent(cached), edited); assert.notEqual( revisions.set(2, "actors/counter", "abc", original).toString(), cached.toString(), ); assert.throws(() => revisions.set(1, "actors/counter", "abc", original), /Immutable/); revisions.remove(cached); assert.throws(() => revisions.provideTextDocumentContent(cached), /no longer cached/); } finally { revisions.dispose(); } console.log( "Runa sources: automatic tree, state input, save, uncommitted spawn, commit, history, rollback, session isolation passed", ); } async function replaceText(document: vscode.TextDocument, text: string): Promise { const edit = new vscode.WorkspaceEdit(); edit.replace( document.uri, new vscode.Range(document.positionAt(0), document.positionAt(document.getText().length)), text, ); assert.ok(await vscode.workspace.applyEdit(edit)); } async function checkSourceDiagnostics(address: string): Promise { 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("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 { 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 { 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((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((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 { const extension = vscode.extensions.all.find( (candidate) => candidate.packageJSON.name === "runa", ); assert.ok(extension); const { actorTree, sourceTree } = extension.exports as ReturnType; 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("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 { 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); }); }