| 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 |