返回 CodeWhale
extension_host_tests.rs
根目录 / crates / tui / src / commands / extension_host_tests.rs
1 //! Extension slash commands through the real command table and the TUI's
2 //! dispatch.
3 //!
4 //! These tests drive `crate::commands` (the user registry, `execute`, the
5 //! built-in table) and `crate::tui::app::App`, so they live on the commands
6 //! side of the runtime/UI boundary: the extension host is a runtime module and
7 //! its own tests may not depend on either (`scripts/check-command-crate-boundaries.py`).
8 //! They run the real Node host over the same fixtures the host's own tests use
9 //! and, where the table matters, install the real built-in command catalog
10 //! ([`BuiltinCommandNames`]) in place of the host tests' stub.
11
12 use std::path::PathBuf;
13 use std::sync::Arc;
14
15 use super::BuiltinCommandNames;
16 use crate::extension_host::command::BuiltinCommandsGuard;
17 use crate::extension_host::protocol::{RegisterKind, RegisterParams, RegisterSpecWire};
18 use crate::extension_host::registry::{OwnerRegistry, OwnerState};
19 use crate::extension_host::tests::{
20 FixturePlugins, fake_authority, installed, node_for_tests, plugin_id,
21 };
22 use crate::extension_host::tier::HostTier;
23 use crate::plugins::activation::TestPolicyGuard;
24
25 /// The real catalog answers to every built-in name, every alias and the fixed
26 /// mode aliases the dispatcher handles ahead of the registry, and an extension
27 /// command cannot take any of them.
28 #[test]
29 fn the_real_built_in_catalog_refuses_every_name_alias_and_mode_alias() {
30 use crate::extension_host::command::BuiltinCommandCatalog;
31 let catalog = BuiltinCommandNames;
32 let mut owned = vec!["jihua".to_string(), "zidong".to_string()];
33 for info in crate::commands::command_infos() {
34 owned.push(info.name.to_string());
35 owned.extend(info.aliases.iter().map(|alias| (*alias).to_string()));
36 }
37 assert!(
38 crate::commands::command_infos()
39 .iter()
40 .any(|info| !info.aliases.is_empty()),
41 "some built-in has an alias"
42 );
43 for name in &owned {
44 assert!(catalog.answers_to(name), "/{name} is built in");
45 }
46 assert!(!catalog.answers_to("ext-echo"));
47
48 let _catalog = BuiltinCommandsGuard::install(Arc::new(BuiltinCommandNames));
49 let mut registry = OwnerRegistry::new();
50 let owner = registry
51 .begin_owner(
52 HostTier::Plugin,
53 "a",
54 "a",
55 Some(fake_authority("a")),
56 "hash-a",
57 )
58 .unwrap();
59 let register = |registry: &mut OwnerRegistry, name: &str| {
60 registry.register(&RegisterParams {
61 scope: None,
62 owner: owner.clone(),
63 kind: RegisterKind::Command,
64 spec: RegisterSpecWire {
65 name: name.to_string(),
66 description: "d".to_string(),
67 input_schema: None,
68 argument_hint: None,
69 },
70 })
71 };
72 for name in ["help", "trust", "model", "jihua", "zidong"]
73 .into_iter()
74 .map(str::to_string)
75 .chain(owned)
76 {
77 let refused =
78 register(&mut registry, &name).expect_err(&format!("/{name} must be refused"));
79 // A built-in spelled outside an extension command's grammar (a
80 // punctuation alias) is refused as invalid before the catalog is asked.
81 let in_grammar = name.starts_with(|c: char| c.is_ascii_lowercase())
82 && name.len() <= 64
83 && name
84 .chars()
85 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-');
86 let expected = if in_grammar {
87 "built-in command"
88 } else {
89 "invalid"
90 };
91 assert!(refused.contains(expected), "{name}: {refused}");
92 }
93 register(&mut registry, "ext-not-built-in").unwrap();
94 }
95
96 /// Commands registered by real plugins in a real host: what the user
97 /// registry loads, what dispatch returns, and what running each one gives.
98 #[tokio::test]
99 async fn extension_commands_run_end_to_end_through_the_user_command_registry() {
100 use crate::extension_host::command::CommandOutcome;
101 use crate::tui::app::{App, AppAction, TuiOptions};
102
103 let Some(node) = node_for_tests("extension_commands_run_end_to_end") else {
104 return;
105 };
106 let _policy = TestPolicyGuard::extension_host(true);
107 let fixture = FixturePlugins::new(&["ext-commands"]).await;
108 let _catalog = BuiltinCommandsGuard::install(Arc::new(BuiltinCommandNames));
109 let manager = fixture.manager(node);
110 let _manager = crate::extension_host::TestManagerGuard::install(Arc::clone(&manager));
111 let engine = manager.attach(fixture.registry());
112 // Nothing is visible before the host has registered anything.
113 assert!(manager.live_command_names().is_empty());
114 engine.sync().await.unwrap();
115 let mut live = manager.live_command_names();
116 live.sort();
117 assert_eq!(
118 live,
119 [
120 "ext-ansi",
121 "ext-ask",
122 "ext-dsh",
123 "ext-echo",
124 "ext-fail",
125 "ext-slow",
126 "ext-throw"
127 ]
128 );
129 // Commands are not tools.
130 assert!(manager.live_tool_names().is_empty());
131 assert!(installed(&engine, fixture.workspace()).is_empty());
132
133 // The user registry loads them for this workspace (and no other).
134 let hint = |name: &str| {
135 crate::commands::user_registry::with_registry_for_plugins(
136 engine.plugin_view().as_ref(),
137 |registry| {
138 registry
139 .get(name)
140 .map(|command| (command.argument_hint.clone(), command.takes_arguments()))
141 },
142 )
143 };
144 assert_eq!(hint("ext-echo"), Some((Some("<text>".to_string()), true)));
145 assert_eq!(hint("ext-ask"), Some((Some("<topic>".to_string()), true)));
146 assert_eq!(hint("ext-fail"), Some((None, false)));
147 let other = fixture.workspace().join("elsewhere");
148 assert!(
149 crate::commands::user_registry::with_registry_for_workspace(Some(&other), |registry| {
150 registry.get("ext-echo").is_none()
151 }),
152 "another workspace never sees this workspace's extension commands"
153 );
154 // Discovery lists them.
155 let described = crate::commands::user_registry::with_registry_for_plugins(
156 engine.plugin_view().as_ref(),
157 |registry| {
158 registry
159 .iter()
160 .filter(|command| command.extension.is_some())
161 .count()
162 },
163 );
164 assert_eq!(described, 7);
165
166 // Dispatch is the ordinary slash-command path, and returns the action
167 // the UI loop runs; the arguments arrive trimmed.
168 let mut app = App::new(
169 TuiOptions {
170 workspace: fixture.workspace().to_path_buf(),
171 ..crate::test_support::test_tui_options(fixture.workspace())
172 },
173 &crate::config::Config::default(),
174 );
175 let help = crate::commands::execute("/help ext-echo", &mut app);
176 assert!(
177 help.message
178 .as_deref()
179 .is_some_and(|text| text.contains("Echo the arguments.")),
180 "{help:?}"
181 );
182 let dispatched = |app: &mut App, input: &str| match crate::commands::execute(input, app).action
183 {
184 Some(AppAction::RunExtensionCommand {
185 command,
186 name,
187 input,
188 }) => (command, name, input),
189 other => panic!("{input}: expected an extension command action, got {other:?}"),
190 };
191 let (echo, name, args) = dispatched(&mut app, "/ext-echo hello there ");
192 assert_eq!(
193 (name.as_str(), args.as_str()),
194 ("ext-echo", "hello there")
195 );
196 assert_eq!(echo.origin, "extension:ext-commands");
197 assert_eq!(
198 crate::extension_host::run_command(&echo, &args, None).await,
199 Ok(CommandOutcome::Show {
200 text: "echo: hello there".to_string()
201 })
202 );
203 let (ask, _, args) = dispatched(&mut app, "/EXT-ASK tokens");
204 assert_eq!(
205 crate::extension_host::run_command(&ask, &args, None).await,
206 Ok(CommandOutcome::Submit {
207 prompt: "Summarize: tokens".to_string(),
208 note: Some("Asking the model.".to_string())
209 })
210 );
211 let (fail, _, args) = dispatched(&mut app, "/ext-fail");
212 assert_eq!(
213 crate::extension_host::run_command(&fail, &args, None).await,
214 Err("unknown topic".to_string())
215 );
216 let (thrown, _, args) = dispatched(&mut app, "/ext-throw");
217 let error = crate::extension_host::run_command(&thrown, &args, None)
218 .await
219 .unwrap_err();
220 assert!(
221 error.starts_with("failed:") && error.contains("boom"),
222 "{error}"
223 );
224 // Escape sequences never reach the transcript.
225 let (ansi, _, args) = dispatched(&mut app, "/ext-ansi");
226 assert_eq!(
227 crate::extension_host::run_command(&ansi, &args, None).await,
228 Ok(CommandOutcome::Show {
229 text: "plain red end".to_string()
230 })
231 );
232 // The DSH-shaped `rawInput` keeps its leading separator.
233 let (dsh, _, args) = dispatched(&mut app, "/ext-dsh a b");
234 assert_eq!(
235 crate::extension_host::run_command(&dsh, &args, None).await,
236 Ok(CommandOutcome::Show {
237 text: "\" a b\"".to_string()
238 })
239 );
240
241 // Disabling the plugin removes every command at once: the registry stops
242 // listing them, and the reference a user (or palette) still holds fails
243 // closed instead of reaching the host.
244 engine.set_plugins(fixture.disable("ext-commands"));
245 engine.sync().await.unwrap();
246 assert!(manager.live_command_names().is_empty());
247 assert_eq!(hint("ext-echo"), None);
248 let error = crate::extension_host::run_command(&echo, "x", None)
249 .await
250 .unwrap_err();
251 assert!(
252 (error.contains("no longer registered") || error.contains("no longer selected")),
253 "{error}"
254 );
255 manager.shutdown().await;
256 }
257
258 /// A command may never take a built-in's name or another plugin's: the
259 /// registration is refused with a reason and that plugin fails to activate,
260 /// without disturbing the plugin that already holds the name.
261 #[tokio::test]
262 async fn extension_commands_never_shadow_built_ins_or_other_plugins() {
263 let Some(node) = node_for_tests("extension_commands_never_shadow") else {
264 return;
265 };
266 let _policy = TestPolicyGuard::extension_host(true);
267 let fixture = FixturePlugins::new(&[
268 "ext-commands",
269 "commands-clash-builtin",
270 "commands-clash-plugin",
271 ])
272 .await;
273 let _catalog = BuiltinCommandsGuard::install(Arc::new(BuiltinCommandNames));
274 let manager = fixture.manager(node);
275 let engine = manager.attach(fixture.registry());
276 engine.sync().await.unwrap();
277 let state = |name: &str| manager.owner_state(&plugin_id(&fixture, name)).unwrap();
278 match state("commands-clash-builtin") {
279 OwnerState::Failed(reason) => {
280 assert!(
281 reason.contains("collides with a built-in command"),
282 "{reason}"
283 )
284 }
285 other => panic!("{other:?}"),
286 }
287 // Distinct entry scopes may register the same spelling. The selected
288 // caller refuses ambiguity instead of choosing a global union winner.
289 assert_eq!(state("ext-commands"), OwnerState::Active);
290 assert_eq!(state("commands-clash-plugin"), OwnerState::Active);
291 assert_eq!(
292 manager
293 .live_command_names()
294 .iter()
295 .filter(|name| *name == "ext-echo")
296 .count(),
297 2
298 );
299 let selected = manager.commands_for_plugins(engine.plugin_view().as_ref());
300 assert!(
301 !selected
302 .iter()
303 .any(|entry| entry.registration.name == "ext-echo")
304 );
305 assert!(!manager.live_command_names().contains(&"help".to_string()));
306 let diagnostics = manager.diagnostics().join("\n");
307 assert!(
308 diagnostics.contains("command `help` refused"),
309 "{diagnostics}"
310 );
311 manager.shutdown().await;
312
313 // The user registry is the last line: a command already defined by a
314 // markdown source, or by a built-in, wins the spelling and the extension
315 // one is left out with a load error naming why.
316 let mut registry = crate::commands::user_registry::UserCommandRegistry::from_loaded(vec![(
317 "ext-echo".to_string(),
318 "markdown wins".to_string(),
319 )]);
320 let entry = |name: &str| crate::extension_host::command::ExtensionCommandEntry {
321 selection: None,
322 registration: crate::extension_host::registry::CommandRegistration {
323 scope: None,
324 handle: 1,
325 owner: crate::extension_host::protocol::OwnerRef {
326 plugin_id: "p".into(),
327 generation: 1,
328 owner_token: "t".into(),
329 },
330 tier: HostTier::Plugin,
331 plugin_name: "p".into(),
332 content_hash: "h".into(),
333 name: name.to_string(),
334 description: "d".into(),
335 argument_hint: None,
336 },
337 authority: fake_authority("p"),
338 workspace: PathBuf::from("/w"),
339 };
340 registry.load_extension_commands(vec![entry("ext-echo"), entry("help")]);
341 let errors: Vec<String> = registry
342 .load_errors()
343 .iter()
344 .map(|error| error.message.clone())
345 .collect();
346 assert!(
347 errors
348 .iter()
349 .any(|e| e.contains("'/ext-echo' collides with another command")),
350 "{errors:?}"
351 );
352 assert!(
353 errors
354 .iter()
355 .any(|e| e.contains("'/help' collides with a built-in command")),
356 "{errors:?}"
357 );
358 assert!(registry.get("ext-echo").unwrap().extension.is_none());
359 }
360
360 lines RUST