Implement native TestClient shell
Some checks failed
Native Rust workspace compile / compile (push) Failing after 12m49s

This commit is contained in:
2026-08-11 10:21:16 +00:00
parent d1e05d1b30
commit 9675440210
6 changed files with 2837 additions and 5 deletions

View File

@@ -9,7 +9,7 @@ publish = false
[dependencies] [dependencies]
clap = { version = "4.5", features = ["derive"] } clap = { version = "4.5", features = ["derive"] }
libremetaverse = { path = "../crates/libremetaverse", default-features = false } libremetaverse = { path = "../crates/libremetaverse", default-features = false }
tokio = { version = "1.47", features = ["io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] } tokio = { version = "1.47", features = ["io-std", "io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
[lints] [lints]
workspace = true workspace = true

View File

@@ -13,7 +13,7 @@ here so a source entry is never mistaken for a completed port.
| `prim-inspector` | PrimInspector | Implemented with live and deterministic fake-grid discovery | | `prim-inspector` | PrimInspector | Implemented with live and deterministic fake-grid discovery |
| `inventory-explorer` | InventoryExplorer | Implemented with live inventory and deterministic AIS fixtures | | `inventory-explorer` | InventoryExplorer | Implemented with live inventory and deterministic AIS fixtures |
| `irc-gateway` | IRCGateway | Implemented with live IRC/grid transports and deterministic offline scripts | | `irc-gateway` | IRCGateway | Implemented with live IRC/grid transports and deterministic offline scripts |
| `test-client` | TestClient | Pending milestone 11 issues #91#94 | | `test-client` | TestClient | Native shell, registry, system, and communication groups implemented; remaining command groups tracked by #92#94 |
| `vivox-test` | VivoxTest | Pending milestone 11 issue #95 | | `vivox-test` | VivoxTest | Pending milestone 11 issue #95 |
| `webrtc-test` | WebRtcTest | Pending milestone 11 issue #96 | | `webrtc-test` | WebRtcTest | Pending milestone 11 issue #96 |
@@ -151,6 +151,64 @@ cargo test -p libremetaverse-programs irc_gateway::tests --locked
cargo test --manifest-path tests/compat/Cargo.toml --test social_message_semantics --locked cargo test --manifest-path tests/compat/Cargo.toml --test social_message_semantics --locked
``` ```
## TestClient
`test-client` is a native asynchronous multi-avatar command shell. It accepts
one account with `--first`, `--last`, and `--pass`, or a bounded account file
with `--file`. Account-file records use `First Last Password` followed by an
optional `Region/x/y/z` start location. `--loginuri`, `--startpos`, `--master`,
`--masterkey`, `--groupcommands`, `--scriptfile`, and `--nogui` preserve the
upstream shell controls. Each login has a configurable timeout, the registry is
limited to 64 clients, and `@ First Last` selects one client while `@` restores
broadcast command routing.
The implemented command groups are `@`, `debug`, `echomaster`, `help`, `im`,
`imgroup`, `load`, `login`, `logpacket`, `logout`, `md5`, `quit`, `say`,
`setmaster`, `setmasterkey`, `shout`, `showeffects`, `sleep`, `waitforlogin`, and
`whisper`. Chat and instant-message bodies are bounded to the grid protocol
limit. Group commands require both `--groupcommands` and current group
membership. Master chat can be echoed, master teleport lures are accepted, and
remote `login` and `md5` command text is redacted from transcripts. Packet logs
contain only timestamped packet type, simulator name, and byte count; they are
limited to 10,000 records and 16 MiB.
The native `load` command reads a portable command-alias manifest instead of a
CLR assembly. Each non-comment line is tab-separated
`name<TAB>description<TAB>template`; `{args}` or `$*` inserts the quoted command
arguments. Manifests are limited to 1 MiB and 128 commands, with an alias
expansion depth of eight.
Use `--fake-script FILE` for deterministic, credential-free terminal and grid
validation. Normal lines are dispatched exactly like interactive input. Fake
grid records begin with `!` and use tab-separated fields:
```text
!client<TAB>client-uuid<TAB>first<TAB>last
!client<TAB>client-uuid<TAB>first<TAB>last<TAB>master-name<TAB>master-uuid<TAB>true|false
!person<TAB>avatar-name<TAB>avatar-uuid
!group-member<TAB>avatar-uuid
!chat<TAB>client-uuid<TAB>source-uuid<TAB>name<TAB>message
!im<TAB>client-uuid<TAB>source-uuid<TAB>name<TAB>agent|object|teleport<TAB>true|false<TAB>message<TAB>session-uuid
!packet<TAB>client-uuid<TAB>packet-type<TAB>simulator<TAB>bytes
!effect<TAB>client-uuid<TAB>summary
!disconnect<TAB>client-uuid<TAB>reason
!cancel
!shutdown
```
Inputs are limited to 1 MiB and 4,096 lines. The fake transcript records native
backend calls and ends with connected-client, pending-login, active-task,
shutdown, dropped-event, and pending-command-inventory state. Run the focused
framework and compatibility checks with:
```sh
cargo test -p libremetaverse-programs --test test_client_cli --locked
cargo test -p libremetaverse-programs test_client::tests --locked
cargo test --manifest-path tests/compat/Cargo.toml --test core_runtime_shims --locked
cargo test --manifest-path tests/compat/Cargo.toml --test social_message_semantics --locked
cargo test --manifest-path tests/compat/Cargo.toml --test network_semantics --locked
```
## PacketDump ## PacketDump
`packet-dump` preserves the upstream live arguments and its 20-second login `packet-dump` preserves the upstream live arguments and its 20-second login

View File

@@ -1,5 +1,3 @@
fn main() -> std::process::ExitCode { fn main() -> std::process::ExitCode {
let command_count = libremetaverse_programs::commands::TEST_CLIENT_COMMANDS.len(); libremetaverse_programs::test_client::main_entry()
eprintln!("TestClient command inventory contains {command_count} translated targets");
libremetaverse_programs::pending_program("TestClient")
} }

View File

@@ -7,5 +7,6 @@ pub mod osd_inspector;
pub mod packet_dump; pub mod packet_dump;
pub mod prim_inspector; pub mod prim_inspector;
pub mod simple_bot; pub mod simple_bot;
pub mod test_client;
pub use libremetaverse::shim::pending_program; pub use libremetaverse::shim::pending_program;

2502
programs/src/test_client.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,273 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
const ALICE: &str = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
const BOB: &str = "bbbbbbbb-cccc-dddd-eeee-ffffffffffff";
const MASTER: &str = "11111111-2222-3333-4444-555555555555";
const TARGET: &str = "22222222-3333-4444-5555-666666666666";
const GROUP: &str = "33333333-4444-5555-6666-777777777777";
const GROUP_MEMBER: &str = "44444444-5555-6666-7777-888888888888";
const STRANGER: &str = "55555555-6666-7777-8888-999999999999";
const SESSION: &str = "66666666-7777-8888-9999-aaaaaaaaaaaa";
const EXIT_USAGE: i32 = 2;
const EXIT_INPUT: i32 = 3;
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
struct TestDir(PathBuf);
impl TestDir {
fn new(name: &str) -> Self {
let id = TEMP_ID.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"metacrate-test-client-{name}-{}-{id}",
std::process::id()
));
fs::create_dir(&path).expect("create test directory");
Self(path)
}
fn write(&self, name: &str, contents: &str) -> PathBuf {
let path = self.0.join(name);
fs::write(&path, contents).expect("write fixture");
path
}
fn path(&self, name: &str) -> PathBuf {
self.0.join(name)
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn path_text(path: &Path) -> &str {
path.to_str().expect("UTF-8 test path")
}
fn run(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_test-client"))
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("run test-client")
}
fn stdout(output: &Output) -> &str {
std::str::from_utf8(&output.stdout).expect("UTF-8 stdout")
}
fn stderr(output: &Output) -> &str {
std::str::from_utf8(&output.stderr).expect("UTF-8 stderr")
}
fn assert_exit(output: &Output, expected: i32) {
assert_eq!(
output.status.code(),
Some(expected),
"stdout:\n{}\nstderr:\n{}",
stdout(output),
stderr(output)
);
}
#[test]
fn help_documents_upstream_framework_and_owned_commands() {
let output = run(&["--help"]);
assert!(output.status.success());
let text = stdout(&output);
for marker in [
"--first",
"--last",
"--pass",
"--file",
"--loginuri",
"--startpos",
"--master",
"--masterkey",
"--groupcommands",
"--gettextures",
"--scriptfile",
"--nogui",
"--fake-script",
"echomaster",
"imgroup",
"logpacket",
"waitforlogin",
] {
assert!(text.contains(marker), "help omitted {marker}:\n{text}");
}
let output = run(&["--first", "Only"]);
assert_exit(&output, EXIT_USAGE);
}
#[test]
fn scripted_terminal_exercises_registry_communication_system_and_remote_auth() {
let directory = TestDir::new("commands");
let packet_log = directory.path("packets.log");
let script = directory.write(
"terminal.tsv",
&format!(
"!client\t{ALICE}\tAlice\tBot\tMaster Resident\t{MASTER}\ttrue\n\
!client\t{BOB}\tBob\tBot\tMaster Resident\t{MASTER}\ttrue\n\
!person\tTarget Resident\t{TARGET}\n\
!group-member\t{GROUP_MEMBER}\n\
help say\n\
say 7 \"hello world\"\n\
whisper quiet\n\
shout loud\n\
@ Alice Bot\n\
im Target Resident private hello\n\
imgroup {GROUP} group hello\n\
echomaster\n\
showeffects on\n\
logpacket 2 \"{}\"\n\
!chat\t{ALICE}\t{MASTER}\tMaster Resident\techo this\n\
!effect\t{ALICE}\tViewerEffect [LookAt] source={MASTER}\n\
!packet\t{ALICE}\tChatFromSimulator\tFake Region\t120\n\
!packet\t{ALICE}\tImprovedInstantMessage\tFake Region\t240\n\
!im\t{ALICE}\t{MASTER}\tMaster Resident\tagent\tfalse\tsay remote works\t{SESSION}\n\
!im\t{ALICE}\t{GROUP_MEMBER}\tGroup Member\tagent\ttrue\twhisper group works\t{SESSION}\n\
!im\t{ALICE}\t{STRANGER}\tStranger Resident\tagent\tfalse\tlogin Victim Resident supersecret\t{SESSION}\n\
setmaster Target Resident\n\
setmasterkey {MASTER}\n\
!im\t{ALICE}\t{MASTER}\tMaster Resident\tteleport\tfalse\tlure\t{SESSION}\n\
sleep 0\n\
debug info\n\
md5 secret\n\
logout\n\
@\n\
say after logout\n\
quit\n",
path_text(&packet_log)
),
);
let output = run(&["--fake-script", path_text(&script)]);
assert!(output.status.success(), "{}", stderr(&output));
assert!(output.stderr.is_empty(), "{}", stderr(&output));
let text = stdout(&output);
for expected in [
"Say something. Usage: say [optional-channel] message",
"[Alice Bot] CALL chat channel=7 type=Normal hello world",
"[Bob Bot] CALL chat channel=7 type=Normal hello world",
"[Alice Bot] CALL instant-message 22222222-3333-4444-5555-666666666666 private hello",
"[Alice Bot] CALL group-chat-join 33333333-4444-5555-6666-777777777777",
"[Alice Bot] CALL group-instant-message 33333333-4444-5555-6666-777777777777 group hello",
"[Alice Bot] CALL chat channel=0 type=Normal echo this",
"ViewerEffect [LookAt] source=11111111-2222-3333-4444-555555555555",
"[Alice Bot] CALL chat channel=0 type=Normal remote works",
"[Alice Bot] CALL chat channel=0 type=Whisper group works",
"<sensitive command redacted>",
"Master set to Target Resident",
"CALL teleport-lure-respond",
"CALL agent-pause",
"CALL agent-resume",
"Logging is set to Info",
"$1$5ebe2294ecd0e0f08eab7690d2a6ee69",
"[Bob Bot] CALL chat channel=0 type=Normal after logout",
"active-tasks=0 shutdown=true",
] {
assert!(text.contains(expected), "missing {expected}:\n{text}");
}
assert!(!text.contains("supersecret"), "credential leaked:\n{text}");
assert_eq!(text.matches("CALL logout-dispose").count(), 2, "{text}");
let packet_text = fs::read_to_string(packet_log).expect("read packet log");
assert_eq!(packet_text.lines().count(), 2, "{packet_text}");
assert!(packet_text.contains("ChatFromSimulator"));
assert!(packet_text.contains("ImprovedInstantMessage"));
}
#[test]
fn login_replaces_duplicate_client_and_waitforlogin_is_deterministic() {
let directory = TestDir::new("login");
let script = directory.write(
"terminal.tsv",
"login Carol Bot credential-one\n\
waitforlogin\n\
login Carol Bot credential-two\n\
say only replacement\n\
quit\n",
);
let output = run(&["--fake-script", path_text(&script)]);
assert!(output.status.success(), "{}", stderr(&output));
let text = stdout(&output);
assert!(text.contains("currently tracking 1 bots"), "{text}");
assert_eq!(text.matches("Logged in Carol Bot").count(), 2, "{text}");
assert_eq!(
text.matches("CALL chat channel=0 type=Normal only replacement")
.count(),
1,
"{text}"
);
assert!(!text.contains("credential-one"), "password leaked:\n{text}");
assert!(!text.contains("credential-two"), "password leaked:\n{text}");
}
#[test]
fn native_alias_manifest_and_parser_are_bounded_and_deterministic() {
let directory = TestDir::new("load");
let manifest = directory.write(
"commands.tsv",
"greet\tSend a greeting through channel 9\tsay 9 hello {args}\n",
);
let script = directory.write(
"terminal.tsv",
&format!(
"!client\t{ALICE}\tAlice\tBot\n\
load \"{}\"\n\
help greet\n\
greet \"quoted world\"\n\
say 'unterminated\n\
quit\n",
path_text(&manifest)
),
);
let output = run(&["--fake-script", path_text(&script)]);
assert!(output.status.success(), "{}", stderr(&output));
let text = stdout(&output);
assert!(text.contains("Loaded 1 native command alias"), "{text}");
assert!(text.contains("Send a greeting through channel 9"), "{text}");
assert!(
text.contains("CALL chat channel=9 type=Normal hello quoted world"),
"{text}"
);
assert!(
text.contains("Command parse error: unterminated quoted token"),
"{text}"
);
}
#[test]
fn malformed_inputs_and_cancellation_fail_or_shutdown_cleanly() {
let directory = TestDir::new("invalid");
let invalid = directory.write("terminal.tsv", "!unknown\tvalue\n");
let output = run(&["--fake-script", path_text(&invalid)]);
assert_exit(&output, EXIT_INPUT);
assert!(stderr(&output).contains("line 1"));
let cancelled = directory.write(
"cancelled.tsv",
&format!(
"!client\t{ALICE}\tAlice\tBot\n\
!cancel\n\
sleep 60\n\
!shutdown\n"
),
);
let output = run(&["--fake-script", path_text(&cancelled)]);
assert!(output.status.success(), "{}", stderr(&output));
let text = stdout(&output);
assert!(text.contains("Pause cancelled; agent resumed"), "{text}");
assert!(text.contains("CALL agent-pause"), "{text}");
assert!(text.contains("CALL agent-resume"), "{text}");
assert!(text.contains("connected=0"), "{text}");
assert!(text.contains("active-tasks=0 shutdown=true"), "{text}");
}