Implement remaining TestClient service commands (#94)
Some checks failed
Native code generation / deterministic (push) Successful in 18m19s
Imaging and meshing gate / native (push) Failing after 4m28s
Native Rust workspace compile / compile (push) Failing after 12m58s

This commit is contained in:
2026-08-11 13:10:27 +00:00
parent 374d7958f1
commit 21f85a1b58
13 changed files with 3300 additions and 24 deletions

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 |
| `inventory-explorer` | InventoryExplorer | Implemented with live inventory and deterministic AIS fixtures |
| `irc-gateway` | IRCGateway | Implemented with live IRC/grid transports and deterministic offline scripts |
| `test-client` | TestClient | Native shell, registry, system, communication, inventory, appearance, asset, movement, object, parcel, estate, and grid groups implemented; remaining command group tracked by #94 |
| `test-client` | TestClient | All non-voice command groups implemented with live and deterministic fake-grid backends; voice adapters tracked by #95 and #96 |
| `vivox-test` | VivoxTest | Pending milestone 11 issue #95 |
| `webrtc-test` | WebRtcTest | Pending milestone 11 issue #96 |
@@ -184,6 +184,13 @@ transfer, and grid map/layer/location/wind commands. `textures on` and
primitive textures. Detailed command behavior and limits are in
[`docs/test-client.md`](../docs/test-client.md).
The final non-voice wave adds nearby-agent and bot inspection, complete profile
cloning, friend mapping, group membership and role operations, paged directory
searches, event details, animation control, object touch, and region/network
statistics. Live profile cloning combines the modern AgentProfile capability
with the legacy interests reply so text, images, URL, interests, picks, and
public groups are all synchronized.
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

View File

@@ -1,6 +1,7 @@
//! Native `TestClient` shell, registry, and first-wave command groups.
mod inventory;
mod services;
mod world;
use crate::commands::TEST_CLIENT_COMMANDS;
@@ -135,6 +136,34 @@ pub const IMPLEMENTED_TEST_CLIENT_COMMANDS: &[&str] = &[
"ShowEffectsCommand",
"SleepCommand",
"WaitForLoginCommand",
"BotsCommand",
"CloneProfileCommand",
"GenericMessageCommand",
"PlayAnimationCommand",
"TouchCommand",
"WhoCommand",
"Key2NameCommand",
"SearchClassifiedsCommand",
"SearchEventsCommand",
"SearchGroupsCommand",
"SearchLandCommand",
"SearchPeopleCommand",
"SearchPlacesCommand",
"ShowEventDetailsCommand",
"FriendsCommand",
"MapFriendCommand",
"ActivateGroupCommand",
"GroupMembersCommand",
"GroupRolesCommand",
"GroupsCommand",
"InviteGroupCommand",
"JoinGroupCommand",
"LeaveGroupCommand",
"DilationCommand",
"NetstatsCommand",
"RegionInfoCommand",
"StatsCommand",
"UptimeCommand",
];
#[must_use]
@@ -153,7 +182,7 @@ pub fn pending_test_client_commands() -> Vec<&'static str> {
version,
about = "Run the native LibreMetaverse multi-client command shell",
long_about = None,
after_help = "Owned commands include @, debug, echomaster, help, im, imgroup, load, login, logpacket, logout, md5, quit, say, setmaster, setmasterkey, shout, showeffects, sleep, waitforlogin, whisper, plus the inventory, appearance, asset, movement, object, parcel, estate, and grid groups. Run `help` or `help COMMAND` inside the shell for the complete command registry."
after_help = "Owned commands include @, debug, echomaster, help, im, imgroup, load, login, logpacket, logout, md5, quit, say, setmaster, setmasterkey, shout, showeffects, sleep, waitforlogin, and whisper. All non-voice inventory, appearance, asset, movement, object, parcel, estate, grid, social, directory, and statistics commands are also native. Run `help` or `help COMMAND` inside the shell for the complete command registry."
)]
#[allow(clippy::struct_excessive_bools)]
struct Cli {
@@ -296,7 +325,7 @@ enum ClientEvent {
type BackendFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
type InventoryFuture<'a, T> = Pin<Box<dyn Future<Output = T> + 'a>>;
trait ClientBackend: Send + Sync + inventory::Backend + world::Backend {
trait ClientBackend: Send + Sync + inventory::Backend + services::Backend + world::Backend {
fn id(&self) -> UUID;
fn name(&self) -> String;
fn connected(&self) -> bool;
@@ -332,6 +361,7 @@ struct LiveBackend {
subscriptions: Mutex<Vec<Subscription>>,
inventory_state: Mutex<inventory::State>,
world_state: Mutex<world::State>,
services_state: Mutex<services::State>,
closed: AtomicBool,
}
@@ -393,6 +423,7 @@ impl LiveBackend {
subscriptions: Mutex::new(Vec::new()),
inventory_state: Mutex::new(inventory::State::new(account_policy)),
world_state: Mutex::new(world::State::new(account_policy, get_textures)),
services_state: Mutex::new(services::State::new(account_policy)),
closed: AtomicBool::new(false),
});
backend.install(&events, &dropped);
@@ -512,6 +543,7 @@ impl LiveBackend {
},
)));
world::install_live(self, &mut subscriptions);
services::install_live(self, &mut subscriptions);
*lock(&self.subscriptions) = subscriptions;
}
}
@@ -680,6 +712,7 @@ struct FakeBackend {
group_members: Arc<Mutex<HashSet<UUID>>>,
inventory_state: Mutex<inventory::State>,
world_state: Mutex<world::State>,
services_state: Mutex<services::State>,
}
impl FakeBackend {
@@ -700,6 +733,7 @@ impl FakeBackend {
group_members,
inventory_state: Mutex::new(inventory::State::new(policy)),
world_state: Mutex::new(world::State::new(policy, get_textures)),
services_state: Mutex::new(services::State::new(policy)),
}
}
@@ -820,10 +854,13 @@ impl ProgramOutput {
enum CommandCategory {
Appearance,
Communication,
Friends,
Groups,
Inventory,
Movement,
Objects,
Parcel,
Search,
Simulator,
TestClient,
Other,
@@ -838,6 +875,7 @@ enum CommandHandler {
Im,
ImGroup,
Inventory(inventory::Command),
Services(services::Command),
World(world::Command),
Load,
Login,
@@ -999,6 +1037,7 @@ fn built_in_commands() -> HashMap<String, CommandDefinition> {
.into_iter()
.chain(communication_commands())
.chain(inventory::commands())
.chain(services::commands())
.chain(world::commands())
.map(|(name, description, category, handler)| {
(
@@ -1780,6 +1819,9 @@ async fn execute_client_command(
CommandHandler::Inventory(command) => {
inventory::execute(client.backend.as_ref(), *command, args, from, cancellation).await
}
CommandHandler::Services(command) => {
services::execute(client.backend.as_ref(), *command, args, cancellation).await
}
CommandHandler::World(command) => {
world::execute(client.backend.as_ref(), *command, args, cancellation).await
}
@@ -2367,6 +2409,10 @@ async fn run_fake_event_directive(
result.map_err(|reason| ProgramError::InvalidInput { line, reason })?;
return Ok(());
}
if let Some(result) = services::apply_fake_directive(manager, fields) {
result.map_err(|reason| ProgramError::InvalidInput { line, reason })?;
return Ok(());
}
match fields {
["chat", client, source, name, message] => {
let event = ClientEvent::Chat {
@@ -2670,6 +2716,7 @@ mod tests {
assert!(TEST_CLIENT_COMMANDS.contains(command));
assert!(!pending.contains(command));
}
assert_eq!(pending, ["ParcelVoiceInfo", "VoiceAcountCommand"]);
}
#[test]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,215 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
const CLIENT: &str = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
const AVATAR: &str = "11111111-2222-3333-4444-555555555555";
const BOT: &str = "22222222-3333-4444-5555-666666666666";
const GROUP: &str = "33333333-4444-5555-6666-777777777777";
const MEMBER: &str = "44444444-5555-6666-7777-888888888888";
const ROLE: &str = "55555555-6666-7777-8888-999999999999";
const CLASSIFIED: &str = "66666666-7777-8888-9999-aaaaaaaaaaaa";
const LAND: &str = "77777777-8888-9999-aaaa-bbbbbbbbbbbb";
const SIM: &str = "88888888-9999-aaaa-bbbb-cccccccccccc";
const OBJECT: &str = "99999999-aaaa-bbbb-cccc-dddddddddddd";
const DANCE1: &str = "b68a3d7c-de9e-fc87-eec8-543d787e5b0d";
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
struct TestDir(PathBuf);
impl TestDir {
fn new() -> Self {
let id = TEMP_ID.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"metacrate-test-client-services-{}-{id}",
std::process::id()
));
fs::create_dir(&path).expect("create test directory");
Self(path)
}
fn write(&self, contents: &str) -> PathBuf {
let path = self.0.join("session.tsv");
fs::write(&path, contents).expect("write script");
path
}
}
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")
}
#[test]
#[allow(clippy::too_many_lines)]
fn full_offline_session_exercises_every_remaining_non_voice_command() {
let directory = TestDir::new();
let script = directory.write(&format!(
"!client\t{CLIENT}\tAlice\tBot\n\
!service-avatar\t{CLIENT}\t{AVATAR}\tActive Resident\tBuilders\t128\t129\t25\t10\ttrue\n\
!service-avatar\t{CLIENT}\t{BOT}\tQuiet Bot\tNone\t130\t131\t25\t11\tfalse\n\
!service-friend\t{CLIENT}\t{AVATAR}\tActive Resident\ttrue\t4294967298000\t128\t129\t25\n\
!service-group\t{CLIENT}\t{GROUP}\tBuilders Guild\n\
!service-group-result\t{CLIENT}\t{GROUP}\tBuilders Guild\t42\n\
!service-group-member\t{CLIENT}\t{GROUP}\t{MEMBER}\n\
!service-group-role\t{CLIENT}\t{GROUP}\t{ROLE}\tOfficer\tArchitect\n\
!service-person\t{CLIENT}\t{AVATAR}\tActive\tResident\ttrue\n\
!service-classified\t{CLIENT}\t{CLASSIFIED}\tBuilder Services\t25\n\
!service-event\t{CLIENT}\t7\tBuilding Class\tToday\n\
!service-event-info\t{CLIENT}\t7\tBuilding Class\tTest Region\t1000\t2000\t25\n\
!service-land\t{CLIENT}\t{LAND}\tBuilder Parcel\t1024\t500\n\
!service-place\t{CLIENT}\tBuilder Plaza\tTest Region\t128\t128\t25\n\
!service-sim\t{CLIENT}\tTest Region\t{SIM}\t4294967298000\t0.98\n\
!service-utilization\t{CLIENT}\ttrue\n\
!service-utilization-row\t{CLIENT}\tpacket\tAgentUpdate\t2\t3\t2048\t4096\n\
!service-utilization-row\t{CLIENT}\tcapability\tEventQueueGet\t1\t4\t512\t8192\n\
who\n\
bots\n\
friends\n\
mapfriend {AVATAR}\n\
key2name {AVATAR}\n\
key2name {GROUP}\n\
searchclassifieds Builder\n\
searchevents Building\n\
searchgroups Builders\n\
searchland mainland 1000 512\n\
searchpeople Active\n\
searchplaces Builder\n\
showevent 7\n\
groups\n\
groupmembers Builders Guild\n\
grouproles Builders Guild\n\
play list\n\
play show\n\
dilation\n\
netstats\n\
regioninfo\n\
stats\n\
uptime\n\
cloneprofile {AVATAR} --confirm\n\
sendgeneric test-method alpha beta --confirm\n\
play DANCE1 --confirm\n\
touch {OBJECT} --confirm\n\
activategroup Builders Guild --confirm\n\
invitegroup {AVATAR} {GROUP} {ROLE} --confirm\n\
joingroup Builders Guild --confirm\n\
leavegroup Builders Guild --confirm\n\
quit\n"
));
let output = run(&[
"--allow-live-mutations",
"--allow-spending",
"--fake-script",
path_text(&script),
]);
let stdout = String::from_utf8(output.stdout).expect("UTF-8 stdout");
let stderr = String::from_utf8(output.stderr).expect("UTF-8 stderr");
assert!(
output.status.success(),
"stdout:\n{stdout}\nstderr:\n{stderr}"
);
assert!(stderr.is_empty(), "{stderr}");
for expected in [
"Active Resident (Group: Builders",
"Quiet Bot (Group: None",
"Is Probably a bot",
"has 1 friends:",
"Found Friend 11111111-2222-3333-4444-555555555555",
"Avatar: Active Resident",
"Group: Builders Guild",
"returned 1 classified ads",
"matched 1 Events",
"matched 1 Groups",
"1 results",
"matched 1 People",
"returned 1 results",
"<redacted-url> Region/232/208/0",
"got 1 groups",
"MemberCount 1",
"RoleCount 1",
"AFRAID",
DANCE1,
"Dilation is 0.98",
"Capabilities Totals",
"Packet Totals",
"UUID: 88888888-9999-aaaa-bbbb-cccccccccccc",
"Packets in the queue:",
"I am Alice Bot, Up Since:",
"Synchronized our profile",
"Sent generic message with method test-method",
"Touched object",
"Active group is now Builders Guild",
"invited",
"Joined the group Builders Guild",
"has left the group Builders Guild",
"CALL profile-clone",
"CALL generic-message",
"CALL animation-start",
"CALL object-touch",
"CALL group-activate",
"CALL group-invite",
"CALL group-join",
"CALL group-leave",
] {
assert!(stdout.contains(expected), "missing {expected:?}:\n{stdout}");
}
}
#[test]
fn social_mutations_require_startup_and_per_command_confirmation() {
let directory = TestDir::new();
let script = directory.write(&format!(
"!client\t{CLIENT}\tAlice\tBot\nplay {DANCE1} --confirm\nquit\n"
));
let output = run(&["--fake-script", path_text(&script)]);
let stdout = String::from_utf8(output.stdout).expect("UTF-8 stdout");
assert!(output.status.success());
assert!(stdout.contains("Live mutations are disabled"), "{stdout}");
assert!(!stdout.contains("CALL animation-start"), "{stdout}");
let script = directory.write(&format!(
"!client\t{CLIENT}\tAlice\tBot\ntouch {OBJECT}\nquit\n"
));
let output = run(&[
"--allow-live-mutations",
"--fake-script",
path_text(&script),
]);
let stdout = String::from_utf8(output.stdout).expect("UTF-8 stdout");
assert!(output.status.success());
assert!(stdout.contains("append --confirm"), "{stdout}");
assert!(!stdout.contains("CALL object-touch"), "{stdout}");
let script = directory.write(&format!(
"!client\t{CLIENT}\tAlice\tBot\njoingroup uuid {GROUP} --confirm\nquit\n"
));
let output = run(&[
"--allow-live-mutations",
"--fake-script",
path_text(&script),
]);
let stdout = String::from_utf8(output.stdout).expect("UTF-8 stdout");
assert!(output.status.success());
assert!(stdout.contains("restart with --allow-spending"), "{stdout}");
assert!(!stdout.contains("CALL group-join"), "{stdout}");
}
#[test]
fn command_inventory_leaves_only_explicit_voice_adapters() {
assert_eq!(
libremetaverse_programs::test_client::pending_test_client_commands(),
["ParcelVoiceInfo", "VoiceAcountCommand"]
);
}