返回 CodeWhale
plugin_e2e_acceptance.rs
根目录 / crates / tui / tests / plugin_e2e_acceptance.rs
1 //! Plugin acceptance at both compatibility and v0.9.1 bundle boundaries.
2 //!
3 //! Tests the plugin frontmatter scanner end-to-end from the binary level:
4 //! - Scripts with valid `# name:` frontmatter are discovered
5 //! - Approval levels (auto, suggest, required) are parsed correctly
6 //! - Hidden files and README.md are ignored
7 //! - Empty and missing directories are handled gracefully
8 //! - The distributed binary still loads after the plugin module migration
9 //! - A sealed real PTY exercises plugin.toml review/trust/enable/revoke,
10 //! reviewed Skill dispatch, and hermetic reviewed stdio MCP execution
11
12 use std::path::PathBuf;
13 use std::process::Command;
14
15 use cucumber::{World as _, given, then, when, writer::Stats as _};
16 use tempfile::TempDir;
17
18 #[cfg(all(unix, feature = "long-running-tests"))]
19 #[path = "support/qa_harness/mod.rs"]
20 mod qa_harness;
21
22 #[cfg(all(unix, feature = "long-running-tests"))]
23 use qa_harness::harness::{Harness, make_sealed_workspace};
24 #[cfg(all(unix, feature = "long-running-tests"))]
25 use qa_harness::keys;
26
27 const FEATURE_NAME: &str = "Plugin discovery and listing";
28 const FEATURE_PATH: &str = concat!(
29 env!("CARGO_MANIFEST_DIR"),
30 "/tests/features/plugin_e2e_acceptance.feature"
31 );
32 const DISCOVERY_SCENARIO: &str =
33 "Plugin scripts are discovered from the configured plugin directory";
34 const EMPTY_SCENARIO: &str = "Empty plugin directory reports no plugins";
35 const MISSING_SCENARIO: &str = "Missing plugin directory reports the path";
36
37 // ---------------------------------------------------------------------------
38 // Test-local plugin scanner
39 //
40 // Mirrors the real `scan_plugin_dir` from `crates/tui/src/tools/plugin.rs`
41 // so the test can run as a standalone integration test without relying on
42 // `#[path]` (which breaks on internal `crate::` and `super::` imports).
43 // The contract (frontmatter format, skip rules) matches exactly.
44 // ---------------------------------------------------------------------------
45
46 #[derive(Debug, Clone, PartialEq)]
47 struct TestPluginMeta {
48 name: String,
49 description: String,
50 approval: TestApproval,
51 }
52
53 #[derive(Debug, Clone, Copy, PartialEq)]
54 enum TestApproval {
55 Auto,
56 Suggest,
57 Required,
58 }
59
60 fn parse_frontmatter(content: &str) -> Option<TestPluginMeta> {
61 let mut name = String::new();
62 let mut description = String::new();
63 let mut approval_str = String::new();
64
65 for line in content.lines().take(20) {
66 let line = line.trim();
67 let rest = line
68 .strip_prefix('#')
69 .or_else(|| line.strip_prefix("//"))
70 .or_else(|| line.strip_prefix("--"));
71 let Some(rest) = rest else { continue };
72 let Some((key, value)) = rest.trim_start().split_once(':') else {
73 continue;
74 };
75 match key.trim().to_lowercase().as_str() {
76 "name" => name = value.trim().to_string(),
77 "description" => description = value.trim().to_string(),
78 "approval" => approval_str = value.trim().to_string(),
79 _ => {}
80 }
81 }
82
83 if name.is_empty() {
84 return None;
85 }
86
87 let approval = match approval_str.to_lowercase().as_str() {
88 "auto" => TestApproval::Auto,
89 "required" => TestApproval::Required,
90 _ => TestApproval::Suggest,
91 };
92
93 Some(TestPluginMeta {
94 name,
95 description: if description.is_empty() {
96 "User-provided plugin tool".to_string()
97 } else {
98 description
99 },
100 approval,
101 })
102 }
103
104 fn scan_plugin_dir(dir: &std::path::Path) -> Vec<(PathBuf, TestPluginMeta)> {
105 let mut results = Vec::new();
106
107 let entries = match std::fs::read_dir(dir) {
108 Ok(entries) => entries,
109 Err(_) => return results,
110 };
111
112 let mut entries: Vec<_> = entries.flatten().collect();
113 entries.sort_by_key(|entry| entry.file_name());
114
115 for entry in entries {
116 let path = entry.path();
117
118 if path.is_dir() {
119 continue;
120 }
121
122 if let Some(name) = path.file_name().and_then(|n| n.to_str())
123 && (name.starts_with('.') || name == "README.md")
124 {
125 continue;
126 }
127
128 if let Ok(content) = std::fs::read_to_string(&path)
129 && let Some(meta) = parse_frontmatter(&content)
130 {
131 results.push((path, meta));
132 }
133 }
134
135 results
136 }
137
138 // ---------------------------------------------------------------------------
139 // Cucumber world
140 // ---------------------------------------------------------------------------
141
142 #[derive(Debug, Default, cucumber::World)]
143 struct PluginE2EWorld {
144 /// TempDir holding the plugin directory. We keep a second TempDir as
145 /// the "workspace" so the plugin dir path stays valid after move.
146 _workspace: Option<TempDir>,
147 plugin_dir: Option<TempDir>,
148 discovered: Option<Vec<(PathBuf, TestPluginMeta)>>,
149 scanner_message: Option<String>,
150 }
151
152 // ---------------------------------------------------------------------------
153 // Given steps
154 // ---------------------------------------------------------------------------
155
156 #[given("an offline CodeWhale workspace with a configured plugin directory")]
157 fn offline_workspace_with_plugin_dir(world: &mut PluginE2EWorld) {
158 let workspace = TempDir::new().expect("workspace tempdir");
159 let plugin_dir = TempDir::new().expect("plugin tempdir");
160 world._workspace = Some(workspace);
161 world.plugin_dir = Some(plugin_dir);
162 }
163
164 #[given(regex = r"^the plugin directory contains:$")]
165 fn plugin_directory_contains(world: &mut PluginE2EWorld, step: &cucumber::gherkin::Step) {
166 let dir = world
167 .plugin_dir
168 .as_ref()
169 .expect("plugin directory should be configured");
170
171 let table = step
172 .table
173 .as_ref()
174 .expect("step should include a data table");
175 let mut rows = table.rows.iter();
176 let headers = rows.next().expect("data table should include a header");
177 let name_idx = headers
178 .iter()
179 .position(|h| h == "name")
180 .expect("data table should have a 'name' column");
181 let desc_idx = headers
182 .iter()
183 .position(|h| h == "description")
184 .expect("data table should have a 'description' column");
185 let approval_idx = headers
186 .iter()
187 .position(|h| h == "approval")
188 .expect("data table should have an 'approval' column");
189
190 for row in rows {
191 let name = row.get(name_idx).expect("plugin name");
192 let description = row.get(desc_idx).expect("plugin description");
193 let approval = row.get(approval_idx).expect("plugin approval");
194
195 let script_path = dir.path().join(format!("{name}.sh"));
196 let script_content = format!(
197 "# name: {name}\n\
198 # description: {description}\n\
199 # approval: {approval}\n\
200 # schema: {{\"type\":\"object\"}}\n\
201 echo hello\n"
202 );
203 std::fs::write(&script_path, &script_content)
204 .unwrap_or_else(|e| panic!("write plugin script {name}.sh: {e}"));
205
206 // Make executable on Unix
207 #[cfg(unix)]
208 {
209 use std::os::unix::fs::PermissionsExt;
210 std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))
211 .unwrap_or_else(|e| panic!("chmod {name}.sh: {e}"));
212 }
213 }
214
215 // Write a README.md and a hidden file that should be ignored
216 std::fs::write(dir.path().join("README.md"), "# Plugin Docs\n").expect("write README.md");
217 std::fs::write(
218 dir.path().join(".hidden_script.sh"),
219 "# name: hidden\n# description: Should not appear\n",
220 )
221 .expect("write hidden");
222 }
223
224 #[given("the plugin directory is empty")]
225 fn plugin_directory_empty(world: &mut PluginE2EWorld) {
226 // Replace with a fresh empty directory
227 let dir = TempDir::new().expect("empty plugin tempdir");
228 world.plugin_dir = Some(dir);
229 }
230
231 #[given("the plugin directory does not exist")]
232 fn plugin_directory_does_not_exist(world: &mut PluginE2EWorld) {
233 let base = TempDir::new().expect("base tempdir for non-existent path");
234 let non_existent = base.path().join("nonexistent");
235 // Ensure it truly doesn't exist
236 let _ = std::fs::remove_dir_all(&non_existent);
237 // Store the base so the path stays valid for the lifetime of the test
238 world._workspace = Some(base);
239 // Remove the previous plugin_dir so scanning uses the path deliberately
240 world.plugin_dir = None;
241 world.scanner_message = Some(format!(
242 "No plugin directory found at {}",
243 non_existent.display()
244 ));
245 }
246
247 // ---------------------------------------------------------------------------
248 // When steps
249 // ---------------------------------------------------------------------------
250
251 #[when("the plugin scanner discovers plugins")]
252 fn plugin_scanner_discovers_plugins(world: &mut PluginE2EWorld) {
253 let dir = world
254 .plugin_dir
255 .as_ref()
256 .expect("plugin directory should be configured");
257 let discovered = scan_plugin_dir(dir.path());
258 world.discovered = Some(discovered);
259 }
260
261 #[when("the plugin scanner runs")]
262 fn plugin_scanner_runs(world: &mut PluginE2EWorld) {
263 // Use the stored non-existent path
264 let msg = world
265 .scanner_message
266 .as_ref()
267 .expect("missing path message");
268 // Extract the path from the message
269 let path_str = msg
270 .strip_prefix("No plugin directory found at ")
271 .expect("message format");
272 let path = std::path::Path::new(path_str);
273 let discovered = scan_plugin_dir(path);
274 world.discovered = Some(discovered);
275 }
276
277 // ---------------------------------------------------------------------------
278 // Then steps
279 // ---------------------------------------------------------------------------
280
281 #[then(regex = r"^the scanner should report (\d+) plugins?$")]
282 fn scanner_should_report_n_plugins(world: &mut PluginE2EWorld, expected_count: usize) {
283 let discovered = world.discovered.as_ref().expect("scanner should have run");
284 assert_eq!(
285 discovered.len(),
286 expected_count,
287 "expected {expected_count} plugins, found {}: {discovered:#?}",
288 discovered.len()
289 );
290 }
291
292 #[then(regex = r#"^the scanned plugin "([^"]+)" should have "([^"]+)" as description$"#)]
293 fn scanned_plugin_should_have_description(
294 world: &mut PluginE2EWorld,
295 name: String,
296 expected_description: String,
297 ) {
298 let discovered = world.discovered.as_ref().expect("scanner should have run");
299 let meta = discovered
300 .iter()
301 .find(|(_, m)| m.name == name)
302 .map(|(_, m)| m)
303 .unwrap_or_else(|| panic!("plugin \"{name}\" not found in scan results"));
304
305 assert_eq!(
306 meta.description, expected_description,
307 "plugin \"{name}\" description mismatch"
308 );
309 }
310
311 #[then(regex = r#"^the scanned plugin "([^"]+)" should have "([^"]+)" as approval$"#)]
312 fn scanned_plugin_should_have_approval(
313 world: &mut PluginE2EWorld,
314 name: String,
315 expected_approval: String,
316 ) {
317 let discovered = world.discovered.as_ref().expect("scanner should have run");
318 let meta = discovered
319 .iter()
320 .find(|(_, m)| m.name == name)
321 .map(|(_, m)| m)
322 .unwrap_or_else(|| panic!("plugin \"{name}\" not found in scan results"));
323
324 let actual = match meta.approval {
325 TestApproval::Auto => "auto",
326 TestApproval::Suggest => "suggest",
327 TestApproval::Required => "required",
328 };
329 assert_eq!(
330 actual, expected_approval,
331 "plugin \"{name}\" approval mismatch"
332 );
333 }
334
335 #[then(regex = r#"^the scanned plugin "([^"]+)" should not be found$"#)]
336 fn scanned_plugin_should_not_be_found(world: &mut PluginE2EWorld, name: String) {
337 let discovered = world.discovered.as_ref().expect("scanner should have run");
338 assert!(
339 !discovered.iter().any(|(_, m)| m.name == name),
340 "plugin \"{name}\" should not be present in scan results, but was found"
341 );
342 }
343
344 #[then("the scanner should report the missing directory path")]
345 fn scanner_should_report_missing_path(world: &mut PluginE2EWorld) {
346 let discovered = world.discovered.as_ref().expect("scanner should have run");
347 assert!(
348 discovered.is_empty(),
349 "expected empty results for missing directory, got: {discovered:#?}"
350 );
351 let msg = world
352 .scanner_message
353 .as_deref()
354 .unwrap_or("scanner ran without message");
355 assert!(
356 msg.contains("No plugin directory found"),
357 "expected missing directory message, got: {msg}"
358 );
359 }
360
361 // ---------------------------------------------------------------------------
362 // Binary smoke test
363 // ---------------------------------------------------------------------------
364
365 /// Prove the binary still loads after the plugin module extraction.
366 #[tokio::test(flavor = "current_thread")]
367 async fn plugin_module_does_not_break_binary_load() {
368 let output = Command::new(codewhale_tui_binary())
369 .arg("--version")
370 .output()
371 .expect("codewhale-tui --version should start");
372
373 assert!(
374 output.status.success(),
375 "codewhale-tui --version failed\nstderr:\n{}",
376 String::from_utf8_lossy(&output.stderr)
377 );
378
379 let version = String::from_utf8_lossy(&output.stdout);
380 assert!(
381 version.contains("codewhale"),
382 "version output should mention codewhale, got: {version}"
383 );
384 }
385
386 // ---------------------------------------------------------------------------
387 // Real plugin.toml binary/TUI acceptance
388 // ---------------------------------------------------------------------------
389
390 #[cfg(all(unix, feature = "long-running-tests"))]
391 const BINARY_ACCEPTANCE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
392
393 #[cfg(all(unix, feature = "long-running-tests"))]
394 fn write_reviewed_bundle_fixture(workspace: &std::path::Path) -> PathBuf {
395 use std::os::unix::fs::PermissionsExt as _;
396
397 let bundle = workspace.join(".codewhale/plugins/demo");
398 std::fs::create_dir_all(bundle.join("skills/review")).expect("plugin fixture directories");
399 std::fs::write(
400 bundle.join("plugin.toml"),
401 r#"schema_version = 1
402 [plugin]
403 name = "demo"
404 version = "1.0.0"
405 description = "Hermetic binary acceptance fixture"
406
407 [skills]
408 path = "skills"
409
410 [mcp_servers.local]
411 command = "./server.py"
412 connect_timeout = 5
413 execute_timeout = 30
414 read_timeout = 30
415
416 [mcp_servers.local.env]
417 ACCEPTANCE_LOG = "${PLUGIN_ACCEPTANCE_LOG}"
418 "#,
419 )
420 .expect("plugin manifest");
421 std::fs::write(
422 bundle.join("skills/review/SKILL.md"),
423 "---\nname: review\ndescription: reviewed binary acceptance Skill\n---\n\nUse the reviewed fixture.\n",
424 )
425 .expect("plugin Skill");
426 let server = bundle.join("server.py");
427 std::fs::write(
428 &server,
429 r#"#!/usr/bin/env python3
430 import json
431 import os
432 import signal
433 import sys
434 import time
435
436 log_path = os.environ["ACCEPTANCE_LOG"]
437
438 def record(event):
439 with open(log_path, "a", encoding="utf-8") as handle:
440 handle.write(event + "\n")
441 handle.flush()
442
443 def stop(signum, _frame):
444 record("signal:" + str(signum))
445 raise SystemExit(0)
446
447 signal.signal(signal.SIGTERM, stop)
448 signal.signal(signal.SIGINT, stop)
449 record("started")
450 record("api-key-present:" + str("DEEPSEEK_API_KEY" in os.environ).lower())
451
452 for raw in sys.stdin:
453 message = json.loads(raw)
454 method = message.get("method")
455 request_id = message.get("id")
456 if method == "initialize":
457 result = {
458 "protocolVersion": "2024-11-05",
459 "capabilities": {"tools": {}},
460 "serverInfo": {"name": "plugin-acceptance", "version": "1.0.0"},
461 }
462 elif method == "tools/list":
463 record("tools:list")
464 result = {"tools": [{
465 "name": "echo",
466 "description": "Hermetic plugin echo",
467 "inputSchema": {
468 "type": "object",
469 "properties": {
470 "text": {"type": "string"},
471 "hang": {"type": "boolean"},
472 },
473 },
474 }]}
475 elif method == "tools/call":
476 args = message.get("params", {}).get("arguments", {})
477 if args.get("hang"):
478 record("call:hang")
479 while True:
480 time.sleep(0.05)
481 record("call:echo")
482 result = {"content": [{
483 "type": "text",
484 "text": "plugin-echo:" + str(args.get("text", "")),
485 }]}
486 else:
487 if request_id is None:
488 continue
489 result = {}
490 sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": request_id, "result": result}) + "\n")
491 sys.stdout.flush()
492 "#,
493 )
494 .expect("stdio MCP fixture");
495 std::fs::set_permissions(&server, std::fs::Permissions::from_mode(0o755))
496 .expect("executable MCP fixture");
497 bundle
498 }
499
500 #[cfg(all(unix, feature = "long-running-tests"))]
501 fn sse_line(value: serde_json::Value) -> String {
502 format!(
503 "data: {}\n\n",
504 serde_json::to_string(&value).expect("SSE JSON")
505 )
506 }
507
508 #[cfg(all(unix, feature = "long-running-tests"))]
509 fn text_sse(text: &str) -> String {
510 [
511 sse_line(serde_json::json!({
512 "id": "chatcmpl-plugin-acceptance",
513 "object": "chat.completion.chunk",
514 "model": "deepseek-v4-pro",
515 "choices": [{"index": 0, "delta": {"content": text}, "finish_reason": null}]
516 })),
517 sse_line(serde_json::json!({
518 "id": "chatcmpl-plugin-acceptance",
519 "object": "chat.completion.chunk",
520 "model": "deepseek-v4-pro",
521 "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
522 "usage": {"prompt_tokens": 8, "completion_tokens": 4, "total_tokens": 12}
523 })),
524 "data: [DONE]\n\n".to_string(),
525 ]
526 .join("")
527 }
528
529 #[cfg(all(unix, feature = "long-running-tests"))]
530 fn tool_call_sse(hang: bool) -> String {
531 let call_id = if hang {
532 "call_plugin_hang"
533 } else {
534 "call_plugin_echo"
535 };
536 let arguments = serde_json::to_string(&serde_json::json!({
537 "text": "acceptance",
538 "hang": hang,
539 }))
540 .expect("tool args");
541 [
542 sse_line(serde_json::json!({
543 "id": "chatcmpl-plugin-tool",
544 "object": "chat.completion.chunk",
545 "model": "deepseek-v4-pro",
546 "choices": [{
547 "index": 0,
548 "delta": {"tool_calls": [{
549 "index": 0,
550 "id": call_id,
551 "type": "function",
552 "function": {
553 "name": "mcp_plugin-4-demo-local_echo",
554 "arguments": arguments
555 }
556 }]},
557 "finish_reason": null
558 }]
559 })),
560 sse_line(serde_json::json!({
561 "id": "chatcmpl-plugin-tool",
562 "object": "chat.completion.chunk",
563 "model": "deepseek-v4-pro",
564 "choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}],
565 "usage": {"prompt_tokens": 10, "completion_tokens": 6, "total_tokens": 16}
566 })),
567 "data: [DONE]\n\n".to_string(),
568 ]
569 .join("")
570 }
571
572 #[cfg(all(unix, feature = "long-running-tests"))]
573 fn spawn_hermetic_model_server() -> (
574 String,
575 std::sync::mpsc::Sender<()>,
576 std::thread::JoinHandle<()>,
577 ) {
578 use tiny_http::{Header, Method, Response, Server};
579
580 let server = Server::http("127.0.0.1:0").expect("loopback model server");
581 let base_url = format!(
582 "http://{}/v1",
583 server.server_addr().to_ip().expect("loopback address")
584 );
585 let (shutdown_tx, shutdown_rx) = std::sync::mpsc::channel();
586 let handle = std::thread::spawn(move || {
587 loop {
588 let request = match server.recv_timeout(std::time::Duration::from_millis(100)) {
589 Ok(Some(request)) => request,
590 Ok(None) => {
591 if shutdown_rx.try_recv().is_ok() {
592 break;
593 }
594 continue;
595 }
596 Err(_) => break,
597 };
598 let mut request = request;
599 let url = request.url().to_string();
600 if request.method() == &Method::Get && url.ends_with("/models") {
601 let response = Response::from_string(
602 r#"{"object":"list","data":[{"id":"deepseek-v4-pro","object":"model"}]}"#,
603 )
604 .with_header(
605 Header::from_bytes("content-type", "application/json").expect("JSON header"),
606 );
607 let _ = request.respond(response);
608 continue;
609 }
610 let mut body = String::new();
611 let _ = request.as_reader().read_to_string(&mut body);
612 let current_user = serde_json::from_str::<serde_json::Value>(&body)
613 .ok()
614 .and_then(|request| request.get("messages")?.as_array().cloned())
615 .and_then(|messages| {
616 messages.into_iter().rev().find_map(|message| {
617 (message.get("role")?.as_str()? == "user")
618 .then(|| message.get("content")?.as_str().map(str::to_owned))?
619 })
620 })
621 .unwrap_or_default();
622 let stream = if current_user.contains("hang plugin call") {
623 tool_call_sse(true)
624 } else if body.contains("plugin-echo:acceptance") {
625 text_sse("binary plugin call complete")
626 } else if body.contains("call plugin echo") {
627 tool_call_sse(false)
628 } else {
629 text_sse("binary fixture acknowledged")
630 };
631 let response = Response::from_string(stream).with_header(
632 Header::from_bytes("content-type", "text/event-stream").expect("SSE header"),
633 );
634 let _ = request.respond(response);
635 }
636 });
637 (base_url, shutdown_tx, handle)
638 }
639
640 #[cfg(all(unix, feature = "long-running-tests"))]
641 fn submit_tui_command(tui: &mut Harness, text: &str) {
642 tui.send(keys::key::text(text)).expect("type TUI command");
643 tui.wait_for_text(text, std::time::Duration::from_secs(3))
644 .expect("typed command visible");
645 std::thread::sleep(std::time::Duration::from_millis(180));
646 tui.pump();
647 tui.send(keys::key::enter()).expect("submit TUI command");
648 }
649
650 #[cfg(all(unix, feature = "long-running-tests"))]
651 fn visible_review_confirmation(tui: &mut Harness) -> Option<String> {
652 tui.pump();
653 review_confirmation_in_text(&tui.frame().text())
654 }
655
656 #[cfg(all(unix, feature = "long-running-tests"))]
657 fn review_confirmation_in_text(text: &str) -> Option<String> {
658 text.lines().map(str::trim).find_map(|line| {
659 let token = line.strip_prefix("/plugin trust demo ")?;
660 (token.contains('.')
661 && token.len() >= 17
662 && token.chars().all(|ch| ch.is_ascii_hexdigit() || ch == '.'))
663 .then(|| line.to_string())
664 })
665 }
666
667 #[cfg(all(unix, feature = "long-running-tests"))]
668 fn wait_for_log(tui: &mut Harness, path: &std::path::Path, needle: &str) {
669 let deadline = std::time::Instant::now() + BINARY_ACCEPTANCE_TIMEOUT;
670 loop {
671 tui.pump();
672 if std::fs::read_to_string(path).is_ok_and(|body| body.contains(needle)) {
673 return;
674 }
675 assert!(
676 std::time::Instant::now() < deadline,
677 "plugin MCP log did not contain {needle:?}\n{}",
678 tui.debug_dump()
679 );
680 std::thread::sleep(std::time::Duration::from_millis(40));
681 }
682 }
683
684 /// Exercise the distributed binary through a real PTY and a sealed home. The
685 /// only socket is a test-owned loopback model endpoint; plugin execution is
686 /// stdio-only and receives no real credentials or ambient secret environment.
687 #[cfg(all(unix, feature = "long-running-tests"))]
688 #[tokio::test(flavor = "current_thread")]
689 async fn plugin_toml_binary_lifecycle_skill_and_stdio_mcp_acceptance() {
690 static ACCEPTANCE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
691 let _serial = ACCEPTANCE_LOCK
692 .lock()
693 .unwrap_or_else(|lock| lock.into_inner());
694 let workspace = make_sealed_workspace().expect("sealed workspace");
695 let bundle = write_reviewed_bundle_fixture(workspace.workspace());
696 let mcp_log = workspace.home().join(".codewhale/plugin-acceptance.log");
697 let (base_url, shutdown_tx, model_thread) = spawn_hermetic_model_server();
698 let mut tui = Harness::builder(Harness::cargo_bin("codewhale-tui"))
699 .cwd(workspace.workspace())
700 .clear_env()
701 .seal_home(workspace.home())
702 .env("DEEPSEEK_API_KEY", "sealed-plugin-acceptance-key")
703 .env("DEEPSEEK_BASE_URL", &base_url)
704 .env("DEEPSEEK_MODEL", "deepseek-v4-pro")
705 .env("PLUGIN_ACCEPTANCE_LOG", mcp_log.to_string_lossy())
706 .env("NO_ANIMATIONS", "1")
707 .env("RUST_LOG", "warn")
708 .args([
709 "--workspace",
710 workspace.workspace().to_str().expect("workspace UTF-8"),
711 "--no-project-config",
712 "--skip-onboarding",
713 "--fresh",
714 ])
715 .size(52, 200)
716 .spawn()
717 .expect("start distributed TUI binary");
718 tui.wait_for_text("Write a task", BINARY_ACCEPTANCE_TIMEOUT)
719 .expect("TUI composer");
720
721 submit_tui_command(&mut tui, "/plugin show demo");
722 tui.wait_for_text("Qualified skills: [demo:review]", BINARY_ACCEPTANCE_TIMEOUT)
723 .expect("show reviewed Skill inventory");
724 assert!(
725 !workspace
726 .home()
727 .join(".codewhale/plugins/state.json")
728 .exists(),
729 "show must remain read-only"
730 );
731
732 submit_tui_command(&mut tui, "/plugin trust demo");
733 tui.wait_for(
734 |frame| review_confirmation_in_text(&frame.text()).is_some(),
735 BINARY_ACCEPTANCE_TIMEOUT,
736 )
737 .expect("review confirmation");
738 let confirmation = visible_review_confirmation(&mut tui)
739 .unwrap_or_else(|| panic!("review confirmation not visible\n{}", tui.debug_dump()));
740 submit_tui_command(&mut tui, &confirmation);
741 tui.wait_for_text("Plugin bundle 'demo': trusted.", BINARY_ACCEPTANCE_TIMEOUT)
742 .expect("trust receipt");
743
744 submit_tui_command(&mut tui, "/plugin enable demo");
745 tui.wait_for_text("Plugin bundle 'demo': enabled.", BINARY_ACCEPTANCE_TIMEOUT)
746 .expect("bundle enabled");
747 submit_tui_command(&mut tui, "$demo:review");
748 tui.wait_for_text("Activated skill: demo:review", BINARY_ACCEPTANCE_TIMEOUT)
749 .expect("reviewed Skill dispatch");
750
751 submit_tui_command(&mut tui, "call plugin echo");
752 tui.wait_for_text("Do you want to proceed?", BINARY_ACCEPTANCE_TIMEOUT)
753 .expect("MCP approval prompt");
754 tui.send(keys::key::ch('2'))
755 .expect("approve this reviewed MCP kind for the sealed session");
756 wait_for_log(&mut tui, &mcp_log, "started");
757 wait_for_log(&mut tui, &mcp_log, "api-key-present:false");
758 wait_for_log(&mut tui, &mcp_log, "tools:list");
759 wait_for_log(&mut tui, &mcp_log, "call:echo");
760 tui.wait_for_text("binary plugin call complete", BINARY_ACCEPTANCE_TIMEOUT)
761 .expect("plugin tool result returned to model");
762
763 submit_tui_command(&mut tui, "hang plugin call");
764 wait_for_log(&mut tui, &mcp_log, "call:hang");
765 tui.send([0x03]).expect("interrupt hanging plugin turn");
766 std::thread::sleep(std::time::Duration::from_millis(300));
767 tui.pump();
768 tui.send([0x15])
769 .expect("clear the interrupted prompt restored into the composer");
770 submit_tui_command(&mut tui, "/plugin revoke demo");
771 tui.wait_for_text(
772 "Plugin bundle 'demo': trust-revoked.",
773 BINARY_ACCEPTANCE_TIMEOUT,
774 )
775 .expect("bundle trust revoked");
776 wait_for_log(&mut tui, &mcp_log, "signal:");
777
778 let state = std::fs::read_to_string(workspace.home().join(".codewhale/plugins/state.json"))
779 .expect("durable plugin state");
780 assert!(state.contains("\"enabled\": true"));
781 assert!(state.contains("\"trust\": null"));
782 assert!(bundle.join("server.py").exists(), "source bundle preserved");
783
784 let _ = tui.shutdown();
785 let _ = shutdown_tx.send(());
786 let _ = model_thread.join();
787 }
788
789 // ---------------------------------------------------------------------------
790 // Scenario runners
791 // ---------------------------------------------------------------------------
792
793 #[tokio::test(flavor = "current_thread")]
794 async fn plugin_discovery_happy_path() {
795 run_scenario(DISCOVERY_SCENARIO, 9).await;
796 }
797
798 #[tokio::test(flavor = "current_thread")]
799 async fn plugin_discovery_empty_directory() {
800 run_scenario(EMPTY_SCENARIO, 4).await;
801 }
802
803 #[tokio::test(flavor = "current_thread")]
804 async fn plugin_discovery_missing_directory() {
805 run_scenario(MISSING_SCENARIO, 4).await;
806 }
807
808 async fn run_scenario(name: &'static str, expected_steps: usize) {
809 let writer = PluginE2EWorld::cucumber()
810 .fail_on_skipped()
811 .with_default_cli()
812 .filter_run(FEATURE_PATH, move |feature, _, scenario| {
813 feature.name == FEATURE_NAME && scenario.name == name
814 })
815 .await;
816 assert_eq!(writer.failed_steps(), 0, "scenario failed: {name}");
817 assert_eq!(writer.skipped_steps(), 0, "scenario skipped steps: {name}");
818 assert_eq!(
819 writer.passed_steps(),
820 expected_steps,
821 "scenario did not run: {name}"
822 );
823 }
824
825 // ---------------------------------------------------------------------------
826 // Helpers
827 // ---------------------------------------------------------------------------
828
829 fn codewhale_tui_binary() -> PathBuf {
830 if let Some(path) = option_env!("CARGO_BIN_EXE_codewhale-tui") {
831 return PathBuf::from(path);
832 }
833 if let Ok(path) = std::env::var("CARGO_BIN_EXE_codewhale-tui") {
834 return PathBuf::from(path);
835 }
836
837 let mut path = std::env::current_exe().expect("current test executable path");
838 path.pop();
839 if path.ends_with("deps") {
840 path.pop();
841 }
842 path.push(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
843 path
844 }
845
845 lines RUST