返回 CodeWhale
plugin_e2e_acceptance.rs
根目录 / crates / tui / tests / cucumber / 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 use super::qa_harness;
20
21 #[cfg(all(unix, feature = "long-running-tests"))]
22 use qa_harness::harness::{Harness, make_sealed_workspace};
23 #[cfg(all(unix, feature = "long-running-tests"))]
24 use qa_harness::keys;
25
26 const FEATURE_NAME: &str = "Plugin discovery and listing";
27 const FEATURE_PATH: &str = concat!(
28 env!("CARGO_MANIFEST_DIR"),
29 "/tests/features/plugin_e2e_acceptance.feature"
30 );
31 const DISCOVERY_SCENARIO: &str =
32 "Plugin scripts are discovered from the configured plugin directory";
33 const EMPTY_SCENARIO: &str = "Empty plugin directory reports no plugins";
34 const MISSING_SCENARIO: &str = "Missing plugin directory reports the path";
35
36 // ---------------------------------------------------------------------------
37 // Test-local plugin scanner
38 //
39 // Mirrors the real `scan_plugin_dir` from `crates/tui/src/tools/plugin.rs`
40 // so the test can run as a standalone integration test without relying on
41 // `#[path]` (which breaks on internal `crate::` and `super::` imports).
42 // The contract (frontmatter format, skip rules) matches exactly.
43 // ---------------------------------------------------------------------------
44
45 #[derive(Debug, Clone, PartialEq)]
46 struct TestPluginMeta {
47 name: String,
48 description: String,
49 approval: TestApproval,
50 }
51
52 #[derive(Debug, Clone, Copy, PartialEq)]
53 enum TestApproval {
54 Auto,
55 Suggest,
56 Required,
57 }
58
59 fn parse_frontmatter(content: &str) -> Option<TestPluginMeta> {
60 let mut name = String::new();
61 let mut description = String::new();
62 let mut approval_str = String::new();
63
64 for line in content.lines().take(20) {
65 let line = line.trim();
66 let rest = line
67 .strip_prefix('#')
68 .or_else(|| line.strip_prefix("//"))
69 .or_else(|| line.strip_prefix("--"));
70 let Some(rest) = rest else { continue };
71 let Some((key, value)) = rest.trim_start().split_once(':') else {
72 continue;
73 };
74 match key.trim().to_lowercase().as_str() {
75 "name" => name = value.trim().to_string(),
76 "description" => description = value.trim().to_string(),
77 "approval" => approval_str = value.trim().to_string(),
78 _ => {}
79 }
80 }
81
82 if name.is_empty() {
83 return None;
84 }
85
86 let approval = match approval_str.to_lowercase().as_str() {
87 "auto" => TestApproval::Auto,
88 "required" => TestApproval::Required,
89 _ => TestApproval::Suggest,
90 };
91
92 Some(TestPluginMeta {
93 name,
94 description: if description.is_empty() {
95 "User-provided plugin tool".to_string()
96 } else {
97 description
98 },
99 approval,
100 })
101 }
102
103 fn scan_plugin_dir(dir: &std::path::Path) -> Vec<(PathBuf, TestPluginMeta)> {
104 let mut results = Vec::new();
105
106 let entries = match std::fs::read_dir(dir) {
107 Ok(entries) => entries,
108 Err(_) => return results,
109 };
110
111 let mut entries: Vec<_> = entries.flatten().collect();
112 entries.sort_by_key(|entry| entry.file_name());
113
114 for entry in entries {
115 let path = entry.path();
116
117 if path.is_dir() {
118 continue;
119 }
120
121 if let Some(name) = path.file_name().and_then(|n| n.to_str())
122 && (name.starts_with('.') || name == "README.md")
123 {
124 continue;
125 }
126
127 if let Ok(content) = std::fs::read_to_string(&path)
128 && let Some(meta) = parse_frontmatter(&content)
129 {
130 results.push((path, meta));
131 }
132 }
133
134 results
135 }
136
137 // ---------------------------------------------------------------------------
138 // Cucumber world
139 // ---------------------------------------------------------------------------
140
141 #[derive(Debug, Default, cucumber::World)]
142 struct PluginE2EWorld {
143 /// TempDir holding the plugin directory. We keep a second TempDir as
144 /// the "workspace" so the plugin dir path stays valid after move.
145 _workspace: Option<TempDir>,
146 plugin_dir: Option<TempDir>,
147 discovered: Option<Vec<(PathBuf, TestPluginMeta)>>,
148 scanner_message: Option<String>,
149 }
150
151 // ---------------------------------------------------------------------------
152 // Given steps
153 // ---------------------------------------------------------------------------
154
155 #[given("an offline CodeWhale workspace with a configured plugin directory")]
156 fn offline_workspace_with_plugin_dir(world: &mut PluginE2EWorld) {
157 let workspace = TempDir::new().expect("workspace tempdir");
158 let plugin_dir = TempDir::new().expect("plugin tempdir");
159 world._workspace = Some(workspace);
160 world.plugin_dir = Some(plugin_dir);
161 }
162
163 #[given(regex = r"^the plugin directory contains:$")]
164 fn plugin_directory_contains(world: &mut PluginE2EWorld, step: &cucumber::gherkin::Step) {
165 let dir = world
166 .plugin_dir
167 .as_ref()
168 .expect("plugin directory should be configured");
169
170 let table = step
171 .table
172 .as_ref()
173 .expect("step should include a data table");
174 let mut rows = table.rows.iter();
175 let headers = rows.next().expect("data table should include a header");
176 let name_idx = headers
177 .iter()
178 .position(|h| h == "name")
179 .expect("data table should have a 'name' column");
180 let desc_idx = headers
181 .iter()
182 .position(|h| h == "description")
183 .expect("data table should have a 'description' column");
184 let approval_idx = headers
185 .iter()
186 .position(|h| h == "approval")
187 .expect("data table should have an 'approval' column");
188
189 for row in rows {
190 let name = row.get(name_idx).expect("plugin name");
191 let description = row.get(desc_idx).expect("plugin description");
192 let approval = row.get(approval_idx).expect("plugin approval");
193
194 let script_path = dir.path().join(format!("{name}.sh"));
195 let script_content = format!(
196 "# name: {name}\n\
197 # description: {description}\n\
198 # approval: {approval}\n\
199 # schema: {{\"type\":\"object\"}}\n\
200 echo hello\n"
201 );
202 std::fs::write(&script_path, &script_content)
203 .unwrap_or_else(|e| panic!("write plugin script {name}.sh: {e}"));
204
205 // Make executable on Unix
206 #[cfg(unix)]
207 {
208 use std::os::unix::fs::PermissionsExt;
209 std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))
210 .unwrap_or_else(|e| panic!("chmod {name}.sh: {e}"));
211 }
212 }
213
214 // Write a README.md and a hidden file that should be ignored
215 std::fs::write(dir.path().join("README.md"), "# Plugin Docs\n").expect("write README.md");
216 std::fs::write(
217 dir.path().join(".hidden_script.sh"),
218 "# name: hidden\n# description: Should not appear\n",
219 )
220 .expect("write hidden");
221 }
222
223 #[given("the plugin directory is empty")]
224 fn plugin_directory_empty(world: &mut PluginE2EWorld) {
225 // Replace with a fresh empty directory
226 let dir = TempDir::new().expect("empty plugin tempdir");
227 world.plugin_dir = Some(dir);
228 }
229
230 #[given("the plugin directory does not exist")]
231 fn plugin_directory_does_not_exist(world: &mut PluginE2EWorld) {
232 let base = TempDir::new().expect("base tempdir for non-existent path");
233 let non_existent = base.path().join("nonexistent");
234 // Ensure it truly doesn't exist
235 let _ = std::fs::remove_dir_all(&non_existent);
236 // Store the base so the path stays valid for the lifetime of the test
237 world._workspace = Some(base);
238 // Remove the previous plugin_dir so scanning uses the path deliberately
239 world.plugin_dir = None;
240 world.scanner_message = Some(format!(
241 "No plugin directory found at {}",
242 non_existent.display()
243 ));
244 }
245
246 // ---------------------------------------------------------------------------
247 // When steps
248 // ---------------------------------------------------------------------------
249
250 #[when("the plugin scanner discovers plugins")]
251 fn plugin_scanner_discovers_plugins(world: &mut PluginE2EWorld) {
252 let dir = world
253 .plugin_dir
254 .as_ref()
255 .expect("plugin directory should be configured");
256 let discovered = scan_plugin_dir(dir.path());
257 world.discovered = Some(discovered);
258 }
259
260 #[when("the plugin scanner runs")]
261 fn plugin_scanner_runs(world: &mut PluginE2EWorld) {
262 // Use the stored non-existent path
263 let msg = world
264 .scanner_message
265 .as_ref()
266 .expect("missing path message");
267 // Extract the path from the message
268 let path_str = msg
269 .strip_prefix("No plugin directory found at ")
270 .expect("message format");
271 let path = std::path::Path::new(path_str);
272 let discovered = scan_plugin_dir(path);
273 world.discovered = Some(discovered);
274 }
275
276 // ---------------------------------------------------------------------------
277 // Then steps
278 // ---------------------------------------------------------------------------
279
280 #[then(regex = r"^the scanner should report (\d+) plugins?$")]
281 fn scanner_should_report_n_plugins(world: &mut PluginE2EWorld, expected_count: usize) {
282 let discovered = world.discovered.as_ref().expect("scanner should have run");
283 assert_eq!(
284 discovered.len(),
285 expected_count,
286 "expected {expected_count} plugins, found {}: {discovered:#?}",
287 discovered.len()
288 );
289 }
290
291 #[then(regex = r#"^the scanned plugin "([^"]+)" should have "([^"]+)" as description$"#)]
292 fn scanned_plugin_should_have_description(
293 world: &mut PluginE2EWorld,
294 name: String,
295 expected_description: String,
296 ) {
297 let discovered = world.discovered.as_ref().expect("scanner should have run");
298 let meta = discovered
299 .iter()
300 .find(|(_, m)| m.name == name)
301 .map(|(_, m)| m)
302 .unwrap_or_else(|| panic!("plugin \"{name}\" not found in scan results"));
303
304 assert_eq!(
305 meta.description, expected_description,
306 "plugin \"{name}\" description mismatch"
307 );
308 }
309
310 #[then(regex = r#"^the scanned plugin "([^"]+)" should have "([^"]+)" as approval$"#)]
311 fn scanned_plugin_should_have_approval(
312 world: &mut PluginE2EWorld,
313 name: String,
314 expected_approval: String,
315 ) {
316 let discovered = world.discovered.as_ref().expect("scanner should have run");
317 let meta = discovered
318 .iter()
319 .find(|(_, m)| m.name == name)
320 .map(|(_, m)| m)
321 .unwrap_or_else(|| panic!("plugin \"{name}\" not found in scan results"));
322
323 let actual = match meta.approval {
324 TestApproval::Auto => "auto",
325 TestApproval::Suggest => "suggest",
326 TestApproval::Required => "required",
327 };
328 assert_eq!(
329 actual, expected_approval,
330 "plugin \"{name}\" approval mismatch"
331 );
332 }
333
334 #[then(regex = r#"^the scanned plugin "([^"]+)" should not be found$"#)]
335 fn scanned_plugin_should_not_be_found(world: &mut PluginE2EWorld, name: String) {
336 let discovered = world.discovered.as_ref().expect("scanner should have run");
337 assert!(
338 !discovered.iter().any(|(_, m)| m.name == name),
339 "plugin \"{name}\" should not be present in scan results, but was found"
340 );
341 }
342
343 #[then("the scanner should report the missing directory path")]
344 fn scanner_should_report_missing_path(world: &mut PluginE2EWorld) {
345 let discovered = world.discovered.as_ref().expect("scanner should have run");
346 assert!(
347 discovered.is_empty(),
348 "expected empty results for missing directory, got: {discovered:#?}"
349 );
350 let msg = world
351 .scanner_message
352 .as_deref()
353 .unwrap_or("scanner ran without message");
354 assert!(
355 msg.contains("No plugin directory found"),
356 "expected missing directory message, got: {msg}"
357 );
358 }
359
360 // ---------------------------------------------------------------------------
361 // Binary smoke test
362 // ---------------------------------------------------------------------------
363
364 /// Prove the binary still loads after the plugin module extraction.
365 #[tokio::test(flavor = "current_thread")]
366 async fn plugin_module_does_not_break_binary_load() {
367 let output = Command::new(codewhale_tui_binary())
368 .arg("--version")
369 .output()
370 .expect("codewhale-tui --version should start");
371
372 assert!(
373 output.status.success(),
374 "codewhale-tui --version failed\nstderr:\n{}",
375 String::from_utf8_lossy(&output.stderr)
376 );
377
378 let version = String::from_utf8_lossy(&output.stdout);
379 assert!(
380 version.contains("codewhale"),
381 "version output should mention codewhale, got: {version}"
382 );
383 }
384
385 // ---------------------------------------------------------------------------
386 // Real plugin.toml binary/TUI acceptance
387 // ---------------------------------------------------------------------------
388
389 #[cfg(all(unix, feature = "long-running-tests"))]
390 const BINARY_ACCEPTANCE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
391
392 #[cfg(all(unix, feature = "long-running-tests"))]
393 fn write_reviewed_bundle_fixture(workspace: &std::path::Path) -> PathBuf {
394 use std::os::unix::fs::PermissionsExt as _;
395
396 let bundle = workspace.join(".codewhale/plugins/demo");
397 std::fs::create_dir_all(bundle.join("skills/review")).expect("plugin fixture directories");
398 std::fs::write(
399 bundle.join("plugin.toml"),
400 r#"schema_version = 1
401 [plugin]
402 name = "demo"
403 version = "1.0.0"
404 description = "Hermetic binary acceptance fixture"
405
406 [skills]
407 path = "skills"
408
409 [mcp_servers.local]
410 command = "./server.py"
411 connect_timeout = 5
412 execute_timeout = 30
413 read_timeout = 30
414
415 [mcp_servers.local.env]
416 ACCEPTANCE_LOG = "${PLUGIN_ACCEPTANCE_LOG}"
417 "#,
418 )
419 .expect("plugin manifest");
420 std::fs::write(
421 bundle.join("skills/review/SKILL.md"),
422 "---\nname: review\ndescription: reviewed binary acceptance Skill\n---\n\nUse the reviewed fixture.\n",
423 )
424 .expect("plugin Skill");
425 let server = bundle.join("server.py");
426 std::fs::write(
427 &server,
428 r#"#!/usr/bin/env python3
429 import json
430 import os
431 import signal
432 import sys
433 import time
434
435 log_path = os.environ["ACCEPTANCE_LOG"]
436
437 def record(event):
438 with open(log_path, "a", encoding="utf-8") as handle:
439 handle.write(event + "\n")
440 handle.flush()
441
442 def stop(signum, _frame):
443 record("signal:" + str(signum))
444 raise SystemExit(0)
445
446 signal.signal(signal.SIGTERM, stop)
447 signal.signal(signal.SIGINT, stop)
448 record("started")
449 record("api-key-present:" + str("DEEPSEEK_API_KEY" in os.environ).lower())
450
451 for raw in sys.stdin:
452 message = json.loads(raw)
453 method = message.get("method")
454 request_id = message.get("id")
455 if method == "initialize":
456 result = {
457 "protocolVersion": "2024-11-05",
458 "capabilities": {"tools": {}},
459 "serverInfo": {"name": "plugin-acceptance", "version": "1.0.0"},
460 }
461 elif method == "tools/list":
462 record("tools:list")
463 result = {"tools": [{
464 "name": "echo",
465 "description": "Hermetic plugin echo",
466 "inputSchema": {
467 "type": "object",
468 "properties": {
469 "text": {"type": "string"},
470 "hang": {"type": "boolean"},
471 },
472 },
473 }]}
474 elif method == "tools/call":
475 args = message.get("params", {}).get("arguments", {})
476 if args.get("hang"):
477 record("call:hang")
478 while True:
479 time.sleep(0.05)
480 record("call:echo")
481 result = {"content": [{
482 "type": "text",
483 "text": "plugin-echo:" + str(args.get("text", "")),
484 }]}
485 else:
486 if request_id is None:
487 continue
488 result = {}
489 sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": request_id, "result": result}) + "\n")
490 sys.stdout.flush()
491 "#,
492 )
493 .expect("stdio MCP fixture");
494 std::fs::set_permissions(&server, std::fs::Permissions::from_mode(0o755))
495 .expect("executable MCP fixture");
496 bundle
497 }
498
499 #[cfg(unix)]
500 fn sse_line(value: serde_json::Value) -> String {
501 format!(
502 "data: {}\n\n",
503 serde_json::to_string(&value).expect("SSE JSON")
504 )
505 }
506
507 #[cfg(unix)]
508 fn text_sse(text: &str) -> String {
509 [
510 sse_line(serde_json::json!({
511 "id": "chatcmpl-plugin-acceptance",
512 "object": "chat.completion.chunk",
513 "model": "deepseek-v4-pro",
514 "choices": [{"index": 0, "delta": {"content": text}, "finish_reason": null}]
515 })),
516 sse_line(serde_json::json!({
517 "id": "chatcmpl-plugin-acceptance",
518 "object": "chat.completion.chunk",
519 "model": "deepseek-v4-pro",
520 "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
521 "usage": {"prompt_tokens": 8, "completion_tokens": 4, "total_tokens": 12}
522 })),
523 "data: [DONE]\n\n".to_string(),
524 ]
525 .join("")
526 }
527
528 #[cfg(unix)]
529 fn tool_call_sse(hang: bool) -> String {
530 let call_id = if hang {
531 "call_plugin_hang"
532 } else {
533 "call_plugin_echo"
534 };
535 let arguments = serde_json::to_string(&serde_json::json!({
536 "text": "acceptance",
537 "hang": hang,
538 }))
539 .expect("tool args");
540 [
541 sse_line(serde_json::json!({
542 "id": "chatcmpl-plugin-tool",
543 "object": "chat.completion.chunk",
544 "model": "deepseek-v4-pro",
545 "choices": [{
546 "index": 0,
547 "delta": {"tool_calls": [{
548 "index": 0,
549 "id": call_id,
550 "type": "function",
551 "function": {
552 "name": "mcp_plugin-4-demo-local_echo",
553 "arguments": arguments
554 }
555 }]},
556 "finish_reason": null
557 }]
558 })),
559 sse_line(serde_json::json!({
560 "id": "chatcmpl-plugin-tool",
561 "object": "chat.completion.chunk",
562 "model": "deepseek-v4-pro",
563 "choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}],
564 "usage": {"prompt_tokens": 10, "completion_tokens": 6, "total_tokens": 16}
565 })),
566 "data: [DONE]\n\n".to_string(),
567 ]
568 .join("")
569 }
570
571 /// Read exactly `Content-Length` bytes. `read_to_string` waits for EOF, so an
572 /// HTTP/1.1 keep-alive client (reqwest on macOS CI) never finishes the body
573 /// and this single-threaded fixture never answers.
574 #[cfg(unix)]
575 fn read_limited_http_body(
576 reader: &mut (impl std::io::Read + ?Sized),
577 content_length: Option<usize>,
578 ) -> String {
579 const MAX: usize = 64 * 1024;
580 let limit = content_length.unwrap_or(0).min(MAX);
581 if limit == 0 {
582 return String::new();
583 }
584 let mut buf = vec![0u8; limit];
585 let mut filled = 0usize;
586 while filled < buf.len() {
587 match reader.read(&mut buf[filled..]) {
588 Ok(0) => break,
589 Ok(n) => filled += n,
590 Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
591 Err(_) => break,
592 }
593 }
594 String::from_utf8_lossy(&buf[..filled]).into_owned()
595 }
596
597 #[cfg(unix)]
598 fn spawn_hermetic_model_server() -> (
599 String,
600 std::sync::mpsc::Sender<()>,
601 std::thread::JoinHandle<()>,
602 ) {
603 use tiny_http::{Header, Method, Response, Server};
604
605 let server = Server::http("127.0.0.1:0").expect("loopback model server");
606 let base_url = format!(
607 "http://{}/v1",
608 server.server_addr().to_ip().expect("loopback address")
609 );
610 let (shutdown_tx, shutdown_rx) = std::sync::mpsc::channel();
611 let handle = std::thread::spawn(move || {
612 loop {
613 let request = match server.recv_timeout(std::time::Duration::from_millis(100)) {
614 Ok(Some(request)) => request,
615 Ok(None) => {
616 if shutdown_rx.try_recv().is_ok() {
617 break;
618 }
619 continue;
620 }
621 Err(_) => break,
622 };
623 let mut request = request;
624 let url = request.url().to_string();
625 if request.method() == &Method::Get && url.ends_with("/models") {
626 let response = Response::from_string(
627 r#"{"object":"list","data":[{"id":"deepseek-v4-pro","object":"model"}]}"#,
628 )
629 .with_header(
630 Header::from_bytes("content-type", "application/json").expect("JSON header"),
631 )
632 .with_header(Header::from_bytes("connection", "close").expect("close header"));
633 let _ = request.respond(response);
634 continue;
635 }
636 let content_length = request.body_length();
637 let body = read_limited_http_body(request.as_reader(), content_length);
638 let current_user = serde_json::from_str::<serde_json::Value>(&body)
639 .ok()
640 .and_then(|request| request.get("messages")?.as_array().cloned())
641 .and_then(|messages| {
642 messages.into_iter().rev().find_map(|message| {
643 (message.get("role")?.as_str()? == "user")
644 .then(|| message.get("content")?.as_str().map(str::to_owned))?
645 })
646 })
647 .unwrap_or_default();
648 let stream = if current_user.contains("hang plugin call") {
649 tool_call_sse(true)
650 } else if body.contains("plugin-echo:acceptance") {
651 text_sse("binary plugin call complete")
652 } else if body.contains("call plugin echo") {
653 tool_call_sse(false)
654 } else {
655 text_sse("binary fixture acknowledged")
656 };
657 let response = Response::from_string(stream)
658 .with_header(
659 Header::from_bytes("content-type", "text/event-stream").expect("SSE header"),
660 )
661 .with_header(Header::from_bytes("connection", "close").expect("close header"));
662 let _ = request.respond(response);
663 }
664 });
665 (base_url, shutdown_tx, handle)
666 }
667
668 #[cfg(all(unix, feature = "long-running-tests"))]
669 fn submit_tui_command(tui: &mut Harness, text: &str) {
670 tui.send(keys::key::text(text)).expect("type TUI command");
671 if tui
672 .wait_for_text(text, std::time::Duration::from_secs(3))
673 .is_err()
674 {
675 panic!(
676 "typed command not visible: {text:?}\n{}",
677 short_diagnostics(tui, None)
678 );
679 }
680 std::thread::sleep(std::time::Duration::from_millis(180));
681 tui.pump();
682 tui.send(keys::key::enter()).expect("submit TUI command");
683 }
684
685 #[cfg(all(unix, feature = "long-running-tests"))]
686 fn sanitize_diag_line(line: &str) -> String {
687 line.chars()
688 .map(|ch| {
689 if ch.is_control() && ch != '\t' {
690 ' '
691 } else {
692 ch
693 }
694 })
695 .take(120)
696 .collect()
697 }
698
699 #[cfg(all(unix, feature = "long-running-tests"))]
700 fn short_diagnostics(tui: &mut Harness, log_path: Option<&std::path::Path>) -> String {
701 tui.pump();
702 let mut out = String::new();
703 if let Some(pid) = tui.pid() {
704 let alive = std::process::Command::new("kill")
705 .args(["-0", &pid.to_string()])
706 .status()
707 .is_ok_and(|status| status.success());
708 out.push_str(&format!("tui pid={pid} alive={alive}\n"));
709 } else {
710 out.push_str("tui pid=none\n");
711 }
712 if let Some(path) = log_path {
713 match std::fs::read_to_string(path) {
714 Ok(log) => out.push_str(&format!("mcp log ({} bytes):\n{log}\n", log.len())),
715 Err(err) => out.push_str(&format!("mcp log unreadable: {err}\n")),
716 }
717 }
718 let lines: Vec<String> = tui.frame().text().lines().map(str::to_owned).collect();
719 out.push_str("visible head:\n");
720 for (index, line) in lines.iter().take(12).enumerate() {
721 out.push_str(&format!("{index:>3} | {}\n", sanitize_diag_line(line)));
722 }
723 if lines.len() > 12 {
724 out.push_str("visible tail:\n");
725 let start = lines.len().saturating_sub(12);
726 for (index, line) in lines.iter().skip(start).enumerate() {
727 out.push_str(&format!(
728 "{:>3} | {}\n",
729 start + index,
730 sanitize_diag_line(line)
731 ));
732 }
733 }
734 out
735 }
736
737 #[cfg(all(unix, feature = "long-running-tests"))]
738 fn expect_visible(tui: &mut Harness, needle: &str, label: &str) {
739 if tui
740 .wait_for_text(needle, BINARY_ACCEPTANCE_TIMEOUT)
741 .is_err()
742 {
743 panic!(
744 "{label}: {needle:?} not visible within {:?}\n{}",
745 qa_harness::harness::ci_scaled(BINARY_ACCEPTANCE_TIMEOUT),
746 short_diagnostics(tui, None)
747 );
748 }
749 }
750
751 #[cfg(all(unix, feature = "long-running-tests"))]
752 fn wait_for_composer_ready(tui: &mut Harness) {
753 if tui
754 .wait_for(
755 |frame| {
756 let (row, _) = frame.cursor();
757 // Density and user drafts change the composer's height. Its
758 // prompt owns the focused row, not a fixed bottom offset.
759 frame.any_visible_text() && frame.row(row).contains('❯')
760 },
761 BINARY_ACCEPTANCE_TIMEOUT,
762 )
763 .is_err()
764 {
765 panic!(
766 "TUI did not paint a focused composer within {:?}\n{}",
767 qa_harness::harness::ci_scaled(BINARY_ACCEPTANCE_TIMEOUT),
768 short_diagnostics(tui, None)
769 );
770 }
771 }
772
773 /// This acceptance starts as a fresh interactive launch. Select the real
774 /// Startup "New session" action before testing commands that belong to a
775 /// live conversation; a focused pre-session composer is not itself a session.
776 #[cfg(all(unix, feature = "long-running-tests"))]
777 fn begin_new_session_from_startup(tui: &mut Harness) {
778 expect_visible(tui, "New session", "show the launch card");
779 // Typing goes straight to the composer; Enter sends the first message
780 // and the session begins (the card dissolved on the first keystroke).
781 // type_line, not send+enter: a zero-gap PTY write is paste-classified
782 // and the immediate Enter would be absorbed as a pasted newline.
783 tui.type_line("start the session")
784 .expect("type and send the first prompt");
785 if tui
786 .wait_for(
787 |frame| !frame.text().contains('\u{2442}'),
788 BINARY_ACCEPTANCE_TIMEOUT,
789 )
790 .is_err()
791 {
792 panic!(
793 "the first prompt did not enter the live shell within {:?}\n{}",
794 qa_harness::harness::ci_scaled(BINARY_ACCEPTANCE_TIMEOUT),
795 short_diagnostics(tui, None)
796 );
797 }
798 }
799
800 #[cfg(all(unix, feature = "long-running-tests"))]
801 fn wait_for_log(tui: &mut Harness, path: &std::path::Path, needle: &str) {
802 let budget = qa_harness::harness::ci_scaled(BINARY_ACCEPTANCE_TIMEOUT);
803 let deadline = std::time::Instant::now() + budget;
804 loop {
805 tui.pump();
806 if std::fs::read_to_string(path).is_ok_and(|body| body.contains(needle)) {
807 return;
808 }
809 if std::time::Instant::now() >= deadline {
810 panic!(
811 "plugin MCP log did not contain {needle:?} within {budget:?}\n{}",
812 short_diagnostics(tui, Some(path))
813 );
814 }
815 std::thread::sleep(std::time::Duration::from_millis(40));
816 }
817 }
818
819 /// A focused composer and a streamed answer can both appear before the turn
820 /// settles. Use the runtime's terminal receipt, not either paint, to admit the
821 /// next prompt; otherwise this fixture exercises the busy-turn queue by accident.
822 #[cfg(all(unix, feature = "long-running-tests"))]
823 fn wait_for_turn_receipt(tui: &mut Harness, outbox: &std::path::Path, count: usize, kind: &str) {
824 let receipt = || {
825 std::fs::read_to_string(outbox)
826 .unwrap_or_default()
827 .lines()
828 .filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
829 .filter(|event| event["event"] == "turn_end")
830 .collect::<Vec<_>>()
831 };
832 if tui
833 .wait_for(|_| receipt().len() >= count, BINARY_ACCEPTANCE_TIMEOUT)
834 .is_err()
835 {
836 panic!(
837 "terminal turn receipt {count} not observed within {:?}\n{}",
838 qa_harness::harness::ci_scaled(BINARY_ACCEPTANCE_TIMEOUT),
839 short_diagnostics(tui, Some(outbox)),
840 );
841 }
842 let events = receipt();
843 assert_eq!(
844 events.len(),
845 count,
846 "one terminal receipt per submitted turn"
847 );
848 assert_eq!(events[count - 1]["kind"], kind, "terminal turn outcome");
849 }
850
851 /// Exercise the distributed binary through a real PTY and a sealed home. The
852 /// only socket is a test-owned loopback model endpoint; plugin execution is
853 /// stdio-only and receives no real credentials or ambient secret environment.
854 #[cfg(all(unix, feature = "long-running-tests"))]
855 #[tokio::test(flavor = "current_thread")]
856 async fn plugin_toml_binary_lifecycle_skill_and_stdio_mcp_acceptance() {
857 static ACCEPTANCE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
858 let _serial = ACCEPTANCE_LOCK
859 .lock()
860 .unwrap_or_else(|lock| lock.into_inner());
861 let workspace = make_sealed_workspace().expect("sealed workspace");
862 let bundle = write_reviewed_bundle_fixture(workspace.workspace());
863 let mcp_log = workspace.home().join(".codewhale/plugin-acceptance.log");
864 let outbox = workspace.home().join(".codewhale/lifecycle-outbox.jsonl");
865 let config_path = workspace.home().join(".codewhale/config.toml");
866 let mut config = std::fs::read_to_string(&config_path).expect("sealed config");
867 config.push_str(&format!(
868 "\n[lifecycle_outbox]\npath = {}\n",
869 serde_json::json!(outbox.to_string_lossy()),
870 ));
871 std::fs::write(config_path, config).expect("configure sealed lifecycle receipt");
872 let (base_url, shutdown_tx, model_thread) = spawn_hermetic_model_server();
873 let mut tui = Harness::builder(Harness::cargo_bin("codewhale-tui"))
874 .cwd(workspace.workspace())
875 .clear_env()
876 .seal_home(workspace.home())
877 .env("DEEPSEEK_API_KEY", "sealed-plugin-acceptance-key")
878 .env("DEEPSEEK_BASE_URL", &base_url)
879 .env("DEEPSEEK_MODEL", "deepseek-v4-pro")
880 .env("PLUGIN_ACCEPTANCE_LOG", mcp_log.to_string_lossy())
881 .env("CODEWHALE_DISABLE_MODELS_DEV_FETCH", "1")
882 .env("NO_ANIMATIONS", "1")
883 .env("RUST_LOG", "warn")
884 .args([
885 "--workspace",
886 workspace.workspace().to_str().expect("workspace UTF-8"),
887 "--no-project-config",
888 "--skip-onboarding",
889 "--fresh",
890 ])
891 .size(52, 200)
892 .spawn()
893 .expect("start distributed TUI binary");
894
895 // Readiness is a painted, focused composer—not localized placeholder copy.
896 // The binary begins at Tideline Startup, so choose its real New Session
897 // action before exercising the existing-session plugin contract.
898 begin_new_session_from_startup(&mut tui);
899 wait_for_turn_receipt(&mut tui, &outbox, 1, "turn.completed");
900 wait_for_composer_ready(&mut tui);
901 submit_tui_command(&mut tui, "/plugin show demo");
902 expect_visible(
903 &mut tui,
904 "Qualified skills: [demo:review]",
905 "show reviewed Skill inventory",
906 );
907 assert!(
908 !workspace
909 .home()
910 .join(".codewhale/plugins/state.json")
911 .exists(),
912 "show must remain read-only"
913 );
914
915 submit_tui_command(&mut tui, "/plugin trust demo");
916 expect_visible(&mut tui, "Confirm", "token-bound plugin review control");
917 tui.send(keys::key::ch('y')).expect("arm reviewed trust");
918 expect_visible(&mut tui, "y/Enter", "armed review control");
919 tui.send(keys::key::enter())
920 .expect("confirm reviewed trust");
921 expect_visible(&mut tui, "Plugin bundle 'demo': trusted.", "trust receipt");
922
923 submit_tui_command(&mut tui, "/plugin enable demo");
924 expect_visible(&mut tui, "Plugin bundle 'demo': enabled.", "bundle enabled");
925 submit_tui_command(&mut tui, "$demo:review");
926 expect_visible(
927 &mut tui,
928 "Activated skill: demo:review",
929 "reviewed Skill dispatch",
930 );
931
932 submit_tui_command(&mut tui, "call plugin echo");
933 expect_visible(&mut tui, "Do you want to proceed?", "MCP approval prompt");
934 tui.send(keys::key::ch('2'))
935 .expect("approve this reviewed MCP kind for the sealed session");
936 wait_for_log(&mut tui, &mcp_log, "started");
937 wait_for_log(&mut tui, &mcp_log, "api-key-present:false");
938 wait_for_log(&mut tui, &mcp_log, "tools:list");
939 wait_for_log(&mut tui, &mcp_log, "call:echo");
940 expect_visible(
941 &mut tui,
942 "binary plugin call complete",
943 "plugin tool result returned to model",
944 );
945 wait_for_turn_receipt(&mut tui, &outbox, 2, "turn.completed");
946
947 submit_tui_command(&mut tui, "hang plugin call");
948 wait_for_log(&mut tui, &mcp_log, "call:hang");
949 tui.send([0x03]).expect("interrupt hanging plugin turn");
950 wait_for_turn_receipt(&mut tui, &outbox, 3, "turn.interrupted");
951 tui.send([0x15])
952 .expect("clear the interrupted prompt restored into the composer");
953 submit_tui_command(&mut tui, "/plugin revoke demo");
954 expect_visible(
955 &mut tui,
956 "Plugin bundle 'demo': trust-revoked.",
957 "bundle trust revoked",
958 );
959 wait_for_log(&mut tui, &mcp_log, "signal:");
960
961 let state = std::fs::read_to_string(workspace.home().join(".codewhale/plugins/state.json"))
962 .expect("durable plugin state");
963 assert!(state.contains("\"enabled\": true"));
964 assert!(state.contains("\"trust\": null"));
965 assert!(bundle.join("server.py").exists(), "source bundle preserved");
966
967 submit_tui_command(&mut tui, "/exit");
968 assert_eq!(
969 tui.wait_for_exit(BINARY_ACCEPTANCE_TIMEOUT),
970 Some(0),
971 "the TUI must exit gracefully after revoking the plugin",
972 );
973 let receipts = std::fs::read_to_string(&outbox).expect("outbox after process exit");
974 let final_event: serde_json::Value =
975 serde_json::from_str(receipts.lines().last().expect("final receipt"))
976 .expect("complete final JSONL event");
977 assert_eq!(final_event["event"], "session_end");
978 assert_eq!(final_event["kind"], "session.ended");
979
980 let _ = tui.shutdown();
981 let _ = shutdown_tx.send(());
982 let _ = model_thread.join();
983 }
984
985 #[cfg(unix)]
986 #[test]
987 fn limited_http_body_stops_at_content_length() {
988 let mut cursor = std::io::Cursor::new(b"{\"ok\":true}trailing-keep-alive");
989 let body = read_limited_http_body(&mut cursor, Some(11));
990 assert_eq!(body, "{\"ok\":true}");
991 }
992
993 #[cfg(unix)]
994 #[test]
995 fn hermetic_model_server_answers_http11_keepalive_post() {
996 use std::io::{Read, Write};
997 use std::net::TcpStream;
998 use std::time::{Duration, Instant};
999
1000 let (base_url, shutdown_tx, handle) = spawn_hermetic_model_server();
1001 let host = base_url
1002 .strip_prefix("http://")
1003 .and_then(|rest| rest.strip_suffix("/v1"))
1004 .expect("loopback /v1 URL");
1005 let body = r#"{"messages":[{"role":"user","content":"call plugin echo"}]}"#;
1006 let request = format!(
1007 "POST /v1/chat/completions HTTP/1.1\r\n\
1008 Host: {host}\r\n\
1009 Content-Type: application/json\r\n\
1010 Content-Length: {}\r\n\
1011 Connection: keep-alive\r\n\
1012 \r\n\
1013 {body}",
1014 body.len()
1015 );
1016
1017 let started = Instant::now();
1018 let mut stream = TcpStream::connect(host).expect("connect fixture");
1019 stream
1020 .set_read_timeout(Some(Duration::from_secs(2)))
1021 .expect("read timeout");
1022 stream
1023 .set_write_timeout(Some(Duration::from_secs(2)))
1024 .expect("write timeout");
1025 stream.write_all(request.as_bytes()).expect("write request");
1026 stream.flush().expect("flush request");
1027
1028 let mut response = Vec::new();
1029 let mut buf = [0u8; 4096];
1030 loop {
1031 match stream.read(&mut buf) {
1032 Ok(0) => break,
1033 Ok(n) => {
1034 response.extend_from_slice(&buf[..n]);
1035 let text = String::from_utf8_lossy(&response);
1036 if text.contains("tool_calls") || text.contains("mcp_plugin") {
1037 break;
1038 }
1039 }
1040 Err(err)
1041 if err.kind() == std::io::ErrorKind::WouldBlock
1042 || err.kind() == std::io::ErrorKind::TimedOut =>
1043 {
1044 panic!(
1045 "keepalive POST hung after {:?}; fixture must read Content-Length, not EOF\n{}",
1046 started.elapsed(),
1047 String::from_utf8_lossy(&response)
1048 );
1049 }
1050 Err(err) => panic!("read fixture: {err}"),
1051 }
1052 }
1053 assert!(
1054 started.elapsed() < Duration::from_secs(2),
1055 "keepalive POST took {:?}",
1056 started.elapsed()
1057 );
1058 let text = String::from_utf8_lossy(&response);
1059 assert!(
1060 text.contains("tool_calls") || text.contains("mcp_plugin"),
1061 "unexpected fixture response: {text}"
1062 );
1063 let _ = shutdown_tx.send(());
1064 let _ = handle.join();
1065 }
1066
1067 // ---------------------------------------------------------------------------
1068 // Scenario runners
1069 // ---------------------------------------------------------------------------
1070
1071 #[tokio::test(flavor = "current_thread")]
1072 async fn plugin_discovery_happy_path() {
1073 run_scenario(DISCOVERY_SCENARIO, 9).await;
1074 }
1075
1076 #[tokio::test(flavor = "current_thread")]
1077 async fn plugin_discovery_empty_directory() {
1078 run_scenario(EMPTY_SCENARIO, 4).await;
1079 }
1080
1081 #[tokio::test(flavor = "current_thread")]
1082 async fn plugin_discovery_missing_directory() {
1083 run_scenario(MISSING_SCENARIO, 4).await;
1084 }
1085
1086 async fn run_scenario(name: &'static str, expected_steps: usize) {
1087 let writer = PluginE2EWorld::cucumber()
1088 .fail_on_skipped()
1089 .with_default_cli()
1090 .filter_run(FEATURE_PATH, move |feature, _, scenario| {
1091 feature.name == FEATURE_NAME && scenario.name == name
1092 })
1093 .await;
1094 assert_eq!(writer.failed_steps(), 0, "scenario failed: {name}");
1095 assert_eq!(writer.skipped_steps(), 0, "scenario skipped steps: {name}");
1096 assert_eq!(
1097 writer.passed_steps(),
1098 expected_steps,
1099 "scenario did not run: {name}"
1100 );
1101 }
1102
1103 // ---------------------------------------------------------------------------
1104 // Helpers
1105 // ---------------------------------------------------------------------------
1106
1107 fn codewhale_tui_binary() -> PathBuf {
1108 if let Some(path) = option_env!("CARGO_BIN_EXE_codewhale-tui") {
1109 return PathBuf::from(path);
1110 }
1111 if let Ok(path) = std::env::var("CARGO_BIN_EXE_codewhale-tui") {
1112 return PathBuf::from(path);
1113 }
1114
1115 let mut path = std::env::current_exe().expect("current test executable path");
1116 path.pop();
1117 if path.ends_with("deps") {
1118 path.pop();
1119 }
1120 path.push(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
1121 path
1122 }
1123
1123 lines RUST