返回 CodeWhale
command_catalog.rs
根目录 / crates / tui / src / runtime_api / tests / command_catalog.rs
1 use super::*;
2
3 fn entry<'a>(commands: &'a [CommandCatalogEntry], name: &str) -> &'a CommandCatalogEntry {
4 commands
5 .iter()
6 .find(|command| command.name == name)
7 .unwrap_or_else(|| panic!("catalog must contain {name}"))
8 }
9
10 #[test]
11 fn command_catalog_serves_builtins_with_host_binding() {
12 let users = crate::commands::user_registry::UserCommandRegistry::new();
13 let commands = command_catalog(&users);
14
15 let model = entry(&commands, "model");
16 assert_eq!(model.kind, "builtin");
17 assert_eq!(model.binding, "host");
18 assert_eq!(model.discovery, Some("primary"));
19 assert!(!model.hidden);
20 assert_eq!(model.shadowed_by, None);
21 assert!(model.summary.is_some());
22 assert!(model.usage.is_some());
23 assert!(model.takes_arguments);
24
25 // Composer shape comes from the same predicates the TUI composer uses:
26 // `/profile <name>` cannot run bare.
27 let profile = entry(&commands, "profile");
28 assert!(profile.requires_argument);
29 assert!(profile.requires_required_argument);
30 assert!(profile.composer_wants_trailing_space);
31 assert!(!profile.palette_runs_directly);
32
33 // Unlisted builtins run but are not advertised — hidden, not absent.
34 assert!(entry(&commands, "lane").hidden);
35
36 // A usage line's literal verbs surface as subcommands.
37 let goal = entry(&commands, "goal");
38 assert!(
39 goal.subcommands.iter().any(|verb| verb == "blocked"),
40 "goal usage should declare its verbs: {:?}",
41 goal.subcommands
42 );
43 }
44
45 #[test]
46 fn command_catalog_marks_user_shadowing_of_builtin_names_and_aliases() {
47 let users = crate::commands::user_registry::UserCommandRegistry::from_loaded(vec![
48 ("model".to_string(), "Pick the fast route.".to_string()),
49 ("agents".to_string(), "Alias-shaped command.".to_string()),
50 ]);
51 let commands = command_catalog(&users);
52
53 let model = entry(&commands, "model");
54 assert_eq!(model.shadowed_by.as_deref(), Some("model"));
55
56 // The shadowing user command is served as a prompt-bound row.
57 let user_model = commands
58 .iter()
59 .find(|command| command.name == "model" && command.kind == "user")
60 .expect("user command row");
61 assert_eq!(user_model.binding, "prompt");
62
63 // A user command colliding with a builtin's alias shadows that spelling:
64 // `agents` is a `subagents` alias, so the builtin reports it while keeping
65 // its canonical name.
66 let subagents = entry(&commands, "subagents");
67 assert_eq!(subagents.kind, "builtin");
68 assert_eq!(subagents.shadowed_by, None);
69 assert!(
70 subagents.shadowed_aliases.iter().any(|a| a == "agents"),
71 "shadowed_aliases must report the taken spelling: {:?}",
72 subagents.shadowed_aliases
73 );
74 }
75
76 #[tokio::test]
77 async fn get_v1_commands_serves_the_catalog_over_http() -> Result<()> {
78 let _env = lock_test_env();
79 let temp = tempfile::tempdir()?;
80 let root = temp.path().join("commands-route");
81 let sessions_dir = root.join("sessions");
82 let workspace = root.join("workspace");
83 let commands_dir = workspace.join(".codewhale").join("commands");
84 fs::create_dir_all(&commands_dir)?;
85 fs::write(commands_dir.join("model.md"), "Pick the fast route.\n")?;
86 // Workspace commands load only in a trusted workspace. A sealed config
87 // path lets the server's threads read the same trust record.
88 let _config = crate::test_support::EnvVarGuard::set(
89 "CODEWHALE_CONFIG_PATH",
90 temp.path().join("config.toml"),
91 );
92 crate::test_support::trust_workspace(&workspace);
93
94 let Some((addr, _runtime_threads, handle)) =
95 spawn_test_server_with_root_token_mobile_workspace(
96 root,
97 sessions_dir,
98 None,
99 false,
100 workspace,
101 )
102 .await?
103 else {
104 return Ok(());
105 };
106 let client = crate::tls::reqwest_client();
107
108 let body: serde_json::Value = client
109 .get(format!("http://{addr}/v1/commands"))
110 .send()
111 .await?
112 .error_for_status()?
113 .json()
114 .await?;
115 let commands = body["commands"].as_array().expect("commands array");
116 assert!(
117 commands
118 .iter()
119 .any(|command| command["kind"] == "builtin" && command["binding"] == "host"),
120 "the route must serve builtins: {commands:?}"
121 );
122
123 // The workspace user command named `model` shadows the builtin: the
124 // builtin reports the shadow and the winning definition is served.
125 let builtin_model = commands
126 .iter()
127 .find(|command| command["name"] == "model" && command["kind"] == "builtin")
128 .expect("builtin model row");
129 assert_eq!(builtin_model["shadowed_by"], "model");
130 let user_model = commands
131 .iter()
132 .find(|command| command["name"] == "model" && command["kind"] == "user")
133 .expect("user model row");
134 assert_eq!(user_model["binding"], "prompt");
135 // A template without `$ARGUMENTS` runs bare from the palette.
136 assert_eq!(user_model["requires_required_argument"], false);
137 assert_eq!(user_model["palette_runs_directly"], true);
138 assert_eq!(user_model["show_in_empty_discovery"], true);
139
140 let profile = commands
141 .iter()
142 .find(|command| command["name"] == "profile" && command["kind"] == "builtin")
143 .expect("builtin profile row");
144 assert_eq!(profile["requires_required_argument"], true);
145 assert_eq!(profile["palette_runs_directly"], false);
146
147 handle.abort();
148 Ok(())
149 }
150
151 /// B4: `GET /v1/hooks` serves the hook set Runtime threads run, from the
152 /// engine's own loader, with credential-shaped values masked.
153 #[tokio::test]
154 async fn get_v1_hooks_serves_the_runtime_hook_set() -> Result<()> {
155 let _env = lock_test_env();
156 let temp = tempfile::tempdir()?;
157 let root = temp.path().join("hooks-route");
158 let sessions_dir = root.join("sessions");
159 let workspace = root.join("workspace");
160 fs::create_dir_all(&workspace)?;
161 let config = Config {
162 hooks: Some(crate::hooks::HooksConfig {
163 hooks: vec![
164 crate::hooks::Hook::new(
165 crate::hooks::HookEvent::ToolCallAfter,
166 "curl -H 'Authorization: Bearer sk-ant-api03-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789' https://example.invalid",
167 )
168 .with_name("notify"),
169 ],
170 enabled: true,
171 ..crate::hooks::HooksConfig::default()
172 }),
173 ..Config::default()
174 };
175
176 let Some((addr, _runtime_threads, handle)) =
177 spawn_test_server_with_root_token_mobile_workspace_and_overrides(
178 root,
179 sessions_dir,
180 None,
181 false,
182 workspace,
183 TestServerOverrides {
184 config: Some(config),
185 ..TestServerOverrides::default()
186 },
187 )
188 .await?
189 else {
190 return Ok(());
191 };
192 let client = crate::tls::reqwest_client();
193 let body: serde_json::Value = client
194 .get(format!("http://{addr}/v1/hooks"))
195 .send()
196 .await?
197 .error_for_status()?
198 .json()
199 .await?;
200 assert_eq!(body["enabled"], true);
201 let hooks = body["hooks"].as_array().expect("hooks array");
202 assert_eq!(hooks.len(), 1, "{body}");
203 assert_eq!(hooks[0]["name"], "notify");
204 assert_eq!(hooks[0]["event"], "tool_call_after");
205 assert_eq!(hooks[0]["source"], "global");
206 let command = hooks[0]["command"].as_str().expect("command");
207 assert!(command.starts_with("curl"), "{command}");
208 assert!(!command.contains("sk-ant-api03-AbCd"), "{command}");
209
210 let missing = client
211 .get(format!("http://{addr}/v1/hooks?thread_id=thr_missing"))
212 .send()
213 .await?;
214 assert_eq!(missing.status(), reqwest::StatusCode::NOT_FOUND);
215 handle.abort();
216 Ok(())
217 }
218
219 #[test]
220 fn hook_listing_masks_url_paths_and_userinfo() {
221 assert_eq!(
222 redact_hook_command_for_listing(
223 "curl -X POST https://hooks.slack.com/services/T000/B000/XXXXsecret -d @-"
224 ),
225 "curl -X POST https://hooks.slack.com/[redacted] -d @-"
226 );
227 assert_eq!(
228 redact_hook_command_for_listing("notify 'https://user:pw@example.test/hook?k=v'"),
229 "notify 'https://example.test/[redacted]'"
230 );
231 assert_eq!(
232 redact_hook_command_for_listing("ping https://example.test"),
233 "ping https://example.test"
234 );
235 assert_eq!(
236 redact_hook_command_for_listing("./scripts/lint.sh --fix"),
237 "./scripts/lint.sh --fix"
238 );
239 }
240
240 lines RUST