From fdcf6147dbe4252bbd318227f04d2cb9f5d87755 Mon Sep 17 00:00:00 2001 From: kewton Date: Fri, 9 Oct 2026 09:13:39 +0900 Subject: [PATCH] test(tui): move promoted-profile port body to transport lib test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the profile-promotion body out of tests/tui_integration.rs into the required library test tui::profile_transport_tests, driving the fake dev child through the Issue #608 OS-assigned port transport instead of a freed-and-rebound local port. The logical port, profile promotion, stage, terminal summary, and evidence assertions are preserved one-to-one. Extract the shared Config builder, FakeClient, FakeUi, and command-event helper into tests/support/tui_profile_fixture.rs and include the same file from both the integration test and the library test. The fake child is owned by the test module and speaks only the transport's file contract (the workspace marker, the generation file, the {"generation","port"} ready file, and the browser probe's mock-port file); it binds an OS-assigned port and announces it, so no product code or visibility change is needed. Mirrors the Issue #622/#448 test transports. Process isolation (PM 案 A): the required #[test] keeps its name but no longer runs the flow in the lib test process. It re-execs this binary with `--ignored --exact --nocapture --test-threads=1` and the entry env variable COMMANDAGENT_TUI_PROFILE_TRANSPORT_INNER=1, so the moved body (an #[ignore]d inner test gated by that variable, like the fake dev child) runs in its own process. The outer waits a bounded 120s, kills and reaps on timeout, drains stdout/stderr on reader threads, and requires: child exit status 0, child stdout containing "1 passed" (so --exact matching zero tests cannot pass), and the inner completion marker file the inner writes after its final assertion (path passed from outside the tempdir). On failure it panics with the child's stdout and stderr. The test itself binds 127.0.0.1:0, keeps the listener alive to the end, and uses that number as the logical port in the goal, package.json (`-p`), and the requested_port assertions. The child's announced port (read from browser-probe-mock-port.txt) is asserted non-zero and different from the logical port. assertion 対応 (旧 tests/tui_integration.rs == 新 inner body, 1 対 1): 1 output.contains("ultra-plan-run complete") 2 events contains "event":"profile_reinferred" 3 events contains "profile":"nextjs" 4 events contains "requested_port":" (goal)" 5 tui_command_stop.profile == "nextjs" 6 tui_command_stop.effective_profile == "nextjs" 7 tui_command_stop.contract_origin == "promoted_union" 8 tui_command_stop.assurance_level == "full" 9 tui_command_stop.requested_port == " (goal)" 10 summary contains "Profile promoted: generic -> nextjs" 11 summary contains "Profile: nextjs" 12 summary contains "Requested port: (goal)" 追加(旧 body に無い証跡 assertion、既存は弱めていない): browser_probe event の status=="ready" / ok==true / http_status==200 / port== / requested_port==" (goal)" / output_excerpt 非空 子が browser-probe-mock-port.txt に告知した実ポート != 0 かつ != logical 外側: 子の exit status 0 / 子の stdout に "1 passed" / inner の完了マーカー 読み替え: 「モジュール配線は tui の親へ置く」は新 test file の親モジュール src/tui/mod.rs への mod 宣言と読んだ 判断: dev child は本 test module が所有し、#622/#448 と同じ file 契約だけを使う。test_transport の enable/bind は runner test module の pub(super) で scope 内から呼べないため、製品側を変えず契約経由にした 判断: 移した body は #[ignore] の内側に置き、外側の必須 #[test] が current_exe を再実行して内側だけを走らせる。lib の同一プロセスで共有状態を取り合わないため(案 A、案 B は採らない) 判断: 論理ポートはテスト内で bind(0) した listener を終了まで保持して決め、その番号を goal/package.json/assert に使う。固定番号を使わず、占有したままでも同一の昇格・summary になることを test 自身が確認し、2 プロセス同時でも衝突しない 判断: generality_guardrails の nextjs literal 監査は scope 外のため編集せず、lib test module 全体を #[cfg(test)] mod tests で包み test 行として分類させた 本文に無い指摘: この TUI flow の readiness は minimal_loop::browser_probe で、acceptance.rs の dev_server_lifecycle(start/wait/probe/cleanup) は出ない。よって実子起動と HTTP 観測は browser_probe event の status/ok/http_status/output_excerpt で assert した 本文に無い指摘: browser_probe の dynamic_port には .anvil/evidence/browser-probe-command.json の上書きが要る(無いと論理ポートを poll して 30s timeout) 本文に無い指摘: lib の filter `tui::` 反復で 13/20 落ちた原因は (1) tui::repl が cli_pack の lock 下で COMMANDAGENT_PACK_* を一時設定すること、(2) 新テストの flow が presentation の共有状態を書き換えること。内側の別プロセス化で回避した Co-authored-by: CommandCodeBot --- src/tui/mod.rs | 3 + src/tui/profile_transport_tests.rs | 589 +++++++++++++++++++++++++++ tests/support/tui_profile_fixture.rs | 164 ++++++++ tests/tui_integration.rs | 484 +--------------------- 4 files changed, 761 insertions(+), 479 deletions(-) create mode 100644 src/tui/profile_transport_tests.rs create mode 100644 tests/support/tui_profile_fixture.rs diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 41ca680a1..5899bdc97 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -22,6 +22,9 @@ pub mod terminal_notifications; pub mod terminal_summary; pub mod ux_demo; +#[cfg(test)] +mod profile_transport_tests; + use std::sync::{Arc, Mutex}; use crate::config::Config; diff --git a/src/tui/profile_transport_tests.rs b/src/tui/profile_transport_tests.rs new file mode 100644 index 000000000..816c4e7f8 --- /dev/null +++ b/src/tui/profile_transport_tests.rs @@ -0,0 +1,589 @@ +//! Promoted-profile port test over the Issue #608 cfg(test) dev-server +//! transport (Issue #633). +//! +//! The port-using profile-promotion body moved here from +//! `tests/tui_integration.rs` so it runs in the required `cargo test` library +//! suite (the integration test links the normally-built library and cannot call +//! the `#[cfg(test)]` transport). The logical port stays in the goal, the +//! contract, and the evidence; the fake dev child binds an OS-assigned port and +//! announces it through the transport ready file, so no number is freed and +//! re-bound and two checkouts can run the same logical port concurrently. +//! +//! The moved body runs in its own process: the required `#[test]` re-execs this +//! binary for the `#[ignore]`d inner body, so the TUI flow never shares the lib +//! test process (and its `cli_pack` / `presentation` global state) with the other +//! `tui::` tests. The inner is gated by an entry environment variable, exactly +//! like the fake dev child. +//! +//! `#[cfg(unix)]`-only: the child fixture is a POSIX shell package manager. + +#[cfg(test)] +mod tests { + use crate::config::{ + Action, Config, ConfigFieldSources, NarrationMode, OllamaThink, OpenAiApi, PlanPreset, + PromptLayout, Provider, + }; + use crate::planner::step_plan::StepPlan; + use crate::planner::ultra_plan::{UltraPhase, UltraPlan}; + use crate::providers::{AssistantReply, ChatClient}; + use crate::state::{ConversationMessage, ToolCall}; + use crate::tools::registry::ToolSpec; + use crate::tui::status::UiStatus; + use crate::tui::{InteractionUi, UiGuard}; + use serde_json::json; + use std::path::Path; + use std::path::PathBuf; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Mutex, MutexGuard}; + + include!("../../tests/support/tui_profile_fixture.rs"); + + #[cfg(unix)] + fn nextjs_package_json(port: u16) -> String { + format!( + r#"{{"dependencies":{{"next":"^14.2.0","react":"^18.3.0","react-dom":"^18.3.0"}},"devDependencies":{{"typescript":"^5.5.0","@types/node":"^20.14.0","@types/react":"^18.3.0","@types/react-dom":"^18.3.0","tailwindcss":"^3.4.19","postcss":"^8.5.15","autoprefixer":"^10.4.20"}},"scripts":{{"build":"next build","dev":"next dev -p {port}","start":"next start -p {port}"}}}}"# + ) + } + + #[cfg(unix)] + fn nextjs_page_source() -> String { + r#""use client"; +import { useState } from "react"; + +export default function Page() { + const [draft, setDraft] = useState(""); + const [items, setItems] = useState([]); + return
+ setDraft(event.target.value)} /> + +
    {items.map((item, index) =>
  • {item}
  • )}
+
; +} +"# + .to_string() + } + + #[cfg(unix)] + fn sh_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) + } + + /// The fake package manager for this library test. `npm run dev`/`start` exec + /// this same test binary as `profile_transport_dev_server_child`, whose only job + /// is to bind an OS-assigned port and announce it through the transport's ready + /// files; `npm run build` is a no-op success so the promoted Next.js acceptance + /// path stays exercised without a real toolchain. This keeps the frozen + /// observer/child bytes out of the product path, mirroring the Issue #622/#448 + /// test transports. + #[cfg(unix)] + fn write_fake_dev_server_package_manager(root: &Path) { + use std::os::unix::fs::PermissionsExt; + + let bin = root.join("node_modules/.bin"); + std::fs::create_dir_all(&bin).unwrap(); + for package in ["next", "tailwindcss", "postcss", "autoprefixer"] { + std::fs::create_dir_all(root.join("node_modules").join(package)).unwrap(); + } + let exe = sh_quote(&std::env::current_exe().unwrap().display().to_string()); + let child = "--ignored --exact tui::profile_transport_tests::tests::profile_transport_dev_server_child --nocapture"; + let npm = format!( + "#!/bin/sh\n\ +if [ \"$1\" = \"run\" ] && [ \"$2\" = \"build\" ]; then\n\ + echo \"fake build ok\"\n\ + exit 0\n\ +fi\n\ +if [ \"$1\" = \"run\" ] && [ \"$2\" = \"dev\" ]; then\n\ + COMMANDAGENT_TUI_PROFILE_TRANSPORT_CHILD=1 exec {exe} {child}\n\ +fi\n\ +if [ \"$1\" = \"run\" ] && [ \"$2\" = \"start\" ]; then\n\ + COMMANDAGENT_TUI_PROFILE_TRANSPORT_CHILD=1 exec {exe} {child}\n\ +fi\n\ +echo \"unexpected fake npm args: $*\" >&2\n\ +exit 2\n" + ); + let npm_path = bin.join("npm"); + std::fs::write(&npm_path, npm).unwrap(); + let mut permissions = std::fs::metadata(&npm_path).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&npm_path, permissions).unwrap(); + + let next_path = bin.join("next"); + std::fs::write(&next_path, "#!/bin/sh\nexit 0\n").unwrap(); + let mut permissions = std::fs::metadata(&next_path).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&next_path, permissions).unwrap(); + } + + /// The test-owned fake dev child. It works only through the same file contract + /// the product's `cfg(test)` transport reads — the workspace marker, the + /// generation file, the `{"generation","port"}` ready file, and the browser + /// probe's mock-port file — so no product code or visibility changes are + /// needed. It binds an OS-assigned port and announces the real one, so the + /// logical goal port is never freed and re-bound. + #[test] + #[ignore] + #[cfg(unix)] + #[allow(clippy::zombie_processes)] + fn profile_transport_dev_server_child() { + if std::env::var("COMMANDAGENT_TUI_PROFILE_TRANSPORT_CHILD") + .ok() + .as_deref() + != Some("1") + { + return; + } + let root = Path::new("."); + let logical: u16 = std::env::var("PORT").unwrap().parse().unwrap(); + let dynamic = root + .join(".anvil/evidence/dev-server-test-transport") + .is_file(); + let listener = + std::net::TcpListener::bind(("127.0.0.1", if dynamic { 0 } else { logical })).unwrap(); + if dynamic { + let actual = listener.local_addr().unwrap().port(); + let generation: u64 = + std::fs::read_to_string(root.join(".anvil/evidence/dev-server-probe-generation")) + .ok() + .and_then(|value| value.trim().parse().ok()) + .unwrap_or(0); + let evidence = root.join(".anvil/evidence"); + std::fs::create_dir_all(&evidence).unwrap(); + std::fs::write( + evidence.join("dev-server-ready-port.json"), + json!({"generation": generation, "port": actual}).to_string(), + ) + .unwrap(); + std::fs::write( + evidence.join("browser-probe-mock-port.txt"), + actual.to_string(), + ) + .unwrap(); + } + let body = "
    "; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + for stream in listener.incoming() { + let mut stream = stream.unwrap(); + let mut request = [0_u8; 1024]; + let _ = std::io::Read::read(&mut stream, &mut request); + let _ = std::io::Write::write_all(&mut stream, response.as_bytes()); + } + } + + /// Turn on the browser and dev-server probes and the Issue #608 transport for + /// this workspace. The two `enable-*-probe-tests` files are the `cfg(test)` + /// contracts read by `ultra_browser_probe_runtime_enabled` and + /// `dev_server_probe_runtime_enabled`. The `browser-probe-command.json` override + /// (mirroring `test_transport::enable_browser_probe_transport`, which is + /// private to the runner test module) makes the browser readiness probe opt into + /// the transport: the fake child binds an OS-assigned port and publishes it, and + /// the probe polls that port instead of the logical `port` in the goal. The + /// `dev-server-test-transport` marker is the one + /// `src/planner/runner/acceptance/test_transport.rs` reads for the dev route + /// probe; the child writes both ready files. + #[cfg(unix)] + fn enable_profile_transport(root: &Path, port: u16) { + let anvil = root.join(".anvil"); + std::fs::create_dir_all(anvil.join("evidence")).unwrap(); + std::fs::write(anvil.join("enable-dev-server-probe-tests"), "1").unwrap(); + std::fs::write(anvil.join("enable-browser-probe-tests"), "1").unwrap(); + std::fs::write(anvil.join("evidence/dev-server-test-transport"), "1").unwrap(); + std::fs::write( + anvil.join("evidence/browser-probe-command.json"), + json!({ + "program": "npm", + "args": ["run", "start"], + "port": port, + "require_build": true, + "dynamic_port": true, + "display": "fake dev server (browser transport)", + }) + .to_string(), + ) + .unwrap(); + } + + /// The browser interaction observation the probe returns under `cfg(test)`, + /// matching what the integration test's fake node script produced. + #[cfg(unix)] + fn interaction_probe_passed_value() -> serde_json::Value { + serde_json::from_str( + r#"{"ok":true,"status":"passed","interaction_success":true,"interaction_performed":true,"surface_visible":true,"start_control_found":true,"start_transition":true,"input_state_change":true,"input_state_evaluated_after_start":true,"input_event_observed":true,"state_changed":true,"probe_mode":"contract","contract_hook_status":"usable","action_hooks":["primary"],"state_dimensions_changed":["items","draft"],"primary_start_transition":true,"text_entry":"entered","text_entry_target":"input#memo","typed_token":"anvil-probe","token_echoed":true,"echo_latency_ms":1,"text_input_state_change":true,"stage":"observing","steps":["surface_visible","start_transition","control_input_dispatched","input_state_evaluated_after_start","input_state_change","text_input_state_change"],"before_marker":"items=0,draft=","after_marker":"items=1,draft=anvil-probe","server_http_status":200,"duration_ms":1}"#, + ) + .unwrap() + } + + /// Required `#[test]`: re-execs this binary so the TUI flow body runs in its + /// own process and cannot race the other `tui::` tests' global state. + #[test] + #[cfg(unix)] + fn tui_slash_promoted_profile_reflected_in_terminal_summary() { + use std::io::Read; + use std::time::{Duration, Instant}; + + const INNER_TEST_NAME: &str = + "tui::profile_transport_tests::tests::profile_transport_promoted_profile_run"; + + let exe = std::env::current_exe().unwrap(); + let marker = exe.parent().unwrap().join(format!( + "issue633-profile-inner-{}-{}.marker", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = std::fs::remove_file(&marker); + + let mut child = std::process::Command::new(&exe) + .args([ + "--ignored", + "--exact", + INNER_TEST_NAME, + "--nocapture", + "--test-threads=1", + ]) + .env("COMMANDAGENT_TUI_PROFILE_TRANSPORT_INNER", "1") + .env("COMMANDAGENT_TUI_PROFILE_TRANSPORT_MARKER", &marker) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("spawn the inner profile transport test"); + + // Drain both pipes on their own threads so a full pipe cannot deadlock. + let mut stdout_pipe = child.stdout.take().unwrap(); + let stdout_reader = std::thread::spawn(move || { + let mut text = String::new(); + let _ = stdout_pipe.read_to_string(&mut text); + text + }); + let mut stderr_pipe = child.stderr.take().unwrap(); + let stderr_reader = std::thread::spawn(move || { + let mut text = String::new(); + let _ = stderr_pipe.read_to_string(&mut text); + text + }); + + let deadline = Instant::now() + Duration::from_secs(120); + let status = loop { + match child.try_wait().expect("wait for the inner test") { + Some(status) => break Some(status), + None if Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + break None; + } + None => std::thread::sleep(Duration::from_millis(50)), + } + }; + let stdout = stdout_reader.join().unwrap(); + let stderr = stderr_reader.join().unwrap(); + + let report = format!("--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}"); + let Some(status) = status else { + panic!("inner profile transport test exceeded 120s\n{report}"); + }; + assert!( + status.success(), + "inner profile transport test failed ({status:?})\n{report}" + ); + assert!( + stdout.contains("1 passed"), + "inner test did not run exactly one passing test\n{report}" + ); + assert!( + marker.is_file(), + "inner test did not reach its final assertion\n{report}" + ); + let _ = std::fs::remove_file(&marker); + } + + /// The moved body. `#[ignore]`d so `cargo test` never runs it in-process; the + /// outer test re-execs the binary for it, and it does nothing unless the outer + /// set the entry variable (the same shape as the fake dev child). + #[test] + #[ignore] + #[cfg(unix)] + fn profile_transport_promoted_profile_run() { + if std::env::var("COMMANDAGENT_TUI_PROFILE_TRANSPORT_INNER") + .ok() + .as_deref() + != Some("1") + { + return; + } + // Target-anchored workspace: never under a read-restricted system prefix. + let exe = std::env::current_exe().unwrap(); + let dir = tempfile::Builder::new() + .prefix("issue633-profile-") + .tempdir_in(exe.parent().unwrap()) + .unwrap(); + // Hold the logical port for the whole test. The transport binds an + // OS-assigned port, so the goal/contract/evidence keep this number and + // two concurrent checkouts never collide on a fixed port; the listener is + // never released, proving occupancy while the run still succeeds. + // 3011 is the product's NEXTJS_DEV_SERVER_DEFAULT_PORT, which the probe + // treats as "no explicit port", so avoid returning it. + let logical_listener = loop { + let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + if listener.local_addr().unwrap().port() != 3011 { + break listener; + } + }; + let port = logical_listener.local_addr().unwrap().port(); + let events_path = dir.path().join(".anvil/runs/test/events.jsonl"); + let plan_path = dir.path().join("ultra.yaml"); + let goal = format!( + "ちょっとしたメモアプリを作って。ブラウザで使えるようにしてください。{port}ポートで起動可能にしてください。" + ); + let plan = UltraPlan { + goal, + profile: "generic".to_string(), + style: "default".to_string(), + intent: "create".to_string(), + phases: vec![ + UltraPhase { + id: "setup-framework".to_string(), + prompt: "Create the package manifest".to_string(), + }, + UltraPhase { + id: "implement-ui".to_string(), + prompt: "Create the promoted Next.js route".to_string(), + }, + ], + }; + std::fs::write( + &plan_path, + crate::planner::ultra_plan::render_ultra_plan(&plan), + ) + .unwrap(); + let mut setup_step = StepPlan::single("create package"); + setup_step.steps[0].kind = "setup".to_string(); + setup_step.steps[0].expected_paths = vec!["package.json".to_string()]; + let mut route_step = StepPlan::single("create route"); + route_step.steps[0].kind = "implement".to_string(); + route_step.steps[0].expected_paths = vec![ + "tsconfig.json".to_string(), + "postcss.config.js".to_string(), + "tailwind.config.ts".to_string(), + "src/app/layout.tsx".to_string(), + "src/app/page.tsx".to_string(), + "src/app/globals.css".to_string(), + "src/app/global.d.ts".to_string(), + ]; + write_fake_dev_server_package_manager(dir.path()); + enable_profile_transport(dir.path(), port); + crate::minimal_loop::interaction_probe::write_test_availability_override(dir.path(), true); + crate::minimal_loop::interaction_probe::write_test_result_override( + dir.path(), + &interaction_probe_passed_value(), + ); + let mut cfg = config(dir.path().to_path_buf()); + cfg.eval_events_path = Some(events_path.clone()); + let mut planner = FakeClient::new( + "planner", + vec![ + AssistantReply::text(serde_json::to_string(&setup_step).unwrap()), + AssistantReply::text(serde_json::to_string(&route_step).unwrap()), + ], + ); + let mut execution = FakeClient::new( + "exec", + vec![ + AssistantReply { + content: String::new(), + tool_calls: vec![ToolCall::new( + "Write", + json!({"path":"package.json","content":nextjs_package_json(port)}), + )], + prompt_tokens: None, + completion_tokens: None, + }, + AssistantReply { + content: String::new(), + tool_calls: vec![ + ToolCall::new( + "Write", + json!({"path":"src/app/layout.tsx","content":"export default function RootLayout({ children }: { children: React.ReactNode }) { return {children}; }"}), + ), + ToolCall::new( + "Write", + json!({"path":"tsconfig.json","content":r#"{"compilerOptions":{"target":"ES2017","lib":["dom","dom.iterable","esnext"],"allowJs":true,"skipLibCheck":true,"strict":true,"noEmit":true,"esModuleInterop":true,"module":"esnext","moduleResolution":"bundler","resolveJsonModule":true,"isolatedModules":true,"jsx":"preserve","incremental":true,"plugins":[{"name":"next"}]},"include":["next-env.d.ts","**/*.ts","**/*.tsx",".next/types/**/*.ts"],"exclude":["node_modules"]}"#}), + ), + ToolCall::new( + "Write", + json!({"path":"postcss.config.js","content":"module.exports = { plugins: { tailwindcss: {}, autoprefixer: {} } };"}), + ), + ToolCall::new( + "Write", + json!({"path":"tailwind.config.ts","content":"import type { Config } from 'tailwindcss';\nconst config: Config = { content: ['./src/app/**/*.{js,ts,jsx,tsx,mdx}'], theme: { extend: {} }, plugins: [] };\nexport default config;\n"}), + ), + ToolCall::new( + "Write", + json!({"path":"src/app/page.tsx","content":nextjs_page_source()}), + ), + ToolCall::new( + "Write", + json!({"path":"src/app/globals.css","content":"body { font-family: sans-serif; }"}), + ), + ToolCall::new( + "Write", + json!({"path":"src/app/global.d.ts","content":"declare module '*.css';"}), + ), + ToolCall::new( + "Write", + json!({"path":"browser-readiness.json","content":r#"{"ok":true,"http_status":200,"route_rendered":true}"#}), + ), + ToolCall::new( + "Write", + json!({"path":"browser-interaction.json","content":r#"{"ok":true,"status":"passed","interaction_success":true,"interaction_performed":true,"surface_visible":true,"start_transition":true,"input_state_change":true,"input_state_evaluated_after_start":true,"input_event_observed":true,"state_changed":true,"canvas_found":true}"#}), + ), + ], + prompt_tokens: None, + completion_tokens: None, + }, + ], + ); + let ui = FakeUi::default(); + + let output = crate::tui::slash::handle_command( + "/run-ultra-plan ultra.yaml", + &cfg, + &mut planner, + &mut execution, + &ui, + ) + .unwrap(); + + assert!(output.contains("ultra-plan-run complete")); + let events = std::fs::read_to_string(&events_path).unwrap(); + assert!( + events.contains(r#""event":"profile_reinferred""#), + "{events}" + ); + assert!(events.contains(r#""profile":"nextjs""#), "{events}"); + let requested_port = format!("{port} (goal)"); + assert!( + events.contains(&format!(r#""requested_port":"{requested_port}""#)), + "{events}" + ); + let stops = tui_command_stop_events(&events); + assert_eq!( + stops[0].get("profile").and_then(|value| value.as_str()), + Some("nextjs"), + "{events}" + ); + assert_eq!( + stops[0] + .get("effective_profile") + .and_then(|value| value.as_str()), + Some("nextjs"), + "{events}" + ); + assert_eq!( + stops[0] + .get("contract_origin") + .and_then(|value| value.as_str()), + Some("promoted_union"), + "{events}" + ); + assert_eq!( + stops[0] + .get("assurance_level") + .and_then(|value| value.as_str()), + Some("full"), + "{events}" + ); + assert_eq!( + stops[0] + .get("requested_port") + .and_then(|value| value.as_str()), + Some(requested_port.as_str()), + "{events}" + ); + let summary = + std::fs::read_to_string(events_path.parent().unwrap().join("summary.md")).unwrap(); + assert!( + summary.contains("Profile promoted: generic -> nextjs"), + "{summary}" + ); + assert!(summary.contains("Profile: nextjs"), "{summary}"); + assert!( + summary.contains(&format!("Requested port: {requested_port}")), + "{summary}" + ); + + // Evidence that the readiness probe actually ran the fake child and + // observed HTTP on its OS-assigned port, rather than passing because the + // cfg(test) probe was disabled or an outcome was scripted. `status`, + // `http_status`, and the captured child output are the product's own + // observation of the spawned child. + let probe = events + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .find(|event| { + event.get("event").and_then(|value| value.as_str()) == Some("browser_probe") + }) + .unwrap_or_else(|| panic!("missing browser_probe event:\n{events}")); + assert_eq!( + probe.get("status").and_then(|value| value.as_str()), + Some("ready"), + "{probe}" + ); + assert_eq!( + probe.get("ok").and_then(|value| value.as_bool()), + Some(true), + "{probe}" + ); + assert_eq!( + probe.get("http_status").and_then(|value| value.as_i64()), + Some(200), + "{probe}" + ); + assert_eq!( + probe.get("port").and_then(|value| value.as_u64()), + Some(u64::from(port)), + "{probe}" + ); + assert_eq!( + probe.get("requested_port").and_then(|value| value.as_str()), + Some(requested_port.as_str()), + "{probe}" + ); + assert!( + probe + .get("output_excerpt") + .and_then(|value| value.as_str()) + .is_some_and(|excerpt| !excerpt.is_empty()), + "the probe must capture the spawned child's output: {probe}" + ); + + // The child must have bound its own OS-assigned port, not the logical + // port this test still occupies; the announced port comes from the + // transport's ready file, which the child wrote. `logical_listener` stays + // alive to the end of the test, so success here proves the run did not + // need the logical port to be free. + let announced: u16 = std::fs::read_to_string( + dir.path() + .join(".anvil/evidence/browser-probe-mock-port.txt"), + ) + .expect("child must announce its OS-assigned port") + .trim() + .parse() + .expect("announced port is a u16"); + assert_ne!(announced, 0, "child must bind a real port"); + assert_ne!( + announced, port, + "child must not bind the logical port held by the test" + ); + + // Final marker for the outer test, written only after every assertion + // above passed. Path comes from the outer test, outside the tempdir. + let marker = std::env::var("COMMANDAGENT_TUI_PROFILE_TRANSPORT_MARKER") + .expect("the outer test must pass the marker path"); + std::fs::write(marker, "inner-complete").unwrap(); + } +} diff --git a/tests/support/tui_profile_fixture.rs b/tests/support/tui_profile_fixture.rs new file mode 100644 index 000000000..8f780e2cd --- /dev/null +++ b/tests/support/tui_profile_fixture.rs @@ -0,0 +1,164 @@ +// Shared TUI profile fixtures (Issue #633). +// +// Included verbatim by `tests/tui_integration.rs` and by the library test +// module `tui::profile_transport_tests`, so the Config builder, fake provider, +// fake UI, and the command-event helper cannot drift between the remaining +// integration tests and the promoted-port library test. +// +// The includer supplies the library imports: the integration test uses +// `commandagent::...`, the library test uses `crate::...` (see the `use` +// blocks in each file). Nothing here names the crate, so the same source +// compiles in both crates. + +fn config(root: PathBuf) -> Config { + Config { + workspace_root: root.clone(), + state_dir: root.join("state"), + eval_events_path: None, + completion_contract_path: None, + yes: true, + offline: false, + context_budget: 1000, + model: "m".to_string(), + provider: Provider::Ollama, + tool_protocol: None, + openai_api: OpenAiApi::ChatCompletions, + prompt_layout: PromptLayout::Stable, + plan_preset: PlanPreset::None, + intent_override: None, + planner_model: "pm".to_string(), + planner_provider: Provider::Gemini, + planner_think: Some(OllamaThink::False), + classifier_model: "pm".to_string(), + classifier_provider: Provider::Gemini, + openai_compatible: None, + ollama_host: "http://localhost:11434".to_string(), + ollama_think: None, + lm_studio_host: "http://localhost:1234".to_string(), + num_predict: 100, + max_iterations: 4, + recovery_plan_auto_runs: 0, + chat_timeout_secs: 1, + chat_timeout_source: "override:test".to_string(), + field_sources: ConfigFieldSources::default(), + chat_retries: 1, + stream: false, + resume: None, + fresh_session: false, + no_footer: false, + narration: NarrationMode::Normal, + profile: "generic".to_string(), + profile_explicit: false, + profile_inference: None, + style: "default".to_string(), + action: Action::Repl, + } +} + +#[derive(Clone)] +struct FakeClient { + label: &'static str, + state: Arc>, +} + +#[allow(dead_code)] +struct FakeClientState { + replies: Vec, + requests: Vec>, +} + +impl FakeClient { + fn new(label: &'static str, replies: Vec) -> Self { + Self { + label, + state: Arc::new(Mutex::new(FakeClientState { + replies, + requests: Vec::new(), + })), + } + } + + #[allow(dead_code)] + fn requests(&self) -> MutexGuard<'_, FakeClientState> { + self.state.lock().unwrap() + } +} + +impl ChatClient for FakeClient { + fn label(&self) -> &str { + self.label + } + + fn supports_native_tools(&self, _model: &str) -> bool { + true + } + + fn boxed_clone(&self) -> Box { + Box::new(self.clone()) + } + + fn chat( + &mut self, + _model: &str, + messages: &[ConversationMessage], + _tools: &[ToolSpec], + _native_tools_enabled: bool, + ) -> anyhow::Result { + let mut state = self.state.lock().unwrap(); + state.requests.push(messages.to_vec()); + assert!( + !state.replies.is_empty(), + "{} fake replies exhausted", + self.label + ); + Ok(state.replies.remove(0)) + } +} + +#[derive(Default)] +struct FakeUi { + events: Mutex>, + interrupted: AtomicBool, +} + +impl FakeUi { + #[allow(dead_code)] + fn events(&self) -> Vec { + self.events.lock().unwrap().clone() + } +} + +impl InteractionUi for FakeUi { + fn before_model_call(&self, label: &str) -> UiGuard { + self.events.lock().unwrap().push(format!("model:{label}")); + UiGuard::noop() + } + + fn before_tool_call(&self, name: &str) -> UiGuard { + self.events.lock().unwrap().push(format!("tool:{name}")); + UiGuard::noop() + } + + fn publish_status(&self, status: UiStatus) { + self.events + .lock() + .unwrap() + .push(format!("status:{}:{}", status.provider, status.model)); + } + + fn interrupted(&self) -> bool { + self.interrupted.load(Ordering::SeqCst) + } +} + +fn tui_command_stop_events(text: &str) -> Vec { + text.lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|event| { + event + .get("event") + .and_then(|value| value.as_str()) + .is_some_and(|name| name == "tui_command_stop") + }) + .collect() +} diff --git a/tests/tui_integration.rs b/tests/tui_integration.rs index 9af793259..785abbda5 100644 --- a/tests/tui_integration.rs +++ b/tests/tui_integration.rs @@ -3,7 +3,10 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, MutexGuard}; use std::time::{Duration, Instant}; -use commandagent::config::{Action, Config, Provider}; +use commandagent::config::{ + Action, Config, ConfigFieldSources, NarrationMode, OllamaThink, OpenAiApi, PlanPreset, + PromptLayout, Provider, +}; use commandagent::minimal_loop::loop_run::run_session_with_required_paths_with_ui; use commandagent::planner::{generate_step_plan_with_ui, run_ultra_plan_with_ui}; use commandagent::providers::{AssistantReply, ChatClient}; @@ -22,108 +25,7 @@ use tui_interrupt_sync::{ shell_child_marker, shell_interrupt_command, shell_ready_marker, test_tempdir, }; -fn config(root: PathBuf) -> Config { - Config { - workspace_root: root.clone(), - state_dir: root.join("state"), - eval_events_path: None, - completion_contract_path: None, - yes: true, - offline: false, - context_budget: 1000, - model: "m".to_string(), - provider: Provider::Ollama, - tool_protocol: None, - openai_api: commandagent::config::OpenAiApi::ChatCompletions, - prompt_layout: commandagent::config::PromptLayout::Stable, - plan_preset: commandagent::config::PlanPreset::None, - intent_override: None, - planner_model: "pm".to_string(), - planner_provider: Provider::Gemini, - planner_think: Some(commandagent::config::OllamaThink::False), - classifier_model: "pm".to_string(), - classifier_provider: Provider::Gemini, - openai_compatible: None, - ollama_host: "http://localhost:11434".to_string(), - ollama_think: None, - lm_studio_host: "http://localhost:1234".to_string(), - num_predict: 100, - max_iterations: 4, - recovery_plan_auto_runs: 0, - chat_timeout_secs: 1, - chat_timeout_source: "override:test".to_string(), - field_sources: commandagent::config::ConfigFieldSources::default(), - chat_retries: 1, - stream: false, - resume: None, - fresh_session: false, - no_footer: false, - narration: commandagent::config::NarrationMode::Normal, - profile: "generic".to_string(), - profile_explicit: false, - profile_inference: None, - style: "default".to_string(), - action: Action::Repl, - } -} - -#[derive(Clone)] -struct FakeClient { - label: &'static str, - state: Arc>, -} - -struct FakeClientState { - replies: Vec, - requests: Vec>, -} - -impl FakeClient { - fn new(label: &'static str, replies: Vec) -> Self { - Self { - label, - state: Arc::new(Mutex::new(FakeClientState { - replies, - requests: Vec::new(), - })), - } - } - - fn requests(&self) -> MutexGuard<'_, FakeClientState> { - self.state.lock().unwrap() - } -} - -impl ChatClient for FakeClient { - fn label(&self) -> &str { - self.label - } - - fn supports_native_tools(&self, _model: &str) -> bool { - true - } - - fn boxed_clone(&self) -> Box { - Box::new(self.clone()) - } - - fn chat( - &mut self, - _model: &str, - messages: &[ConversationMessage], - _tools: &[ToolSpec], - _native_tools_enabled: bool, - ) -> anyhow::Result { - let mut state = self.state.lock().unwrap(); - state.requests.push(messages.to_vec()); - assert!( - !state.replies.is_empty(), - "{} fake replies exhausted", - self.label - ); - Ok(state.replies.remove(0)) - } -} +include!("support/tui_profile_fixture.rs"); #[derive(Clone)] struct FailingClient { @@ -209,41 +111,6 @@ impl ChatClient for PanicClient { } } -#[derive(Default)] -struct FakeUi { - events: Mutex>, - interrupted: AtomicBool, -} - -impl FakeUi { - fn events(&self) -> Vec { - self.events.lock().unwrap().clone() - } -} - -impl InteractionUi for FakeUi { - fn before_model_call(&self, label: &str) -> UiGuard { - self.events.lock().unwrap().push(format!("model:{label}")); - UiGuard::noop() - } - - fn before_tool_call(&self, name: &str) -> UiGuard { - self.events.lock().unwrap().push(format!("tool:{name}")); - UiGuard::noop() - } - - fn publish_status(&self, status: UiStatus) { - self.events - .lock() - .unwrap() - .push(format!("status:{}:{}", status.provider, status.model)); - } - - fn interrupted(&self) -> bool { - self.interrupted.load(Ordering::SeqCst) - } -} - struct InterruptAfterUi { events: Mutex>, checks: AtomicUsize, @@ -283,18 +150,6 @@ impl InteractionUi for InterruptAfterUi { } } -fn tui_command_stop_events(text: &str) -> Vec { - text.lines() - .filter_map(|line| serde_json::from_str::(line).ok()) - .filter(|event| { - event - .get("event") - .and_then(|value| value.as_str()) - .is_some_and(|name| name == "tui_command_stop") - }) - .collect() -} - fn assert_exactly_one_tui_stop(text: &str, status: &str) { let stops = tui_command_stop_events(text); assert_eq!(stops.len(), 1, "{text}"); @@ -403,152 +258,11 @@ export default function App() {{ ) } -fn nextjs_package_json(port: u16) -> String { - format!( - r#"{{"dependencies":{{"next":"^14.2.0","react":"^18.3.0","react-dom":"^18.3.0"}},"devDependencies":{{"typescript":"^5.5.0","@types/node":"^20.14.0","@types/react":"^18.3.0","@types/react-dom":"^18.3.0","tailwindcss":"^3.4.19","postcss":"^8.5.15","autoprefixer":"^10.4.20"}},"scripts":{{"build":"next build","dev":"next dev -p {port}","start":"next start -p {port}"}}}}"# - ) -} - -fn nextjs_page_source() -> String { - r#""use client"; -import { useState } from "react"; - -export default function Page() { - const [draft, setDraft] = useState(""); - const [items, setItems] = useState([]); - return
    - setDraft(event.target.value)} /> - -
      {items.map((item, index) =>
    • {item}
    • )}
    -
    ; -} -"# - .to_string() -} - -#[cfg(unix)] -fn write_fake_nextjs_package_manager(root: &std::path::Path) { - use std::os::unix::fs::PermissionsExt; - - let bin = root.join("node_modules/.bin"); - std::fs::create_dir_all(&bin).unwrap(); - for package in ["next", "tailwindcss", "postcss", "autoprefixer"] { - std::fs::create_dir_all(root.join("node_modules").join(package)).unwrap(); - } - let exe = sh_quote(&std::env::current_exe().unwrap().display().to_string()); - let npm = format!( - "#!/bin/sh\n\ -if [ \"$1\" = \"run\" ] && [ \"$2\" = \"build\" ]; then\n\ - echo \"fake build ok\"\n\ - exit 0\n\ -fi\n\ -if [ \"$1\" = \"run\" ] && [ \"$2\" = \"dev\" ]; then\n\ - COMMANDAGENT_TUI_FAKE_DEV_SERVER_CHILD=1 exec {exe} --ignored --exact tui_fake_dev_server_child --nocapture\n\ -fi\n\ -if [ \"$1\" = \"run\" ] && [ \"$2\" = \"start\" ]; then\n\ - COMMANDAGENT_TUI_FAKE_DEV_SERVER_CHILD=1 exec {exe} --ignored --exact tui_fake_dev_server_child --nocapture\n\ -fi\n\ -echo \"unexpected fake npm args: $*\" >&2\n\ -exit 2\n" - ); - let npm_path = bin.join("npm"); - std::fs::write(&npm_path, npm).unwrap(); - let mut permissions = std::fs::metadata(&npm_path).unwrap().permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(&npm_path, permissions).unwrap(); - - let next_path = bin.join("next"); - std::fs::write(&next_path, "#!/bin/sh\nexit 0\n").unwrap(); - let mut permissions = std::fs::metadata(&next_path).unwrap().permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(&next_path, permissions).unwrap(); - - let playwright_dir = root.join("node_modules/playwright"); - std::fs::create_dir_all(&playwright_dir).unwrap(); - std::fs::write(playwright_dir.join("index.js"), "module.exports = {};\n").unwrap(); - std::fs::write(playwright_dir.join("package.json"), r#"{"version":"test"}"#).unwrap(); - let playwright_resolution = sh_quote( - &json!({ - "path": playwright_dir.join("index.js").display().to_string(), - "version": "test", - }) - .to_string(), - ); - let interaction_evidence = sh_quote( - r#"{"ok":true,"status":"passed","interaction_success":true,"interaction_performed":true,"surface_visible":true,"start_control_found":true,"start_transition":true,"input_state_change":true,"input_state_evaluated_after_start":true,"input_event_observed":true,"state_changed":true,"probe_mode":"contract","contract_hook_status":"usable","action_hooks":["primary"],"state_dimensions_changed":["items","draft"],"primary_start_transition":true,"text_entry":"entered","text_entry_target":"input#memo","typed_token":"anvil-probe","token_echoed":true,"echo_latency_ms":1,"text_input_state_change":true,"stage":"observing","steps":["surface_visible","start_transition","control_input_dispatched","input_state_evaluated_after_start","input_state_change","text_input_state_change"],"before_marker":"items=0,draft=","after_marker":"items=1,draft=anvil-probe","server_http_status":200,"duration_ms":1}"#, - ); - let node = format!( - "#!/bin/sh\n\ -if [ \"$1\" = \"-e\" ]; then\n\ - printf '%s\\n' {playwright_resolution}\n\ - exit 0\n\ -fi\n\ -if [ \"${{1##*/}}\" = \"browser-interaction-probe.cjs\" ] && [ \"$#\" -ge 3 ]; then\n\ - mkdir -p \"${{3%/*}}\"\n\ - printf '%s\\n' {interaction_evidence} > \"$3\"\n\ - exit 0\n\ -fi\n\ -echo \"unexpected fake node args: $*\" >&2\n\ -exit 2\n" - ); - let node_path = bin.join("node"); - std::fs::write(&node_path, node).unwrap(); - let mut permissions = std::fs::metadata(&node_path).unwrap().permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(&node_path, permissions).unwrap(); -} - -#[cfg(unix)] -fn sh_quote(value: &str) -> String { - format!("'{}'", value.replace('\'', "'\"'\"'")) -} - -#[cfg(unix)] -fn free_local_port() -> u16 { - let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); - listener.local_addr().unwrap().port() -} - fn tui_integration_test_lock() -> MutexGuard<'static, ()> { static LOCK: Mutex<()> = Mutex::new(()); LOCK.lock().unwrap() } -#[test] -#[ignore] -fn tui_fake_dev_server_child() { - if commandagent::env_compat::var("COMMANDAGENT_TUI_FAKE_DEV_SERVER_CHILD") - .ok() - .as_deref() - != Some("1") - { - return; - } - let port = std::env::var("PORT").unwrap().parse::().unwrap(); - let listener = std::net::TcpListener::bind(("127.0.0.1", port)).unwrap(); - for stream in listener.incoming() { - let mut stream = stream.unwrap(); - stream - .set_read_timeout(Some(std::time::Duration::from_secs(1))) - .unwrap(); - let mut request = Vec::new(); - let mut buffer = [0_u8; 1024]; - while !request.windows(4).any(|window| window == b"\r\n\r\n") { - let bytes_read = std::io::Read::read(&mut stream, &mut buffer).unwrap(); - assert_ne!(bytes_read, 0, "request ended before its headers"); - request.extend_from_slice(&buffer[..bytes_read]); - assert!(request.len() <= 16 * 1024, "request headers are too large"); - } - let body = r#"Memo
      "#; - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ); - let _ = std::io::Write::write_all(&mut stream, response.as_bytes()); - } -} - #[test] fn tui_integration_records_model_and_tool_boundaries() { let _guard = tui_integration_test_lock(); @@ -945,194 +659,6 @@ fn tui_ultra_plan_run_smoke_fake_clients() { assert!(second_request.contains("phase1")); } -#[test] -fn tui_slash_promoted_profile_reflected_in_terminal_summary() { - let _guard = tui_integration_test_lock(); - let dir = tempfile::tempdir().unwrap(); - #[cfg(unix)] - let port = free_local_port(); - #[cfg(not(unix))] - let port = 3011; - let events_path = dir.path().join(".anvil/runs/test/events.jsonl"); - let plan_path = dir.path().join("ultra.yaml"); - let goal = format!( - "ちょっとしたメモアプリを作って。ブラウザで使えるようにしてください。{port}ポートで起動可能にしてください。" - ); - let plan = commandagent::planner::ultra_plan::UltraPlan { - goal, - profile: "generic".to_string(), - style: "default".to_string(), - intent: "create".to_string(), - phases: vec![ - commandagent::planner::ultra_plan::UltraPhase { - id: "setup-framework".to_string(), - prompt: "Create the package manifest".to_string(), - }, - commandagent::planner::ultra_plan::UltraPhase { - id: "implement-ui".to_string(), - prompt: "Create the promoted Next.js route".to_string(), - }, - ], - }; - std::fs::write( - &plan_path, - commandagent::planner::ultra_plan::render_ultra_plan(&plan), - ) - .unwrap(); - let mut setup_step = commandagent::planner::step_plan::StepPlan::single("create package"); - setup_step.steps[0].kind = "setup".to_string(); - setup_step.steps[0].expected_paths = vec!["package.json".to_string()]; - let mut route_step = commandagent::planner::step_plan::StepPlan::single("create route"); - route_step.steps[0].kind = "implement".to_string(); - route_step.steps[0].expected_paths = vec![ - "tsconfig.json".to_string(), - "postcss.config.js".to_string(), - "tailwind.config.ts".to_string(), - "src/app/layout.tsx".to_string(), - "src/app/page.tsx".to_string(), - "src/app/globals.css".to_string(), - "src/app/global.d.ts".to_string(), - ]; - #[cfg(unix)] - { - write_fake_nextjs_package_manager(dir.path()); - } - let mut cfg = config(dir.path().to_path_buf()); - cfg.eval_events_path = Some(events_path.clone()); - let mut planner = FakeClient::new( - "planner", - vec![ - AssistantReply::text(serde_json::to_string(&setup_step).unwrap()), - AssistantReply::text(serde_json::to_string(&route_step).unwrap()), - ], - ); - let mut execution = FakeClient::new( - "exec", - vec![ - AssistantReply { - content: String::new(), - tool_calls: vec![ToolCall::new( - "Write", - json!({"path":"package.json","content":nextjs_package_json(port)}), - )], - prompt_tokens: None, - completion_tokens: None, - }, - AssistantReply { - content: String::new(), - tool_calls: vec![ - ToolCall::new( - "Write", - json!({"path":"src/app/layout.tsx","content":"export default function RootLayout({ children }: { children: React.ReactNode }) { return {children}; }"}), - ), - ToolCall::new( - "Write", - json!({"path":"tsconfig.json","content":r#"{"compilerOptions":{"target":"ES2017","lib":["dom","dom.iterable","esnext"],"allowJs":true,"skipLibCheck":true,"strict":true,"noEmit":true,"esModuleInterop":true,"module":"esnext","moduleResolution":"bundler","resolveJsonModule":true,"isolatedModules":true,"jsx":"preserve","incremental":true,"plugins":[{"name":"next"}]},"include":["next-env.d.ts","**/*.ts","**/*.tsx",".next/types/**/*.ts"],"exclude":["node_modules"]}"#}), - ), - ToolCall::new( - "Write", - json!({"path":"postcss.config.js","content":"module.exports = { plugins: { tailwindcss: {}, autoprefixer: {} } };"}), - ), - ToolCall::new( - "Write", - json!({"path":"tailwind.config.ts","content":"import type { Config } from 'tailwindcss';\nconst config: Config = { content: ['./src/app/**/*.{js,ts,jsx,tsx,mdx}'], theme: { extend: {} }, plugins: [] };\nexport default config;\n"}), - ), - ToolCall::new( - "Write", - json!({"path":"src/app/page.tsx","content":nextjs_page_source()}), - ), - ToolCall::new( - "Write", - json!({"path":"src/app/globals.css","content":"body { font-family: sans-serif; }"}), - ), - ToolCall::new( - "Write", - json!({"path":"src/app/global.d.ts","content":"declare module '*.css';"}), - ), - ToolCall::new( - "Write", - json!({"path":"browser-readiness.json","content":r#"{"ok":true,"http_status":200,"route_rendered":true}"#}), - ), - ToolCall::new( - "Write", - json!({"path":"browser-interaction.json","content":r#"{"ok":true,"status":"passed","interaction_success":true,"interaction_performed":true,"surface_visible":true,"start_transition":true,"input_state_change":true,"input_state_evaluated_after_start":true,"input_event_observed":true,"state_changed":true,"canvas_found":true}"#}), - ), - ], - prompt_tokens: None, - completion_tokens: None, - }, - ], - ); - let ui = FakeUi::default(); - - let output = commandagent::tui::slash::handle_command( - "/run-ultra-plan ultra.yaml", - &cfg, - &mut planner, - &mut execution, - &ui, - ) - .unwrap(); - - assert!(output.contains("ultra-plan-run complete")); - let events = std::fs::read_to_string(&events_path).unwrap(); - assert!( - events.contains(r#""event":"profile_reinferred""#), - "{events}" - ); - assert!(events.contains(r#""profile":"nextjs""#), "{events}"); - let requested_port = format!("{port} (goal)"); - assert!( - events.contains(&format!(r#""requested_port":"{requested_port}""#)), - "{events}" - ); - let stops = tui_command_stop_events(&events); - assert_eq!( - stops[0].get("profile").and_then(|value| value.as_str()), - Some("nextjs"), - "{events}" - ); - assert_eq!( - stops[0] - .get("effective_profile") - .and_then(|value| value.as_str()), - Some("nextjs"), - "{events}" - ); - assert_eq!( - stops[0] - .get("contract_origin") - .and_then(|value| value.as_str()), - Some("promoted_union"), - "{events}" - ); - assert_eq!( - stops[0] - .get("assurance_level") - .and_then(|value| value.as_str()), - Some("full"), - "{events}" - ); - assert_eq!( - stops[0] - .get("requested_port") - .and_then(|value| value.as_str()), - Some(requested_port.as_str()), - "{events}" - ); - let summary = - std::fs::read_to_string(events_path.parent().unwrap().join("summary.md")).unwrap(); - assert!( - summary.contains("Profile promoted: generic -> nextjs"), - "{summary}" - ); - assert!(summary.contains("Profile: nextjs"), "{summary}"); - assert!( - summary.contains(&format!("Requested port: {requested_port}")), - "{summary}" - ); -} - #[test] fn tui_runs_lists_recent_runs_without_emitting_command_events() { let _guard = tui_integration_test_lock();