diff --git a/AGENTS.md b/AGENTS.md index 5e5ef16..5c108a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,7 +90,7 @@ mise run format mise run lint mise run test mise run start -mise run cli +mise run repl mise run test-extension-host mise run pre-commit ``` @@ -104,7 +104,9 @@ are required; install the locked extension dependencies with The workspace tests cover execution probes, actor runtime invariants, and the interactive CLI. Extension tests include a real Rust-server WebSocket round trip. -`mise run start` starts the image server; `mise run cli` opens the REPL. +`mise run start` starts the image server; `mise run repl` opens the REPL. +The CLI requires a subcommand: `runa-cli repl` starts the REPL and image +operations such as `runa-cli ps` run once and exit. Both use Clap derive/env arguments (`RUNA_LISTEN` and `RUNA_CONNECT` respectively). The separate `mise run test-extension-host` task verifies inspector commands in VS Code and requires a display (or diff --git a/CHANGELOG.md b/CHANGELOG.md index 539f02e..96474d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Changed + +- Require an explicit `runa-cli` subcommand: `repl` starts the interactive + session, and each image operation is also available as a one-shot command. +- Rename the REPL actor listing command from `actors` to `ps`, remove its + `cancel` command, and rename `mise run cli` to `mise run repl`. + ### Fixed - Complete the WebSocket close handshake when the CLI exits, preventing normal diff --git a/README.md b/README.md index dc60f6c..8a16ef6 100644 --- a/README.md +++ b/README.md @@ -57,14 +57,14 @@ mise run start In another terminal, connect the REPL: ```sh -mise run cli +mise run repl ``` The REPL defaults to `127.0.0.1:3737`. To select another loopback endpoint, -use `cargo run -p runa-cli -- --connect 127.0.0.1:4000` and start the server with +use `cargo run -p runa-cli -- --connect 127.0.0.1:4000 repl` and start the server with the corresponding `--listen` address. Quitting the REPL disconnects only that client; actors and source files survive until the server exits. -Reconnect and use `actors` to recover their references. REPL bindings and +Reconnect and use `ps` to recover their references. REPL bindings and command history are session-local and are not restored on reconnect. Both executables use Clap for `--help`, `--version`, and argument validation. @@ -75,9 +75,27 @@ For example: ```sh RUNA_LISTEN=127.0.0.1:4000 mise run start -RUNA_CONNECT=127.0.0.1:4000 mise run cli +RUNA_CONNECT=127.0.0.1:4000 mise run repl ``` +`runa-cli` requires a subcommand. Use `runa-cli repl` for an interactive session, +or run an image operation directly: + +```sh +cargo run -p runa-cli -- ps +actor=$(cargo run -p runa-cli -- spawn actors/counter 1) +cargo run -p runa-cli -- send "$actor" '{"op":"add","value":2}' +cargo run -p runa-cli -- tick 100 +``` + +Each image operation in the REPL has a matching subcommand; use +`runa-cli --help` for arguments. Quote JSON as a single shell argument. +One-shot commands use actor references rather than session-local bindings, +print results without a REPL banner, and exit unsuccessfully on errors. +`let name = spawn ...` is session-local REPL syntax. `quit` (alias `exit`) +exits either interface without stopping the image. `cancel` is no longer a +REPL command; the underlying runtime cancellation API remains available. + The server is WebSocket-only; stdio transport has been removed. By default it listens on `ws://127.0.0.1:3737/` and requires the `runa-debug` subprotocol, with up to 16 simultaneous clients sharing one live image. The listener rejects non-loopback diff --git a/crates/runa-cli/src/main.rs b/crates/runa-cli/src/main.rs index 9d4ebc6..2824c81 100644 --- a/crates/runa-cli/src/main.rs +++ b/crates/runa-cli/src/main.rs @@ -74,9 +74,9 @@ impl ReplHelper { fn candidates(&self, line: &str, position: usize) -> Vec { const COMMANDS: &[&str] = &[ - "actors", "sources", "cancel", "events", "exit", "help", "history", "inspect", - "become", "kill", "reload", "write", "let", "pause", "commit", "quit", "restart", - "resume", "rollback", "send", "spawn", "stop", "tick", + "ps", "sources", "events", "exit", "help", "history", "inspect", "become", "kill", + "reload", "write", "let", "pause", "commit", "quit", "restart", "resume", "rollback", + "send", "spawn", "stop", "tick", ]; let (start, reference_value) = Self::completion_start(line, position); if reference_value { @@ -101,7 +101,6 @@ impl ReplHelper { | ["restart"] | ["pause"] | ["resume"] - | ["cancel"] | ["kill"] | ["reload"] | ["become"] => self.actor_candidates(), @@ -183,18 +182,189 @@ struct Args { /// Loopback address of the runa-debug WebSocket server. #[arg(long, env = "RUNA_CONNECT", default_value = "127.0.0.1:3737")] connect: SocketAddr, + #[command(subcommand)] + command: CliCommand, +} + +#[derive(Debug, clap::Subcommand)] +enum CliCommand { + /// Start the interactive REPL. + Repl, + /// List actors in the image. + Ps, + /// List image source files. + Sources, + /// Spawn an actor from the current image source. + Spawn { + path: String, + #[arg(allow_hyphen_values = true)] + state: String, + }, + /// Send a JSON message to an actor. + Send { + actor: String, + #[arg(allow_hyphen_values = true)] + message: String, + }, + /// Advance the scheduler. + Tick { + count: usize, + #[arg(default_value_t = NonZeroUsize::new(DEFAULT_TICK_BUDGET).expect("nonzero budget"))] + budget: NonZeroUsize, + }, + /// Inspect an actor. + Inspect { actor: String }, + /// Drain runtime events. + Events, + /// Save a local UTF-8 file into the image working sources. + Write { + path: String, + file: std::path::PathBuf, + }, + /// Commit the current image source. + Commit { path: String }, + /// List source commits. + History { path: String }, + /// Restore a source commit into the working file. + Rollback { path: String, revision: String }, + /// Stop gracefully at a handler boundary. + Stop { actor: String }, + /// Kill the current invocation without shutdown. + Kill { actor: String }, + /// Restart with the same state and loaded script. + Restart { actor: String }, + /// Pause an actor. + Pause { actor: String }, + /// Resume an actor. + Resume { actor: String }, + /// Load the current working source at the actor's path. + Reload { actor: String }, + /// Switch the actor to another source path. + Become { actor: String, path: String }, + /// Exit without connecting (also available as exit). + #[command(visible_alias = "exit")] + Quit, +} + +impl CliCommand { + fn request(self, server: &mut dyn Connection) -> Result { + let state = ReplState::default(); + let actor_ref = |actor: &str| parse_actor_argument(&[actor], &state); + let system = |actor: &str, message| { + Ok(Command::System { + actor_ref: actor_ref(actor)?, + message, + }) + }; + Ok(match self { + Self::Ps => Command::ListActors, + Self::Sources => Command::ListSources, + Self::Spawn { path, state: json } => Command::Spawn { + behavior_name: path, + state: parse_value(&json, &state)?, + mailbox_capacity: MAILBOX_CAPACITY, + }, + Self::Send { actor, message } => Command::Send { + actor_ref: actor_ref(&actor)?, + message: parse_value(&message, &state)?, + }, + Self::Tick { count, budget } => Command::Tick { + ticks: count, + instruction_budget: budget.get(), + }, + Self::Inspect { actor } => Command::InspectActor { + actor_ref: actor_ref(&actor)?, + }, + Self::Events => Command::GetEvents, + Self::Write { path, file } => return source_write_request(&path, &file, server), + Self::Commit { path } => Command::CommitSource { path }, + Self::History { path } => Command::SourceHistory { path }, + Self::Rollback { path, revision } => Command::RollbackSource { path, revision }, + Self::Stop { actor } => return system(&actor, SystemMessageDto::Stop), + Self::Kill { actor } => return system(&actor, SystemMessageDto::Kill), + Self::Restart { actor } => return system(&actor, SystemMessageDto::Restart), + Self::Pause { actor } => return system(&actor, SystemMessageDto::Pause), + Self::Resume { actor } => return system(&actor, SystemMessageDto::Resume), + Self::Reload { actor } => return system(&actor, SystemMessageDto::Reload), + Self::Become { actor, path } => { + return system(&actor, SystemMessageDto::Become { path }); + } + Self::Repl | Self::Quit => return Err("not an image operation".to_owned()), + }) + } +} + +fn run_command( + command: CliCommand, + server: &mut dyn Connection, + output: &mut impl Write, +) -> Result<(), Box> { + let request = command.request(server)?; + let scheduled = matches!( + &request, + Command::System { + message: SystemMessageDto::Stop + | SystemMessageDto::Reload + | SystemMessageDto::Become { .. }, + .. + } + ); + let reply = dispatch(server, request)?; + match reply { + Reply::ActorSpawned { actor_ref } => writeln!(output, "@{actor_ref}")?, + Reply::Acknowledged if scheduled => { + writeln!(output, "requested; advance scheduler ticks to complete")?; + } + Reply::Acknowledged => writeln!(output, "ok")?, + Reply::Actors(actors) => { + for actor in actors { + writeln!(output, "{actor:#?}")?; + } + } + Reply::Sources(files) => { + for file in files { + writeln!(output, "source/{}.rn ({})", file.path, file.source_id)?; + } + } + Reply::Actor(actor) => writeln!(output, "{actor:#?}")?, + Reply::Events(events) => { + for event in events { + writeln!(output, "{event:#?}")?; + } + } + Reply::TickCompleted { ticks } => writeln!(output, "ran {ticks} scheduler tick(s)")?, + Reply::Source(file) => { + writeln!(output, "saved source/{}.rn ({})", file.path, file.source_id)?; + } + Reply::SourceCommitted(commit) => writeln!(output, "revision {}", commit.revision)?, + Reply::SourceHistory(history) => { + for commit in history { + writeln!(output, "revision {}: {}", commit.revision, commit.source_id)?; + } + } + other => return Err(format!("unexpected protocol response: {other:?}").into()), + } + Ok(()) } fn main() -> Result<(), Box> { - let address = ::parse().connect; + let args = ::parse(); + if matches!(args.command, CliCommand::Quit) { + return Ok(()); + } + let address = args.connect; let mut server = WebSocketClient::connect(address).map_err(|error| { format!("cannot connect to {address}: {error}; start `mise run start` first") })?; - println!("Runa — Rune Actors"); - println!("Connected to {address}. The image remains running when you quit."); - println!("Type `help` for commands. Use Up/Down for history and Tab for completion."); - let result = run_interactive_repl(&mut server); + let result = if matches!(args.command, CliCommand::Repl) { + println!("Runa — Rune Actors"); + println!("Connected to {address}. The image remains running when you quit."); + println!("Type `help` for commands. Use Up/Down for history and Tab for completion."); + run_interactive_repl(&mut server) + } else { + run_command(args.command, &mut server, &mut io::stdout().lock()) + }; let close_result = server.close(); result?; close_result?; @@ -312,7 +482,7 @@ fn execute_line( "history" => history_command(output, args, server), "rollback" => rollback_command(output, args, server), "become" => become_command(output, args, server, state), - "actors" if args.is_empty() => match dispatch(server, Command::ListActors) { + "ps" if args.is_empty() => match dispatch(server, Command::ListActors) { Ok(Reply::Actors(actors)) => { for actor in actors { writeln!(output, "{actor:#?}")?; @@ -322,7 +492,7 @@ fn execute_line( Ok(other) => unexpected_reply(output, other), Err(error) => writeln!(output, "error: {error}"), }, - "actors" => writeln!(output, "error: usage: actors"), + "ps" => writeln!(output, "error: usage: ps"), "send" => match parse_send_args(line, state) { Ok((actor_ref, message)) => { match dispatch(server, Command::Send { actor_ref, message }) { @@ -394,9 +564,6 @@ fn execute_line( actor_ref, message: SystemMessageDto::Resume, }), - "cancel" => lifecycle_command(output, args, server, state, |actor_ref| { - Command::CancelActor { actor_ref } - }), "quit" | "exit" if args.is_empty() => return Ok(false), "quit" | "exit" => writeln!(output, "error: usage: quit"), _ => writeln!(output, "error: unknown command `{command}`; type `help`"), @@ -509,28 +676,11 @@ fn write_command( let [path, source_path] = args else { return writeln!(output, "error: usage: write "); }; - let expected_source_id = match dispatch(server, Command::ListSources) { - Ok(Reply::Sources(files)) => files - .into_iter() - .find(|file| file.path == *path) - .map(|file| file.source_id), - Ok(other) => return unexpected_reply(output, other), + let request = match source_write_request(path, std::path::Path::new(source_path), server) { + Ok(request) => request, Err(error) => return writeln!(output, "error: {error}"), }; - let source = match std::fs::read_to_string(source_path) { - Ok(source) => source, - Err(error) => { - return writeln!(output, "error: could not read `{source_path}`: {error}"); - } - }; - match dispatch( - server, - Command::WriteSource { - path: (*path).to_owned(), - expected_source_id, - source, - }, - ) { + match dispatch(server, request) { Ok(Reply::Source(file)) => { writeln!(output, "saved source/{}.rn ({})", file.path, file.source_id) } @@ -539,6 +689,27 @@ fn write_command( } } +fn source_write_request( + path: &str, + source_path: &std::path::Path, + server: &mut dyn Connection, +) -> Result { + let expected_source_id = match dispatch(server, Command::ListSources)? { + Reply::Sources(files) => files + .into_iter() + .find(|file| file.path == path) + .map(|file| file.source_id), + other => return Err(format!("unexpected protocol response: {other:?}")), + }; + let source = std::fs::read_to_string(source_path) + .map_err(|error| format!("could not read `{}`: {error}", source_path.display()))?; + Ok(Command::WriteSource { + path: path.to_owned(), + expected_source_id, + source, + }) +} + fn commit_command( output: &mut W, args: &[&str], @@ -771,7 +942,7 @@ fn print_help(output: &mut W) -> io::Result<()> { State/messages: JSON values; {{\"$actor\":\"name-or-@ref\"}} embeds an actor reference.\n\ tick [instruction-budget]\n\ inspect \n\ - actors\n\ + ps\n\ events\n\ stop \n\ kill \n\ @@ -779,7 +950,6 @@ fn print_help(output: &mut W) -> io::Result<()> { restart \n\ pause \n\ resume \n\ - cancel \n\ help\n\ quit" ) @@ -789,6 +959,91 @@ fn print_help(output: &mut W) -> io::Result<()> { mod tests { use super::*; + #[test] + fn removed_commands_are_not_completed_or_dispatched() -> Result<(), Box> + { + let helper = ReplHelper::default(); + let commands = helper.candidates("", 0); + assert!(commands.contains(&"ps".to_owned())); + assert!(!commands.contains(&"actors".to_owned())); + assert!(!commands.contains(&"cancel".to_owned())); + let mut output = Vec::new(); + run_repl( + io::Cursor::new("actors\ncancel nobody\nps\nquit\n"), + &mut output, + &mut server(), + )?; + let output = String::from_utf8(output)?; + assert!(output.contains("unknown command `actors`")); + assert!(output.contains("unknown command `cancel`")); + assert!(!output.contains("unknown command `ps`")); + Ok(()) + } + + #[test] + fn subcommands_parse_typed_arguments_and_preserve_json() { + let args = ::try_parse_from([ + "runa-cli", + "--connect", + "[::1]:4000", + "spawn", + "actors/counter", + "-42", + ]) + .unwrap(); + assert_eq!(args.connect, "[::1]:4000".parse::().unwrap()); + assert!(matches!( + args.command.request(&mut server()).unwrap(), + Command::Spawn { + state: Value::Integer(-42), + .. + } + )); + let args = ::try_parse_from([ + "runa-cli", + "spawn", + "actors/counter", + r#"{"text":"two spaces"}"#, + ]) + .unwrap(); + assert!(matches!( + args.command.request(&mut server()).unwrap(), + Command::Spawn { state: Value::Record(values), .. } + if values["text"] == Value::String("two spaces".to_owned()) + )); + for args in [ + vec!["runa-cli"], + vec!["runa-cli", "actors"], + vec!["runa-cli", "cancel", "nobody"], + vec!["runa-cli", "spawn", "actors/counter"], + vec!["runa-cli", "tick", "1", "0"], + ] { + assert!(::try_parse_from(args).is_err()); + } + } + + #[test] + fn one_shot_errors_are_returned_instead_of_printed_as_success() { + let mut image = server(); + for command in [ + CliCommand::Spawn { + path: "actors/missing".to_owned(), + state: "0".to_owned(), + }, + CliCommand::Spawn { + path: "actors/counter".to_owned(), + state: "invalid".to_owned(), + }, + CliCommand::Inspect { + actor: "not-an-actor".to_owned(), + }, + ] { + let mut output = Vec::new(); + assert!(run_command(command, &mut image, &mut output).is_err()); + assert!(output.is_empty()); + } + } + #[test] fn value_parser_preserves_json_shapes_and_distinguishes_references() { let reference = ActorRef::new(); @@ -893,7 +1148,7 @@ mod tests { .unwrap(); assert_eq!(connect.get_default_values(), ["127.0.0.1:3737"]); assert_eq!( - ::try_parse_from(["runa-cli", "--connect", "[::1]:4000"]) + ::try_parse_from(["runa-cli", "--connect", "[::1]:4000", "repl"]) .unwrap() .connect, "[::1]:4000".parse::().unwrap() @@ -972,7 +1227,7 @@ mod tests { let mut client = WebSocketClient::connect(address)?; let mut output = Vec::new(); run_repl( - io::Cursor::new(format!("actors\ninspect @{actor_ref}\nquit\n")), + io::Cursor::new(format!("ps\ninspect @{actor_ref}\nquit\n")), &mut output, &mut client, )?; @@ -1062,7 +1317,7 @@ mod tests { "/../../runa-std/actors/counter.rn" ); let input = io::Cursor::new(format!( - "sources\nwrite actors/counter {source_path}\ncommit actors/counter\nhistory actors/counter\nlet actor = spawn actors/counter 0\nbecome actor actors/counter\nreload actor\nsend actor {{\"op\":\"add\",\"value\":1}}\ntick 1 1\ncancel actor\npause actor\nresume actor\nstop actor\ntick 100\nrestart actor\nkill actor\ninspect actor\nevents\nquit\n" + "sources\nwrite actors/counter {source_path}\ncommit actors/counter\nhistory actors/counter\nlet actor = spawn actors/counter 0\nbecome actor actors/counter\nreload actor\nsend actor {{\"op\":\"add\",\"value\":1}}\ntick 1 1\npause actor\nresume actor\nstop actor\ntick 100\nrestart actor\nkill actor\ninspect actor\nevents\nquit\n" )); let mut output = Vec::new(); run_repl(input, &mut output, &mut server)?; @@ -1073,7 +1328,7 @@ mod tests { assert!(output.contains("scheduled become `actors/counter` on @")); assert!(output.contains("Paused {")); assert!(output.contains("Resumed {")); - assert!(output.contains("InvocationCancelled {")); + assert!(output.contains("Stopped {")); let history = match dispatch( &mut server, Command::SourceHistory { diff --git a/crates/runa-cli/tests/arguments.rs b/crates/runa-cli/tests/arguments.rs index 006243c..e3676d6 100644 --- a/crates/runa-cli/tests/arguments.rs +++ b/crates/runa-cli/tests/arguments.rs @@ -13,6 +13,19 @@ fn cli_help_and_version_are_available_without_connecting() { assert!(help.contains("--connect")); assert!(help.contains("RUNA_CONNECT")); assert!(help.contains("127.0.0.1:3737")); + for subcommand in [ + "repl", "ps", "sources", "spawn", "send", "tick", "inspect", "events", "write", "commit", + "history", "rollback", "stop", "kill", "restart", "pause", "resume", "reload", "become", + "quit", + ] { + let output = Command::new(env!("CARGO_BIN_EXE_runa-cli")) + .args([subcommand, "--help"]) + .env_remove("RUNA_CONNECT") + .output() + .unwrap(); + assert!(output.status.success(), "{subcommand}"); + assert!(String::from_utf8(output.stdout).unwrap().contains("Usage:")); + } let output = Command::new(env!("CARGO_BIN_EXE_runa-cli")) .arg("--version") @@ -32,6 +45,11 @@ fn clap_rejects_bad_flags_addresses_and_environment_values() { vec!["--connect", "invalid"], vec!["--listen", "127.0.0.1:3737"], vec!["extra"], + vec!["actors"], + vec!["cancel", "actor"], + vec!["spawn", "actors/counter"], + vec!["tick", "1", "0"], + vec![], ] { let output = Command::new(env!("CARGO_BIN_EXE_runa-cli")) .args(args) diff --git a/docs/runtime.md b/docs/runtime.md index 96dee1a..96ae757 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -6,8 +6,13 @@ Start the server-owned, in-memory image: mise run start ``` -Then connect from another terminal with `mise run cli`, or use -`cargo run -p runa-cli -- --connect ` for a custom listener. +Then connect from another terminal with `mise run repl`, or use +`cargo run -p runa-cli -- --connect repl` for a custom listener. +The CLI requires a subcommand: `repl` starts the interactive session, while +image operations such as `ps`, `spawn`, and `send` run once and exit. +Use `runa-cli --help` or `runa-cli --help` for argument details. +One-shot JSON values must be quoted as a single shell argument; actor names +and `let` bindings exist only within a REPL session. The server (`runa`) defaults to `127.0.0.1:3737`; `--listen` or `RUNA_LISTEN` select another loopback address. The REPL (`runa-cli`) accepts `--connect` or `RUNA_CONNECT`, defaulting to the same address. Explicit flags override environment @@ -18,8 +23,8 @@ restart. Server exit still loses the entire image. The WebSocket server loads [counter.rn](../runa-std/actors/counter.rn) into the image's working `source/actors/counter.rn`. Commands include `spawn`, `send`, -`tick`, `inspect`, `actors`, `events`, `stop`, `kill`, `restart`, `pause`, `resume`, -`reload`, `become`, `cancel`, `sources`, `write`, `commit`, `history`, and +`tick`, `inspect`, `ps`, `events`, `stop`, `kill`, `restart`, `pause`, `resume`, +`reload`, `become`, `sources`, `write`, `commit`, `history`, and `rollback`; enter `help` to see usage. Paths exclude the root and extension: `spawn actors/counter 1`. Spawn compiles the current working source, not a committed revision. `write ` updates working source using @@ -149,7 +154,8 @@ call-frame depth. Paused invocations retain the same telemetry. These values are execution-local diagnostics only; they do not map to source locations or expose locals, and disappear when the invocation completes or is cancelled. -`cancel` is distinct from `stop`: it drops an actor's active invocation, moves +Runtime invocation cancellation (not exposed as a CLI/REPL command) is distinct +from `stop`: it drops an actor's active invocation, moves its reserved message to the front of the mailbox, and returns the actor to `Idle`. The input is therefore eligible for retry on the next tick, ahead of messages that were already queued. Committed state is unchanged and no diff --git a/mise.toml b/mise.toml index 32b23e8..d7c4aad 100644 --- a/mise.toml +++ b/mise.toml @@ -21,9 +21,9 @@ run = "npm --prefix vscode run test:host" description = "Start the Runa image server on localhost" run = "cargo run -p runa" -[tasks.cli] -description = "Connect the Runa CLI to the image server" -run = "cargo run -p runa-cli" +[tasks.repl] +description = "Start the Runa REPL connected to the image server" +run = "cargo run -p runa-cli -- repl" [tasks.lint] description = "Check Rust targets and the TypeScript extension" diff --git a/vscode/integration/repl.test.ts b/vscode/integration/repl.test.ts index 6caeed1..155abc3 100644 --- a/vscode/integration/repl.test.ts +++ b/vscode/integration/repl.test.ts @@ -1,11 +1,72 @@ import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; import { once } from "node:events"; import { resolve } from "node:path"; import test from "node:test"; +import { promisify } from "node:util"; import { ImageClient } from "../src/client"; import { startImage } from "./server"; +test( + "one-shot CLI commands share the image, report errors, and close cleanly", + { timeout: 15000 }, + async (t) => { + const image = await startImage(); + t.after(() => image.stop()); + const executable = resolve( + __dirname, + "../../../target/debug", + process.platform === "win32" ? "runa-cli.exe" : "runa-cli", + ); + const execute = promisify(execFile); + const run = async (...args: string[]) => { + const result = await execute(executable, ["--connect", image.address, ...args], { + env: { ...process.env, RUNA_CONNECT: "invalid" }, + timeout: 5000, + }); + assert.equal(result.stderr, ""); + assert.doesNotMatch(result.stdout, /runa>|Connected to/); + return result.stdout.trim(); + }; + assert.match(await run("sources"), /source\/actors\/counter\.rn/); + await run("write", "actors/copy", resolve(__dirname, "../../../runa-std/actors/counter.rn")); + const committed = await run("commit", "actors/copy"); + const revision = committed.match(/^revision ([a-z0-9]+)$/)?.[1]; + assert.ok(revision); + assert.match(await run("history", "actors/copy"), new RegExp(revision)); + assert.match(await run("rollback", "actors/copy", revision), /saved source\/actors\/copy\.rn/); + const actor = await run("spawn", "actors/copy", "-5"); + assert.match(actor, /^@[A-Za-z0-9_-]{21}$/); + assert.match(await run("ps"), new RegExp(actor.slice(1))); + assert.equal(await run("send", actor, '{"op":"add","value":7}'), "ok"); + await run("tick", "100", "32"); + assert.match(await run("inspect", actor), /Integer\(\s*2/); + for (const command of ["pause", "resume", "reload"]) { + await run(command, actor); + } + await run("become", actor, "actors/counter"); + await run("tick", "100"); + await run("stop", actor); + await run("tick", "100"); + assert.match(await run("inspect", actor), /Stopped/); + await run("restart", actor); + await run("kill", actor); + assert.match(await run("events"), /Spawned/); + for (const args of [ + ["spawn", "actors/missing", "0"], + ["spawn", "actors/counter", "invalid"], + ["inspect", "not-an-actor"], + ["actors"], + ["cancel", actor], + ]) { + await assert.rejects(run(...args)); + } + assert.equal(await run("quit"), ""); + assert.equal(await run("exit"), ""); + assert.doesNotMatch(image.diagnostics(), /protocol error|reset without closing handshake/i); + }, +); + test( "the real REPL mutates the same WebSocket image and notifies the inspection client", { timeout: 10000 }, @@ -34,7 +95,7 @@ test( "../../../target/debug", process.platform === "win32" ? "runa-cli.exe" : "runa-cli", ); - const repl = spawn(executable, [], { + const repl = spawn(executable, ["repl"], { env: { ...process.env, RUNA_CONNECT: image.address }, stdio: ["pipe", "pipe", "pipe"], }); @@ -72,8 +133,8 @@ test( assert.deepEqual(actor.state, { type: "integer", data: 12n }); assert.deepEqual(actor.queued, []); assert.equal(actor.reserved, null); - for (const input of ["actors\nquit\n", "exit\n", ""]) { - const overridden = spawn(executable, ["--connect", image.address], { + for (const input of ["ps\nquit\n", "exit\n", ""]) { + const overridden = spawn(executable, ["--connect", image.address, "repl"], { env: { ...process.env, RUNA_CONNECT: "invalid" }, stdio: ["pipe", "pipe", "pipe"], });