mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 10:01:30 -10:00
chore(tui): remove brittle visual test mass
Delete full-screen PTY assertions and large inline UI test modules that froze copy, geometry, and implementation shape. Keep production code intact and retain runtime, safety, integrity, protocol, and process-boundary coverage for later surgical review. Signed-off-by: Hunter Bown <[email protected]>
This commit is contained in:
1 parent
f3db55c694
commit
d64b9429b7
21 files changed
+37
-19911
No files matched your search
+10
-13
@@ -139,20 +139,17 @@ default. A single shared `CARGO_TARGET_DIR` remains valid only for
|
||||
serialized trunk work. See
|
||||
[`docs/BUILD_PERFORMANCE.md`](docs/BUILD_PERFORMANCE.md).
|
||||
|
||||
Some suites are slow, platform-bound, or intentionally excluded from the
|
||||
default run; treat them as documented isolation cases rather than
|
||||
failures of the normal gate:
|
||||
Some checks are platform-bound or intentionally excluded from an ordinary
|
||||
change. Choose them for the risk they answer rather than treating every
|
||||
available suite as ritual. Visible TUI behavior is accepted in the actual
|
||||
terminal at the sizes and interaction path affected by the change; the former
|
||||
full-screen PTY assertion suite was removed because it froze layout and copy
|
||||
while missing product quality.
|
||||
|
||||
- **PTY snapshots** (`cargo test -p codewhale-tui --test pty qa_pty
|
||||
--locked`) are Unix-only and internally serialized. One recovery-boot
|
||||
case is `#[ignore]`d for a documented input-starvation issue. When a
|
||||
PTY case fails, rerun that exact case in isolation and diagnose the
|
||||
rendered frame before calling it a flake; `run_verifiers_background_*`
|
||||
is the one known full-suite-parallelism flake that passes in
|
||||
isolation.
|
||||
- **Release runtime QA** (`cargo test -p codewhale-tui --test pty
|
||||
release_runtime_qa --locked`) includes an `#[ignore]`d 32-worker storm
|
||||
benchmark that is only run explicitly for evidence gathering.
|
||||
- **Long-running process acceptance** should use a sealed local home, local
|
||||
fixtures, and the real binary. Record the terminal size, inputs, visible
|
||||
result, and any filesystem side effect instead of adding a full-screen
|
||||
golden.
|
||||
- **OCR** (`image_ocr`) uses the macOS Vision framework or a locally
|
||||
installed `tesseract`; its platform-specific paths are
|
||||
`cfg(target_os = "macos")`-gated and depend on host tooling.
|
||||
|
||||
@@ -1274,7 +1274,7 @@ fn discover_honors_a_hidden_root_directory() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirrors the qa_pty `skills_menu_shows_local_and_global_skills`
|
||||
/// Exercises the local/global skill inventory independent of terminal layout.
|
||||
/// scenario without the PTY harness: a workspace-level skill in
|
||||
/// `.agents/skills/` and a global skill in `~/.codewhale/skills/`
|
||||
/// must both be discoverable.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1517,378 +1517,3 @@ impl ModalView for ExtensionsView {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::mcp::{McpServerCapabilityMetadata, McpServerSnapshot};
|
||||
use crate::tui::app::TuiOptions;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn item(id: &str, label: &str, description: &str) -> ExtensionItem {
|
||||
ExtensionItem {
|
||||
id: id.into(),
|
||||
label: label.into(),
|
||||
description: description.into(),
|
||||
state: "ready".into(),
|
||||
detail: format!("detail-{id}"),
|
||||
action: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot() -> ExtensionsSnapshot {
|
||||
let mut snapshot = ExtensionsSnapshot::default();
|
||||
snapshot.tabs[ExtensionsTab::Plugins.index()] = ExtensionsTabModel {
|
||||
groups: vec![
|
||||
ExtensionGroup {
|
||||
id: "recommended".into(),
|
||||
label: "Recommended".into(),
|
||||
items: vec![
|
||||
item("browser", "Browser Pack", "controls a browser"),
|
||||
item("sandbox", "Sandbox Pack", "isolates commands"),
|
||||
],
|
||||
},
|
||||
ExtensionGroup {
|
||||
id: "installed".into(),
|
||||
label: "Installed".into(),
|
||||
items: vec![item("git", "Git tools", "repository helpers")],
|
||||
},
|
||||
],
|
||||
problem: None,
|
||||
};
|
||||
snapshot
|
||||
}
|
||||
|
||||
fn render_text(view: &ExtensionsView, width: u16, height: u16) -> String {
|
||||
let backend = TestBackend::new(width, height);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|frame| frame.render_widget(ViewWidget(view), frame.area()))
|
||||
.unwrap();
|
||||
terminal
|
||||
.backend()
|
||||
.buffer()
|
||||
.content
|
||||
.iter()
|
||||
.map(|cell| cell.symbol())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn test_app(workspace: &std::path::Path) -> App {
|
||||
let mut app = App::new(
|
||||
TuiOptions {
|
||||
use_alt_screen: false,
|
||||
max_subagents: 2,
|
||||
..crate::test_support::test_tui_options(PathBuf::from(workspace))
|
||||
},
|
||||
&Config::default(),
|
||||
);
|
||||
app.plugin_registry = std::sync::Arc::new(crate::plugins::PluginRegistry::empty(workspace));
|
||||
app.mcp_config_path = workspace.join("mcp.json");
|
||||
app
|
||||
}
|
||||
|
||||
fn mcp_server(name: &str) -> McpServerSnapshot {
|
||||
McpServerSnapshot {
|
||||
name: name.into(),
|
||||
enabled: true,
|
||||
required: false,
|
||||
transport: "stdio".into(),
|
||||
command_or_url: "omitted".into(),
|
||||
connect_timeout: 10,
|
||||
execute_timeout: 60,
|
||||
read_timeout: 120,
|
||||
connected: false,
|
||||
error: None,
|
||||
capability_metadata: McpServerCapabilityMetadata::NotObserved,
|
||||
tools: Vec::new(),
|
||||
resources: Vec::new(),
|
||||
prompts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
struct ViewWidget<'a>(&'a ExtensionsView);
|
||||
|
||||
impl Widget for ViewWidget<'_> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
self.0.render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_reveals_folded_groups_without_mutating_fold_state() {
|
||||
let mut view = ExtensionsView::from_snapshot(snapshot(), ExtensionsTab::Plugins);
|
||||
view.folded_groups.insert("1:recommended".into());
|
||||
view.query = "browser".into();
|
||||
|
||||
let visible = view.visible_entries();
|
||||
|
||||
assert_eq!(visible.len(), 2);
|
||||
assert!(matches!(visible[0], VisibleEntry::Group(_)));
|
||||
assert!(matches!(visible[1], VisibleEntry::Item(_, _)));
|
||||
assert_eq!(view.folded_group_count(), 1);
|
||||
view.query.clear();
|
||||
assert_eq!(view.visible_entries().len(), 3);
|
||||
assert_eq!(view.folded_group_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_headers_are_keyboard_selectable_and_foldable() {
|
||||
let mut view = ExtensionsView::from_snapshot(snapshot(), ExtensionsTab::Plugins);
|
||||
assert!(matches!(view.visible_entries()[0], VisibleEntry::Group(_)));
|
||||
|
||||
view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert!(view.folded_groups.contains("1:recommended"));
|
||||
assert_eq!(view.visible_entries().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marketplace_enter_emits_existing_command_controller_action() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let app = test_app(temp.path());
|
||||
let mut view = ExtensionsView::new(&app, ExtensionsTab::Marketplace);
|
||||
view.selected[ExtensionsTab::Marketplace.index()] = view
|
||||
.visible_entries()
|
||||
.iter()
|
||||
.position(|entry| {
|
||||
matches!(entry, VisibleEntry::Item(_, item) if item.id == "playwright-browser")
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert!(matches!(
|
||||
action,
|
||||
ViewAction::EmitAndClose(ViewEvent::CommandPaletteSelected {
|
||||
action: CommandPaletteAction::ExecuteCommand { command }
|
||||
}) if command == "/mcp add recommended playwright"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marketplace_mouse_click_emits_the_same_existing_command_action() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let app = test_app(temp.path());
|
||||
let mut view = ExtensionsView::new(&app, ExtensionsTab::Marketplace);
|
||||
let target = view
|
||||
.visible_entries()
|
||||
.iter()
|
||||
.position(|entry| {
|
||||
matches!(entry, VisibleEntry::Item(_, item) if item.id == "playwright-browser")
|
||||
})
|
||||
.unwrap();
|
||||
let rendered = render_text(&view, 100, 24);
|
||||
assert!(rendered.contains("[add] Playwright Browser"), "{rendered}");
|
||||
let rect = view
|
||||
.hits
|
||||
.borrow()
|
||||
.rows
|
||||
.iter()
|
||||
.find_map(|(rect, row)| (*row == target).then_some(*rect))
|
||||
.unwrap();
|
||||
|
||||
let action = view.handle_mouse(MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: rect.x,
|
||||
row: rect.y,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
|
||||
assert!(matches!(
|
||||
action,
|
||||
ViewAction::EmitAndClose(ViewEvent::CommandPaletteSelected {
|
||||
action: CommandPaletteAction::ExecuteCommand { command }
|
||||
}) if command == "/mcp add recommended playwright"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommendations_are_state_aware_without_inventing_packages() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
temp.path().join("mcp.json"),
|
||||
r#"{"mcpServers":{"playwright":{"command":"npx","enabled":false},"chrome-devtools":{"command":"npx","enabled":true}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let app = test_app(temp.path());
|
||||
|
||||
let snapshot = ExtensionsSnapshot::from_app(&app);
|
||||
let rows = &snapshot.tab(ExtensionsTab::Marketplace).groups[0].items;
|
||||
let playwright = rows
|
||||
.iter()
|
||||
.find(|row| row.id == "playwright-browser")
|
||||
.unwrap();
|
||||
let chrome = rows.iter().find(|row| row.id == "chrome-devtools").unwrap();
|
||||
let cua = rows
|
||||
.iter()
|
||||
.find(|row| row.id == "cua-computer-use")
|
||||
.unwrap();
|
||||
let browser_use = rows.iter().find(|row| row.id == "browser-use").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
playwright.action.as_ref(),
|
||||
Some(ExtensionAction::Command { command, .. }) if command == "/mcp enable playwright"
|
||||
));
|
||||
assert!(matches!(
|
||||
chrome.action,
|
||||
Some(ExtensionAction::Status { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
cua.action.as_ref(),
|
||||
Some(ExtensionAction::Command { command, .. }) if command == "/mcp add recommended cua"
|
||||
));
|
||||
assert!(matches!(
|
||||
browser_use.action,
|
||||
Some(ExtensionAction::Status { .. })
|
||||
));
|
||||
assert_eq!(browser_use.action.as_ref().unwrap().label(), "unavailable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn six_configured_servers_render_six_rows_even_with_two_row_snapshot() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
temp.path().join("mcp.json"),
|
||||
r#"{"mcpServers":{"alpha":{"command":"true"},"beta":{"command":"true"},"gamma":{"command":"true"},"delta":{"command":"true"},"epsilon":{"command":"true"},"zeta":{"command":"true"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut app = test_app(temp.path());
|
||||
app.mcp_configured_count = 6;
|
||||
app.mcp_snapshot = Some(crate::mcp::McpManagerSnapshot {
|
||||
config_path: app.mcp_config_path.clone(),
|
||||
config_exists: true,
|
||||
reload_required: false,
|
||||
servers: vec![mcp_server("alpha"), mcp_server("beta")],
|
||||
});
|
||||
|
||||
let snapshot = ExtensionsSnapshot::from_app(&app);
|
||||
let groups = &snapshot.tab(ExtensionsTab::Mcp).groups;
|
||||
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(groups[0].items.len(), 6);
|
||||
let zeta = groups[0]
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| item.id == "zeta")
|
||||
.unwrap();
|
||||
assert_eq!(zeta.state, "not inspected");
|
||||
assert!(zeta.detail.contains("Not inspected"));
|
||||
assert!(matches!(
|
||||
zeta.action.as_ref(),
|
||||
Some(ExtensionAction::Command { command, .. }) if command == "/mcp reload"
|
||||
));
|
||||
|
||||
let mut view = ExtensionsView::from_snapshot(snapshot, ExtensionsTab::Mcp);
|
||||
view.selected[ExtensionsTab::Mcp.index()] = view
|
||||
.visible_entries()
|
||||
.iter()
|
||||
.position(|entry| matches!(entry, VisibleEntry::Item(_, item) if item.id == "zeta"))
|
||||
.unwrap();
|
||||
|
||||
let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert!(matches!(
|
||||
action,
|
||||
ViewAction::EmitAndClose(ViewEvent::CommandPaletteSelected {
|
||||
action: CommandPaletteAction::ExecuteCommand { command }
|
||||
}) if command == "/mcp reload"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_and_shift_tab_cycle_focus_zones() {
|
||||
let mut view = ExtensionsView::from_snapshot(snapshot(), ExtensionsTab::Plugins);
|
||||
assert_eq!(view.focus, ExtensionsFocus::List);
|
||||
view.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||
assert_eq!(view.focus, ExtensionsFocus::Tabs);
|
||||
view.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT));
|
||||
assert_eq!(view.focus, ExtensionsFocus::List);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_filters_only_the_active_tab() {
|
||||
let mut snapshot = snapshot();
|
||||
snapshot.tabs[ExtensionsTab::Hooks.index()] = ExtensionsTabModel {
|
||||
groups: vec![ExtensionGroup {
|
||||
id: "hooks".into(),
|
||||
label: "Hooks".into(),
|
||||
items: vec![item("browser-hook", "Browser hook", "browser")],
|
||||
}],
|
||||
problem: None,
|
||||
};
|
||||
let mut view = ExtensionsView::from_snapshot(snapshot, ExtensionsTab::Plugins);
|
||||
view.query = "browser".into();
|
||||
assert_eq!(view.visible_entries().len(), 2);
|
||||
view.set_tab(ExtensionsTab::Hooks);
|
||||
assert_eq!(view.visible_entries().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_40_by_12_keeps_tabs_status_focus_and_footer_but_drops_description() {
|
||||
let view = ExtensionsView::from_snapshot(snapshot(), ExtensionsTab::Plugins);
|
||||
let text = render_text(&view, 40, 12);
|
||||
assert!(text.contains("Plugins"), "{text}");
|
||||
assert!(text.contains("Search:"), "{text}");
|
||||
assert!(text.contains("Browser Pack"), "{text}");
|
||||
assert!(text.contains("Esc"), "{text}");
|
||||
assert!(!text.contains("controls a browser"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_marketplace_keeps_the_action_before_truncating_product_state() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let app = test_app(temp.path());
|
||||
let view = ExtensionsView::new(&app, ExtensionsTab::Marketplace);
|
||||
let text = render_text(&view, 40, 12);
|
||||
|
||||
assert!(text.contains("[add] Playwright Browser"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spacious_layout_includes_descriptions() {
|
||||
let view = ExtensionsView::from_snapshot(snapshot(), ExtensionsTab::Plugins);
|
||||
let text = render_text(&view, 100, 24);
|
||||
assert!(text.contains("controls a browser"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_product_is_one_row_with_real_components() {
|
||||
let product = PluginProduct {
|
||||
id: "browser-pack".into(),
|
||||
name: "Browser Pack".into(),
|
||||
description: "Browser automation".into(),
|
||||
publisher: "Example".into(),
|
||||
source_reference: "example/browser-pack@abc123".into(),
|
||||
components: vec![
|
||||
PluginProductComponent {
|
||||
kind: PluginProductComponentKind::Mcp,
|
||||
name: "Playwright".into(),
|
||||
},
|
||||
PluginProductComponent {
|
||||
kind: PluginProductComponentKind::BrowserDriver,
|
||||
name: "Chrome".into(),
|
||||
},
|
||||
],
|
||||
maturity: "reviewed".into(),
|
||||
};
|
||||
let snapshot =
|
||||
ExtensionsSnapshot::default().with_recommendations(vec![product], Locale::En);
|
||||
let rows = &snapshot.tab(ExtensionsTab::Marketplace).groups[0].items;
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert!(rows[0].detail.contains("Playwright (MCP)"));
|
||||
assert!(rows[0].detail.contains("Chrome (browser driver)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_group_header_renders_once() {
|
||||
let view = ExtensionsView::from_snapshot(snapshot(), ExtensionsTab::Plugins);
|
||||
let text = render_text(&view, 100, 24);
|
||||
|
||||
assert_eq!(text.matches("Recommended").count(), 1, "{text}");
|
||||
assert_eq!(text.matches("Installed").count(), 1, "{text}");
|
||||
}
|
||||
}
|
||||
@@ -11,16 +11,21 @@ the full `codewhale-tui` graph plus `cucumber`/`wiremock`/`rio-vt`. That was
|
||||
~26 large link jobs per `cargo test -p codewhale-tui` and a major share of the
|
||||
30-minute suite in #4991.
|
||||
|
||||
Since #5247 the 26 files are consolidated into **3 directory harnesses** (plus
|
||||
Since #5247 the remaining files are consolidated into **2 directory harnesses** (plus
|
||||
the `codewhale-tui` bin unit tests):
|
||||
|
||||
| harness | binary | what lives there | why it stays separate |
|
||||
|---|---|---|---|
|
||||
| `tests/integration/main.rs` | `integration` | 17 plain `#[test]`/`#[tokio::test]` suites: `adaptive_evidence_acceptance`, `cache_guard`, `coordination_acceptance`, `diagnostic_read_only`, `dotenv_authority`, `eval_harness`, `exec_persistent_service`, `exec_stream_drop_acceptance`, `exec_turn_usage`, `integration_mock_llm`, `palette_audit`, `protocol_recovery`, `reasoning_content_replayed_after_tool_call`, `skill_cli`, `telemetry_contract`, `verifiers_harness_contract`, `workflow_tool_stream_acceptance` | All are process-level but require no PTY or Gherkin runner; they share `wiremock`/`tempfile` and link the TUI once instead of 17 times. `crate::` for `eval`/`models`/`llm_client`/`palette`/`network_policy`/`config`/`install` is satisfied by `integration/main.rs` re-exporting those `#[path]` modules at the harness crate root so `crate::config` etc. resolve. |
|
||||
| `tests/cucumber/main.rs` | `cucumber` | 6 Gherkin runners: `core_session_command_extraction`, `directory_listing_acceptance`, `epic_acceptance_harness`, `eval_smoke_acceptance`, `plugin_e2e_acceptance`, `tool_lifecycle_acceptance` | Each defines a distinct `cucumber::World`; steps are registered per-World via inventory, so merging is safe and cuts 6 `cucumber` link jobs to 1. `plugin_e2e`’s PTY part is `#[cfg(all(unix, feature="long-running-tests"))]` and stays dormant in the default run. |
|
||||
| `tests/pty/main.rs` | `pty` | 4 real-PTY suites: `qa_pty`, `release_runtime_qa`, `terminal_matrix_qa`, `work_bar_subagents_pty` | Each boots the real binary in a `portable-pty` + `rio-vt` session. They are `#[cfg(unix)]` and serialize on `QA_PTY_TEST_LOCK` etc.; one binary links `rio-vt`/`portable-pty` once instead of 4 times. |
|
||||
The former `tests/pty` harness was removed. It accumulated full-screen copy,
|
||||
color, timing, and geometry assertions that were expensive to link and made the
|
||||
implementation serve a simulated terminal. Visible UX is now accepted against
|
||||
the actual binary in the terminal being changed.
|
||||
|
||||
`ls crates/tui/tests/*.rs | wc -l` is now **0** (all `*.rs` live under `integration/`, `cucumber/`, `pty/`). The surviving binaries are the 3 directory harnesses above.
|
||||
`ls crates/tui/tests/*.rs | wc -l` is **0** (all `*.rs` live under
|
||||
`integration/` and `cucumber/`). The surviving binaries are the 2 directory
|
||||
harnesses above.
|
||||
|
||||
Filtering still works via the module path:
|
||||
|
||||
@@ -28,7 +33,6 @@ Filtering still works via the module path:
|
||||
cargo test -p codewhale-tui --tests -- --list | grep adaptive_evidence
|
||||
cargo test -p codewhale-tui --test integration adaptive_evidence_acceptance -- --nocapture
|
||||
cargo test -p codewhale-tui --test cucumber tool_lifecycle -- --nocapture
|
||||
cargo test -p codewhale-tui --test pty qa_pty -- --nocapture
|
||||
```
|
||||
|
||||
The shared helpers in `crates/tui/tests/support/` (`qa_harness`, `llm_client`) and fixtures in `crates/tui/tests/fixtures/` are untouched — harnesses reach them via `../support`.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
Feature: Tool call lifecycle
|
||||
Scenario: Happy path lists the current directory through a tool
|
||||
# This executable slice asserts the public exec stream and mocked LLM border.
|
||||
# The real Unix PTY screen slice in qa_pty.rs asserts the matching Work,
|
||||
# statusline, idle BlueWhale, live tool-card, and settled-transcript border.
|
||||
# Visible Work, status, tool-card, and transcript behavior is inspected in
|
||||
# the actual terminal when that product surface changes.
|
||||
Given an offline CodeWhale workspace containing:
|
||||
| path | kind |
|
||||
| README.md | file |
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
//! Process-boundary acceptance for delegated coordination (#4647).
|
||||
//!
|
||||
//! Typed event/App/Work rendering assertions live beside their production
|
||||
//! modules, and `qa_pty::real_coordination_details_*` drives the sealed binary.
|
||||
//! This target retains the real-Git fan-in proof: terminal reconciliation must
|
||||
//! keep both candidates available instead of reducing them to source strings.
|
||||
//! Visible focus and composer behavior is accepted in the actual terminal.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
@@ -1,592 +0,0 @@
|
||||
//! Agent focus journey against the real binary in a real PTY.
|
||||
//!
|
||||
//! Two detached workers are spawned by a loopback provider and held open, so
|
||||
//! they stay `running`. The probe then drives the keyboard contract exactly as
|
||||
//! a user would: `←` on an empty composer enters the agent list, `Enter`
|
||||
//! focuses a worker (its transcript owns the conversation area and the
|
||||
//! composer chip names it), a typed follow-up is delivered to that worker and
|
||||
//! the rail counts it as queued until the child's next round, `Esc` returns
|
||||
//! to the main conversation which keeps a one-line receipt, and `↓` opens the
|
||||
//! manage register.
|
||||
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::qa_harness::harness::{Harness, SealedWorkspace, make_sealed_workspace};
|
||||
use crate::qa_harness::keys;
|
||||
use anyhow::{Result, anyhow};
|
||||
use serde_json::{Value, json};
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
|
||||
|
||||
const BOOT_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const INTERACTION_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const PASTE_GUARD_SETTLE: Duration = Duration::from_millis(180);
|
||||
const COMPOSER_READY_TEXT: &str = "Write a task";
|
||||
const MODEL: &str = "deepseek-v4-pro";
|
||||
const PARENT_PROMPT: &str = "spawn the focus probe workers now";
|
||||
const CHILD_MARKER: &str = "focusprobe";
|
||||
/// Empty-composer hint while a worker is focused.
|
||||
const FOCUS_MARKER: &str = "Esc returns to main";
|
||||
const FOLLOW_UP: &str = "focus-follow-up-please-continue";
|
||||
|
||||
static AGENT_FOCUS_PTY_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
fn sse_chunk(value: Value) -> String {
|
||||
format!(
|
||||
"data: {}\n\n",
|
||||
serde_json::to_string(&value).expect("SSE JSON")
|
||||
)
|
||||
}
|
||||
|
||||
fn text_sse(text: &str) -> String {
|
||||
[
|
||||
sse_chunk(json!({
|
||||
"id": "chatcmpl-focus",
|
||||
"object": "chat.completion.chunk",
|
||||
"model": MODEL,
|
||||
"choices": [{"index": 0, "delta": {"content": text}, "finish_reason": null}]
|
||||
})),
|
||||
sse_chunk(json!({
|
||||
"id": "chatcmpl-focus",
|
||||
"object": "chat.completion.chunk",
|
||||
"model": MODEL,
|
||||
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": 12, "completion_tokens": 4, "total_tokens": 16}
|
||||
})),
|
||||
"data: [DONE]\n\n".to_string(),
|
||||
]
|
||||
.join("")
|
||||
}
|
||||
|
||||
fn agent_tool_call_sse(count: usize) -> String {
|
||||
let tool_calls = (1..=count)
|
||||
.map(|worker| {
|
||||
json!({
|
||||
"index": worker - 1,
|
||||
"id": format!("call_focus_{worker}"),
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "agent",
|
||||
"arguments": serde_json::to_string(&json!({
|
||||
"action": "start",
|
||||
"detached": true,
|
||||
"prompt": format!("{CHILD_MARKER}{worker} keep working"),
|
||||
"type": "explorer",
|
||||
"fork_context": false,
|
||||
"session_name": format!("focus-{worker}")
|
||||
}))
|
||||
.expect("agent arguments")
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
[
|
||||
sse_chunk(json!({
|
||||
"id": "chatcmpl-focus-fanout",
|
||||
"object": "chat.completion.chunk",
|
||||
"model": MODEL,
|
||||
"choices": [{"index": 0, "delta": {"tool_calls": tool_calls}, "finish_reason": null}]
|
||||
})),
|
||||
sse_chunk(json!({
|
||||
"id": "chatcmpl-focus-fanout",
|
||||
"object": "chat.completion.chunk",
|
||||
"model": MODEL,
|
||||
"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}],
|
||||
"usage": {"prompt_tokens": 20, "completion_tokens": 12, "total_tokens": 32}
|
||||
})),
|
||||
"data: [DONE]\n\n".to_string(),
|
||||
]
|
||||
.join("")
|
||||
}
|
||||
|
||||
fn sse_response(body: String) -> ResponseTemplate {
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "text/event-stream")
|
||||
.insert_header("cache-control", "no-cache")
|
||||
.set_body_string(body)
|
||||
}
|
||||
|
||||
fn json_response(value: Value) -> ResponseTemplate {
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "application/json")
|
||||
.set_body_json(value)
|
||||
}
|
||||
|
||||
fn chat_message_response(text: &str) -> ResponseTemplate {
|
||||
json_response(json!({
|
||||
"id": "chatcmpl-focus-child",
|
||||
"object": "chat.completion",
|
||||
"model": MODEL,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": text},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 12, "completion_tokens": 4, "total_tokens": 16}
|
||||
}))
|
||||
}
|
||||
|
||||
async fn mount_models(server: &MockServer) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/models"))
|
||||
.respond_with(json_response(json!({
|
||||
"object": "list",
|
||||
"data": [{"id": MODEL, "object": "model"}]
|
||||
})))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
struct ProbeResponder {
|
||||
child_requests: Arc<AtomicUsize>,
|
||||
parent_turns: Arc<AtomicUsize>,
|
||||
workers: usize,
|
||||
child_hold: Duration,
|
||||
}
|
||||
|
||||
impl Respond for ProbeResponder {
|
||||
fn respond(&self, request: &Request) -> ResponseTemplate {
|
||||
let raw = request
|
||||
.body_json::<Value>()
|
||||
.unwrap_or(Value::Null)
|
||||
.to_string();
|
||||
if raw.contains(CHILD_MARKER) && !raw.contains(PARENT_PROMPT) {
|
||||
self.child_requests.fetch_add(1, Ordering::SeqCst);
|
||||
return chat_message_response("focus child receipt").set_delay(self.child_hold);
|
||||
}
|
||||
if raw.contains(PARENT_PROMPT) {
|
||||
if self.parent_turns.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
return sse_response(agent_tool_call_sse(self.workers));
|
||||
}
|
||||
return sse_response(text_sse("focus parent wrapped up"));
|
||||
}
|
||||
sse_response(text_sse("unexpected-request"))
|
||||
}
|
||||
}
|
||||
|
||||
fn tui_builder(
|
||||
ws: &SealedWorkspace,
|
||||
server_uri: &str,
|
||||
) -> crate::qa_harness::harness::HarnessBuilder {
|
||||
tui_builder_with_posture(ws, server_uri, true)
|
||||
}
|
||||
|
||||
fn tui_builder_with_posture(
|
||||
ws: &SealedWorkspace,
|
||||
server_uri: &str,
|
||||
yolo: bool,
|
||||
) -> crate::qa_harness::harness::HarnessBuilder {
|
||||
let mut args = vec![
|
||||
"--workspace".to_string(),
|
||||
ws.workspace()
|
||||
.to_str()
|
||||
.expect("utf-8 workspace path")
|
||||
.to_string(),
|
||||
"--no-project-config".to_string(),
|
||||
"--skip-onboarding".to_string(),
|
||||
];
|
||||
if yolo {
|
||||
args.push("--yolo".to_string());
|
||||
}
|
||||
args.extend(["--max-subagents".to_string(), "2".to_string()]);
|
||||
Harness::builder(Harness::cargo_bin("codewhale-tui"))
|
||||
.cwd(ws.workspace())
|
||||
.clear_env()
|
||||
.seal_home(ws.home())
|
||||
.env("RUST_LOG", "warn")
|
||||
.env("NO_ANIMATIONS", "1")
|
||||
.env("CODEWHALE_PROVIDER", "deepseek")
|
||||
.env("DEEPSEEK_API_KEY", "deepseek-local-test-key")
|
||||
.env("DEEPSEEK_BASE_URL", server_uri.to_string())
|
||||
.env("DEEPSEEK_MODEL", MODEL)
|
||||
.args(args)
|
||||
.size(pty_rows(), pty_cols())
|
||||
}
|
||||
|
||||
/// Optional capture/size overrides so the same journey can be run at other
|
||||
/// terminal sizes and leave real frames behind as evidence:
|
||||
/// `CODEWHALE_FOCUS_PTY_SIZE=16x60 CODEWHALE_FOCUS_CAPTURE_DIR=/tmp/caps`.
|
||||
fn pty_size() -> (u16, u16) {
|
||||
std::env::var("CODEWHALE_FOCUS_PTY_SIZE")
|
||||
.ok()
|
||||
.and_then(|raw| {
|
||||
let (rows, cols) = raw.split_once('x')?;
|
||||
Some((rows.trim().parse().ok()?, cols.trim().parse().ok()?))
|
||||
})
|
||||
.unwrap_or((42, 150))
|
||||
}
|
||||
|
||||
fn pty_rows() -> u16 {
|
||||
pty_size().0
|
||||
}
|
||||
|
||||
fn pty_cols() -> u16 {
|
||||
pty_size().1
|
||||
}
|
||||
|
||||
fn capture(harness: &mut Harness, name: &str) {
|
||||
let Ok(dir) = std::env::var("CODEWHALE_FOCUS_CAPTURE_DIR") else {
|
||||
return;
|
||||
};
|
||||
let (rows, cols) = pty_size();
|
||||
let dir = std::path::Path::new(&dir).join(format!("{rows}x{cols}"));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let _ = std::fs::write(dir.join(format!("{name}.txt")), harness.debug_dump());
|
||||
}
|
||||
|
||||
fn wait_for_counter(
|
||||
harness: &mut Harness,
|
||||
counter: &AtomicUsize,
|
||||
expected: usize,
|
||||
timeout: Duration,
|
||||
) -> Result<()> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
harness.pump();
|
||||
if counter.load(Ordering::SeqCst) >= expected {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(code) = harness.wait_for_exit(Duration::from_millis(0)) {
|
||||
return Err(anyhow!(
|
||||
"codewhale-tui exited with {code} before the counter reached {expected}\n{}",
|
||||
harness.debug_dump()
|
||||
));
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(anyhow!(
|
||||
"counter did not reach {expected} within {timeout:?}; observed {}\n{}",
|
||||
counter.load(Ordering::SeqCst),
|
||||
harness.debug_dump()
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(40));
|
||||
}
|
||||
}
|
||||
|
||||
fn type_and_submit(harness: &mut Harness, text: &str) -> Result<()> {
|
||||
harness.send(keys::key::text(text))?;
|
||||
harness.wait_for_text(text, Duration::from_secs(5))?;
|
||||
std::thread::sleep(PASTE_GUARD_SETTLE);
|
||||
harness.pump();
|
||||
harness.send(keys::key::enter())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `←` then Enter after the parent turn has settled on a still-running row.
|
||||
///
|
||||
/// A 200ms sleep between those keys is a race: completion redraws drop rail
|
||||
/// focus and Enter becomes a composer no-op. Wait for the live row, then
|
||||
/// send the keys back-to-back.
|
||||
fn focus_listed_worker(harness: &mut Harness, worker: &str) -> Result<()> {
|
||||
harness.wait_for_text("for agents", INTERACTION_TIMEOUT)?;
|
||||
harness.wait_for_text("to manage", INTERACTION_TIMEOUT)?;
|
||||
harness.wait_for_text(COMPOSER_READY_TEXT, INTERACTION_TIMEOUT)?;
|
||||
harness.wait_for_text("✓ done", INTERACTION_TIMEOUT)?;
|
||||
harness.wait_for(
|
||||
|frame| {
|
||||
let text = frame.text();
|
||||
text.contains(worker) && !text.contains("completed")
|
||||
},
|
||||
INTERACTION_TIMEOUT,
|
||||
)?;
|
||||
let mut chord = keys::key::left();
|
||||
chord.extend(keys::key::enter());
|
||||
harness.send(chord)?;
|
||||
harness
|
||||
.wait_for_text(FOCUS_MARKER, INTERACTION_TIMEOUT)
|
||||
.map_err(|_| {
|
||||
anyhow!(
|
||||
"← then Enter did not focus the worker\n{}",
|
||||
harness.debug_dump()
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn focus_a_worker_send_a_follow_up_and_return_to_main() -> Result<()> {
|
||||
let _guard = AGENT_FOCUS_PTY_LOCK.lock().await;
|
||||
let server = MockServer::start().await;
|
||||
mount_models(&server).await;
|
||||
let child_requests = Arc::new(AtomicUsize::new(0));
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/chat/completions"))
|
||||
.respond_with(ProbeResponder {
|
||||
child_requests: Arc::clone(&child_requests),
|
||||
parent_turns: Arc::new(AtomicUsize::new(0)),
|
||||
workers: 2,
|
||||
child_hold: Duration::from_secs(40),
|
||||
})
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let ws = make_sealed_workspace()?;
|
||||
std::fs::write(
|
||||
ws.home().join(".codewhale").join("config.toml"),
|
||||
"[subagents]\nmax_concurrent = 2\nlaunch_concurrency = 2\nmax_admitted = 2\n",
|
||||
)?;
|
||||
let mut tui = tui_builder(&ws, &server.uri()).spawn()?;
|
||||
tui.wait_for_text(COMPOSER_READY_TEXT, BOOT_TIMEOUT)?;
|
||||
type_and_submit(&mut tui, PARENT_PROMPT)?;
|
||||
wait_for_counter(&mut tui, &child_requests, 2, INTERACTION_TIMEOUT)?;
|
||||
// Wait until the parent turn has wrapped so the composer is idle and the
|
||||
// footer advertises the agent keys.
|
||||
tui.wait_for_text("focus parent wrapped up", INTERACTION_TIMEOUT)?;
|
||||
tui.wait_for_text("for agents", INTERACTION_TIMEOUT)
|
||||
.map_err(|_| {
|
||||
anyhow!(
|
||||
"footer never advertised `← for agents` while workers exist\n{}",
|
||||
tui.debug_dump()
|
||||
)
|
||||
})?;
|
||||
tui.wait_for_text("to manage", Duration::from_secs(2))?;
|
||||
capture(&mut tui, "01-main-with-workers");
|
||||
|
||||
// ← enters the agent list; Enter focuses the selected worker.
|
||||
tui.send(b"\x1b[D")?; // Left
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
tui.pump();
|
||||
capture(&mut tui, "02-agent-list-entered");
|
||||
tui.send(keys::key::enter())?;
|
||||
tui.wait_for_text(FOCUS_MARKER, Duration::from_secs(5))
|
||||
.map_err(|_| anyhow!("← then Enter did not focus a worker\n{}", tui.debug_dump()))?;
|
||||
// The composer chip names the addressed fork and the rail marks the row.
|
||||
let focused = tui.debug_dump();
|
||||
assert!(
|
||||
focused.contains("→ ") && focused.contains("❯ "),
|
||||
"focused frame lacks the composer chip or the rail marker:\n{focused}"
|
||||
);
|
||||
capture(&mut tui, "03-worker-focused");
|
||||
|
||||
// A follow-up goes to that worker: echoed in the focused view, counted as
|
||||
// queued on its rail row (the child is mid-round, held by the provider),
|
||||
// and the main transcript keeps a one-line receipt.
|
||||
type_and_submit(&mut tui, FOLLOW_UP)?;
|
||||
tui.wait_for_text("1 queued", INTERACTION_TIMEOUT)
|
||||
.map_err(|_| {
|
||||
anyhow!(
|
||||
"rail row never counted the queued follow-up\n{}",
|
||||
tui.debug_dump()
|
||||
)
|
||||
})?;
|
||||
// The echoed message and its receipt sit at the tail of the focused view;
|
||||
// on a 16-row terminal only the receipt may still be on screen.
|
||||
tui.wait_for(
|
||||
|frame| {
|
||||
let text = frame.text();
|
||||
text.contains(FOLLOW_UP) || text.contains("Queued for")
|
||||
},
|
||||
Duration::from_secs(5),
|
||||
)?;
|
||||
capture(&mut tui, "04-follow-up-queued");
|
||||
|
||||
// Esc on the empty composer returns to main; the receipt line stays.
|
||||
tui.send(keys::key::esc())?;
|
||||
tui.wait_for(
|
||||
|frame| !frame.text().contains(FOCUS_MARKER),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.map_err(|_| anyhow!("Esc did not leave focus\n{}", tui.debug_dump()))?;
|
||||
tui.wait_for_text("Queued for", Duration::from_secs(5))
|
||||
.map_err(|_| {
|
||||
anyhow!(
|
||||
"main transcript lacks the queued receipt line\n{}",
|
||||
tui.debug_dump()
|
||||
)
|
||||
})?;
|
||||
|
||||
capture(&mut tui, "05-back-to-main");
|
||||
|
||||
// ↓ opens the manage register.
|
||||
tui.send(b"\x1b[B")?; // Down
|
||||
tui.wait_for_text("stop", Duration::from_secs(5))
|
||||
.map_err(|_| {
|
||||
anyhow!(
|
||||
"↓ did not open the agents register with its stop action\n{}",
|
||||
tui.debug_dump()
|
||||
)
|
||||
})?;
|
||||
capture(&mut tui, "06-manage-register");
|
||||
tui.send(keys::key::esc())?;
|
||||
|
||||
let _ = tui.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- Auto-Review inside a worker: the guardian decides, the receipt lands in the child's transcript ----
|
||||
|
||||
const GATE_PARENT_PROMPT: &str = "spawn the gate probe worker now";
|
||||
const GATE_CHILD_MARKER: &str = "gateprobe";
|
||||
const GATE_ECHO: &str = "gate-probe-output";
|
||||
|
||||
/// Serves the parent (streaming), the child (non-streaming: one `bash` call,
|
||||
/// then a held wrap-up so the worker stays `running` while we focus), and the
|
||||
/// Auto-Review guardian (non-streaming verdict) from one loopback endpoint,
|
||||
/// telling them apart by body shape.
|
||||
struct GateResponder {
|
||||
child_rounds: Arc<AtomicUsize>,
|
||||
guardian_requests: Arc<AtomicUsize>,
|
||||
parent_turns: Arc<AtomicUsize>,
|
||||
child_hold: Duration,
|
||||
}
|
||||
|
||||
impl Respond for GateResponder {
|
||||
fn respond(&self, request: &Request) -> ResponseTemplate {
|
||||
let raw = request
|
||||
.body_json::<Value>()
|
||||
.unwrap_or(Value::Null)
|
||||
.to_string();
|
||||
if raw.contains("proposed_tool_call") {
|
||||
self.guardian_requests.fetch_add(1, Ordering::SeqCst);
|
||||
return chat_message_response(
|
||||
r#"{"risk_level":"low","decision":"allow","reason":"echo through cat stays inside the workspace"}"#,
|
||||
);
|
||||
}
|
||||
if raw.contains(GATE_CHILD_MARKER) && !raw.contains(GATE_PARENT_PROMPT) {
|
||||
let round = self.child_rounds.fetch_add(1, Ordering::SeqCst);
|
||||
if round == 0 {
|
||||
return json_response(json!({
|
||||
"id": "chatcmpl-gate-child",
|
||||
"object": "chat.completion",
|
||||
"model": MODEL,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"tool_calls": [{
|
||||
"id": "call_gate_child_bash",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"arguments": serde_json::to_string(&json!({
|
||||
"command": format!("echo {GATE_ECHO} | cat")
|
||||
})).expect("bash arguments")
|
||||
}
|
||||
}]
|
||||
},
|
||||
"finish_reason": "tool_calls"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 12, "completion_tokens": 6, "total_tokens": 18}
|
||||
}));
|
||||
}
|
||||
return chat_message_response("gate child wrapped up").set_delay(self.child_hold);
|
||||
}
|
||||
if raw.contains(GATE_PARENT_PROMPT) {
|
||||
if self.parent_turns.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
let tool_calls = vec![json!({
|
||||
"index": 0,
|
||||
"id": "call_gate_worker",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "agent",
|
||||
"arguments": serde_json::to_string(&json!({
|
||||
"action": "start",
|
||||
"detached": true,
|
||||
"prompt": format!("{GATE_CHILD_MARKER} run the probe command"),
|
||||
"type": "worker",
|
||||
"fork_context": false,
|
||||
"session_name": "gate-worker"
|
||||
}))
|
||||
.expect("agent arguments")
|
||||
}
|
||||
})];
|
||||
return sse_response(
|
||||
[
|
||||
sse_chunk(json!({
|
||||
"id": "chatcmpl-gate-fanout",
|
||||
"object": "chat.completion.chunk",
|
||||
"model": MODEL,
|
||||
"choices": [{"index": 0, "delta": {"tool_calls": tool_calls}, "finish_reason": null}]
|
||||
})),
|
||||
sse_chunk(json!({
|
||||
"id": "chatcmpl-gate-fanout",
|
||||
"object": "chat.completion.chunk",
|
||||
"model": MODEL,
|
||||
"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}],
|
||||
"usage": {"prompt_tokens": 20, "completion_tokens": 12, "total_tokens": 32}
|
||||
})),
|
||||
"data: [DONE]\n\n".to_string(),
|
||||
]
|
||||
.join(""),
|
||||
);
|
||||
}
|
||||
return sse_response(text_sse("gate parent wrapped up"));
|
||||
}
|
||||
sse_response(text_sse("unexpected-request"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-Review parity for a worker: the child's held `bash` call goes to the
|
||||
/// same one-shot guardian the parent uses (one guardian request, no prompt),
|
||||
/// runs after the allow, and the one-line receipt is visible when the worker
|
||||
/// is focused.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn auto_review_gates_a_workers_call_and_the_receipt_shows_in_focus() -> Result<()> {
|
||||
let _guard = AGENT_FOCUS_PTY_LOCK.lock().await;
|
||||
let server = MockServer::start().await;
|
||||
mount_models(&server).await;
|
||||
let child_rounds = Arc::new(AtomicUsize::new(0));
|
||||
let guardian_requests = Arc::new(AtomicUsize::new(0));
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/chat/completions"))
|
||||
.respond_with(GateResponder {
|
||||
child_rounds: Arc::clone(&child_rounds),
|
||||
guardian_requests: Arc::clone(&guardian_requests),
|
||||
parent_turns: Arc::new(AtomicUsize::new(0)),
|
||||
// Hold the wrap-up, not the bash call: the guardian receipt is
|
||||
// written when bash is allowed, and ← focuses a *running* row
|
||||
// the same way the sibling journey does.
|
||||
child_hold: Duration::from_secs(40),
|
||||
})
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let ws = make_sealed_workspace()?;
|
||||
std::fs::write(
|
||||
ws.home().join(".codewhale").join("config.toml"),
|
||||
"[subagents]\nmax_concurrent = 2\nlaunch_concurrency = 2\nmax_admitted = 2\n",
|
||||
)?;
|
||||
std::fs::write(
|
||||
ws.home().join(".codewhale").join("settings.toml"),
|
||||
"locale = \"en\"\ndefault_mode = \"agent\"\npermission_posture = \"auto-review\"\nlow_motion = true\nfancy_animations = false\n",
|
||||
)?;
|
||||
let mut tui = tui_builder_with_posture(&ws, &server.uri(), false).spawn()?;
|
||||
tui.wait_for_text(COMPOSER_READY_TEXT, BOOT_TIMEOUT)?;
|
||||
type_and_submit(&mut tui, GATE_PARENT_PROMPT)?;
|
||||
// Child bash → guardian → the call runs → child wrap-up request (held).
|
||||
wait_for_counter(&mut tui, &guardian_requests, 1, INTERACTION_TIMEOUT)?;
|
||||
wait_for_counter(&mut tui, &child_rounds, 2, INTERACTION_TIMEOUT)?;
|
||||
tui.wait_for_text("gate parent wrapped up", INTERACTION_TIMEOUT)?;
|
||||
// No approval prompt was ever opened for the child.
|
||||
assert!(
|
||||
!tui.debug_dump().contains("Allow once"),
|
||||
"Auto-Review must not prompt for a worker's call:\n{}",
|
||||
tui.debug_dump()
|
||||
);
|
||||
capture(&mut tui, "10-gate-main");
|
||||
|
||||
// Focus the still-running worker: its transcript carries the receipt.
|
||||
focus_listed_worker(&mut tui, "gate-worker")?;
|
||||
tui.wait_for(
|
||||
|frame| {
|
||||
let text = frame.text();
|
||||
text.contains("Auto-Review allowed 'bash'") && text.contains("model guardian")
|
||||
},
|
||||
INTERACTION_TIMEOUT,
|
||||
)
|
||||
.map_err(|_| {
|
||||
anyhow!(
|
||||
"focused worker transcript lacks the guardian receipt\n{}",
|
||||
tui.debug_dump()
|
||||
)
|
||||
})?;
|
||||
capture(&mut tui, "11-gate-focused-receipt");
|
||||
tui.send(keys::key::esc())?;
|
||||
let _ = tui.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
//! Real-PTY journey for `/fleet setup`: create a project profile, reopen the
|
||||
//! wizard for the same role, see the replace warning, switch the destination
|
||||
//! to Personal, and save — with the resolved absolute path visible on the
|
||||
//! Destination and Review screens before anything is written.
|
||||
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::qa_harness::harness::{Harness, make_sealed_workspace};
|
||||
use crate::qa_harness::keys;
|
||||
|
||||
static FLEET_SETUP_PTY_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn lock() -> MutexGuard<'static, ()> {
|
||||
FLEET_SETUP_PTY_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
const BOOT_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const STEP_TIMEOUT: Duration = Duration::from_secs(6);
|
||||
const PASTE_GUARD_SETTLE: Duration = Duration::from_millis(250);
|
||||
|
||||
fn type_and_submit(h: &mut Harness, text: &str) -> anyhow::Result<()> {
|
||||
h.send(keys::key::text(text))?;
|
||||
h.wait_for_text(text, STEP_TIMEOUT)?;
|
||||
std::thread::sleep(PASTE_GUARD_SETTLE);
|
||||
h.pump();
|
||||
h.send(keys::key::enter())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn press(h: &mut Harness, bytes: Vec<u8>) -> anyhow::Result<()> {
|
||||
h.send(bytes)?;
|
||||
std::thread::sleep(Duration::from_millis(120));
|
||||
h.pump();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fleet_setup_journey_shows_destination_before_writing_and_never_replaces_silently()
|
||||
-> anyhow::Result<()> {
|
||||
let _guard = lock();
|
||||
let ws = make_sealed_workspace()?;
|
||||
let codewhale_home = ws.home().join(".codewhale");
|
||||
let mut h = Harness::builder(Harness::cargo_bin("codewhale-tui"))
|
||||
.cwd(ws.workspace())
|
||||
.clear_env()
|
||||
.seal_home(ws.home())
|
||||
.env(
|
||||
"CODEWHALE_HOME",
|
||||
codewhale_home.to_str().expect("utf-8 home"),
|
||||
)
|
||||
.env("DEEPSEEK_API_KEY", "ci-test-key-not-real")
|
||||
.env("DEEPSEEK_BASE_URL", "http://127.0.0.1:1")
|
||||
.env("NO_ANIMATIONS", "1")
|
||||
.env("RUST_LOG", "warn")
|
||||
// No --no-project-config: project profiles must be a real, enabled
|
||||
// destination for this journey.
|
||||
.args([
|
||||
"--workspace",
|
||||
ws.workspace().to_str().expect("utf-8 workspace path"),
|
||||
"--skip-onboarding",
|
||||
])
|
||||
.size(32, 120)
|
||||
.spawn()?;
|
||||
h.wait_for_text("Write a task", BOOT_TIMEOUT)?;
|
||||
|
||||
// --- Create: manager · inherit · This project -----------------------
|
||||
type_and_submit(&mut h, "/fleet setup")?;
|
||||
h.wait_for_text("Choose a team role", STEP_TIMEOUT)?;
|
||||
// The destination is announced before it is chosen.
|
||||
h.wait_for_text("Saves to: choose in step 3", STEP_TIMEOUT)?;
|
||||
press(&mut h, keys::key::enter())?; // manager -> Model
|
||||
h.wait_for_text("Choose a model", STEP_TIMEOUT)?;
|
||||
press(&mut h, keys::key::enter())?; // inherit -> Destination
|
||||
h.wait_for_text("Where should this profile live?", STEP_TIMEOUT)?;
|
||||
h.wait_for_text("This project", STEP_TIMEOUT)?;
|
||||
h.wait_for_text("Personal", STEP_TIMEOUT)?;
|
||||
press(&mut h, keys::key::up())?; // highlight "This project"
|
||||
let project_target = ws
|
||||
.workspace()
|
||||
.join(".codewhale")
|
||||
.join("agents")
|
||||
.join("manager.toml");
|
||||
h.wait_for(
|
||||
|frame| {
|
||||
// The destination path can hard-wrap mid-token when the temp
|
||||
// directory is long (macOS /var/folders, Windows Temp, VIX
|
||||
// volumes), so compare with whitespace and borders removed.
|
||||
let text: String = frame
|
||||
.text()
|
||||
.chars()
|
||||
.filter(|c| !c.is_whitespace() && *c != '│')
|
||||
.collect();
|
||||
text.contains("File:") && text.contains("agents") && text.contains("manager.toml")
|
||||
},
|
||||
STEP_TIMEOUT,
|
||||
)?;
|
||||
press(&mut h, keys::key::enter())?; // -> Review
|
||||
h.wait_for_text("Review & save", STEP_TIMEOUT)?;
|
||||
h.wait_for_text("Saves to: This project", STEP_TIMEOUT)?;
|
||||
h.wait_for_text("Save to this project", STEP_TIMEOUT)?;
|
||||
assert!(
|
||||
!project_target.exists(),
|
||||
"nothing may be written before the save control is activated"
|
||||
);
|
||||
// Tab moves focus; it must not change the destination.
|
||||
press(&mut h, keys::key::tab())?;
|
||||
h.wait_for_text("▸ Change destination", STEP_TIMEOUT)?;
|
||||
h.wait_for_text("Saves to: This project", STEP_TIMEOUT)?;
|
||||
press(&mut h, keys::key::backtab())?;
|
||||
h.wait_for_text("▸ Save to this project", STEP_TIMEOUT)?;
|
||||
press(&mut h, keys::key::enter())?; // save
|
||||
h.wait_for_text("Fleet project profile saved", STEP_TIMEOUT)?;
|
||||
assert!(project_target.is_file(), "{}", project_target.display());
|
||||
|
||||
// --- Reopen: same role, replace warning, switch to Personal ---------
|
||||
type_and_submit(&mut h, "/fleet setup")?;
|
||||
h.wait_for_text("Choose a team role", STEP_TIMEOUT)?;
|
||||
press(&mut h, keys::key::enter())?; // manager
|
||||
h.wait_for_text("Choose a model", STEP_TIMEOUT)?;
|
||||
press(&mut h, keys::key::enter())?; // inherit
|
||||
h.wait_for_text("Where should this profile live?", STEP_TIMEOUT)?;
|
||||
press(&mut h, keys::key::up())?; // This project
|
||||
h.wait_for_text("Will replace the existing file", STEP_TIMEOUT)?;
|
||||
press(&mut h, keys::key::down())?; // Personal
|
||||
let personal_target = codewhale_home.join("agents").join("manager.toml");
|
||||
h.wait_for(
|
||||
|frame| {
|
||||
let text = frame.text();
|
||||
text.contains("already has a") && !text.contains("Will replace")
|
||||
},
|
||||
STEP_TIMEOUT,
|
||||
)?;
|
||||
press(&mut h, keys::key::enter())?; // -> Review
|
||||
h.wait_for_text("Saves to: Personal", STEP_TIMEOUT)?;
|
||||
h.wait_for_text("Save as Personal profile", STEP_TIMEOUT)?;
|
||||
assert!(!personal_target.exists());
|
||||
press(&mut h, keys::key::enter())?;
|
||||
h.wait_for_text("Fleet personal profile saved", STEP_TIMEOUT)?;
|
||||
assert!(personal_target.is_file(), "{}", personal_target.display());
|
||||
// The project file was not touched by the personal save.
|
||||
assert!(project_target.is_file());
|
||||
|
||||
// --- Replace requires a second Enter --------------------------------
|
||||
type_and_submit(&mut h, "/fleet setup")?;
|
||||
h.wait_for_text("Choose a team role", STEP_TIMEOUT)?;
|
||||
press(&mut h, keys::key::enter())?;
|
||||
h.wait_for_text("Choose a model", STEP_TIMEOUT)?;
|
||||
press(&mut h, keys::key::enter())?;
|
||||
h.wait_for_text("Where should this profile live?", STEP_TIMEOUT)?;
|
||||
// Personal is highlighted (last chosen); it now exists.
|
||||
h.wait_for_text("Will replace the existing file", STEP_TIMEOUT)?;
|
||||
let before = std::fs::metadata(&personal_target)?.modified()?;
|
||||
press(&mut h, keys::key::enter())?; // -> Review
|
||||
h.wait_for_text("Replace Personal profile", STEP_TIMEOUT)?;
|
||||
press(&mut h, keys::key::enter())?; // arms
|
||||
h.wait_for_text("Press Enter again to replace manager.toml", STEP_TIMEOUT)?;
|
||||
assert_eq!(std::fs::metadata(&personal_target)?.modified()?, before);
|
||||
press(&mut h, keys::key::esc())?; // back to Destination — disarms, nothing written
|
||||
h.wait_for_text("Where should this profile live?", STEP_TIMEOUT)?;
|
||||
assert_eq!(std::fs::metadata(&personal_target)?.modified()?, before);
|
||||
press(&mut h, keys::key::esc())?;
|
||||
press(&mut h, keys::key::esc())?;
|
||||
press(&mut h, keys::key::esc())?; // Role -> close
|
||||
h.wait_for_text("Write a task", STEP_TIMEOUT)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
//! Consolidated harness for real-PTY scenarios.
|
||||
//!
|
||||
//! Every test here boots the real `codewhale-tui` binary inside a
|
||||
//! `portable-pty` session and drives it through `support/qa_harness`. They are
|
||||
//! `#[cfg(unix)]` and contend for the terminal, so they serialize on a
|
||||
//! per-harness mutex already. One binary links `rio-vt` + `portable-pty` once
|
||||
//! instead of four times. See `crates/tui/tests/README.md`.
|
||||
|
||||
// The qa_harness module is declared exactly once at the crate root; the
|
||||
// scenario modules below reference it via `use qa_harness::...`. Declaring
|
||||
// it per-scenario would load the same files as separate modules in one
|
||||
// binary (clippy::duplicate_mod).
|
||||
#[cfg(unix)]
|
||||
#[path = "../support/qa_harness/mod.rs"]
|
||||
mod qa_harness;
|
||||
|
||||
mod agent_focus_pty;
|
||||
mod fleet_setup_pty;
|
||||
mod qa_pty;
|
||||
mod release_runtime_qa;
|
||||
mod terminal_matrix_qa;
|
||||
mod work_bar_subagents_pty;
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,925 +0,0 @@
|
||||
//! Automated terminal / key / modal acceptance matrix for #3758.
|
||||
//!
|
||||
//! #3758 asked for a multi-terminal QA pass. Most of that report is
|
||||
//! automatable: terminal geometry, `TERM`/`COLORTERM` capability tiers, the
|
||||
//! Unicode→ASCII fallback, every paste shape a real terminal can deliver,
|
||||
//! IME-style commits, mouse/resize/focus events, and every modal open/close
|
||||
//! path. Those cells run here, provider-free, in real pseudo-terminals.
|
||||
//!
|
||||
//! What deliberately does **not** live here: literal physical observations in
|
||||
//! iTerm2 / Terminal.app / WezTerm / a real SSH session. A PTY reproduces the
|
||||
//! byte protocol, not the emulator, so those rows stay `UNRUN` in
|
||||
//! `docs/releases/v0.9.2-terminal-matrix.md` rather than being claimed by
|
||||
//! proxy.
|
||||
//!
|
||||
//! Conventions this file holds to, because the alternative is a matrix that
|
||||
//! looks green and proves nothing:
|
||||
//!
|
||||
//! - **No sleep as correctness.** Every wait is a bounded poll on a real
|
||||
//! signal (rendered text, view-stack trace record, process exit) and fails
|
||||
//! with the frame *and* the terminal-mode ledger. The only fixed sleeps are
|
||||
//! the paste-burst settle windows, which are part of the behaviour under
|
||||
//! test, not a substitute for synchronisation.
|
||||
//! - **No duplicate shortcut table.** Chord coverage is scraped from the help
|
||||
//! overlay the product actually renders from
|
||||
//! `crate::tui::keybindings::KEYBINDINGS`; the catalog's own invariants are
|
||||
//! pinned by unit tests next to it.
|
||||
//! - **Terminal modes are checked on every exit path**, from the raw control
|
||||
//! stream rather than the screen.
|
||||
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::qa_harness::harness::{Harness, SealedWorkspace, make_sealed_workspace};
|
||||
use crate::qa_harness::modes::{MODES_THAT_MUST_NOT_LEAK, mode};
|
||||
use crate::qa_harness::view_log::{self, VIEW_STACK_RUST_LOG};
|
||||
use crate::qa_harness::{Frame, keys};
|
||||
use anyhow::{Result, anyhow};
|
||||
|
||||
const BOOT_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const KEY_TIMEOUT: Duration = Duration::from_secs(6);
|
||||
const MODAL_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
const EXIT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// The paste-burst detector suppresses a trailing Enter for ~120 ms after a
|
||||
/// burst. Waiting past it is part of the contract under test, not a stand-in
|
||||
/// for synchronisation.
|
||||
const PASTE_GUARD_SETTLE: Duration = Duration::from_millis(180);
|
||||
const COMPOSER_READY_TEXT: &str = "Write a task";
|
||||
|
||||
/// PTY scenarios each boot a real binary and contend for CPU; run them one at
|
||||
/// a time so a wait budget measures the product, not the runner.
|
||||
static TERMINAL_MATRIX_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn matrix_lock() -> MutexGuard<'static, ()> {
|
||||
TERMINAL_MATRIX_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poison| poison.into_inner())
|
||||
}
|
||||
|
||||
/// One column of the capability matrix: what the terminal claims to be.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct TerminalProfile {
|
||||
name: &'static str,
|
||||
term: &'static str,
|
||||
/// Empty means "the variable is present but says nothing", which is how a
|
||||
/// terminal that does not advertise truecolor actually behaves. The
|
||||
/// harness always sets `COLORTERM`, so this is the honest way to model
|
||||
/// its absence.
|
||||
colorterm: &'static str,
|
||||
extra_env: &'static [(&'static str, &'static str)],
|
||||
/// Whether 24-bit SGR is permitted to reach the terminal on this profile.
|
||||
truecolor_allowed: bool,
|
||||
}
|
||||
|
||||
const CAPABILITY_PROFILES: &[TerminalProfile] = &[
|
||||
TerminalProfile {
|
||||
name: "xterm-256color + COLORTERM=truecolor",
|
||||
term: "xterm-256color",
|
||||
colorterm: "truecolor",
|
||||
extra_env: &[],
|
||||
truecolor_allowed: true,
|
||||
},
|
||||
TerminalProfile {
|
||||
name: "xterm-256color, no truecolor claim",
|
||||
term: "xterm-256color",
|
||||
colorterm: "",
|
||||
extra_env: &[],
|
||||
truecolor_allowed: false,
|
||||
},
|
||||
TerminalProfile {
|
||||
name: "xterm (unknown tier)",
|
||||
term: "xterm",
|
||||
colorterm: "",
|
||||
extra_env: &[],
|
||||
truecolor_allowed: false,
|
||||
},
|
||||
TerminalProfile {
|
||||
name: "screen-256color (tmux-style)",
|
||||
term: "screen-256color",
|
||||
colorterm: "",
|
||||
extra_env: &[],
|
||||
truecolor_allowed: false,
|
||||
},
|
||||
TerminalProfile {
|
||||
name: "NO_COLOR present",
|
||||
term: "xterm-256color",
|
||||
colorterm: "truecolor",
|
||||
extra_env: &[("NO_COLOR", "1")],
|
||||
// NO_COLOR is not honored by the palette today. This row proves the
|
||||
// TUI still boots and paints with it set; it deliberately does not
|
||||
// claim color suppression, which is not a v0.9.2 contract.
|
||||
truecolor_allowed: true,
|
||||
},
|
||||
TerminalProfile {
|
||||
name: "CODEWHALE_ASCII_SAFE=1",
|
||||
term: "xterm-256color",
|
||||
colorterm: "truecolor",
|
||||
extra_env: &[("CODEWHALE_ASCII_SAFE", "1")],
|
||||
truecolor_allowed: true,
|
||||
},
|
||||
];
|
||||
|
||||
/// Terminal geometries the release supports, from the smallest pane a user
|
||||
/// realistically splits down to a full-screen 4K terminal.
|
||||
const SIZE_MATRIX: &[(u16, u16, &str)] = &[
|
||||
(24, 80, "classic 80x24"),
|
||||
(40, 120, "laptop 120x40"),
|
||||
(20, 60, "narrow split 60x20"),
|
||||
(14, 48, "tiny pane 48x14"),
|
||||
(50, 200, "wide 200x50"),
|
||||
];
|
||||
|
||||
/// Decorative glyphs the ASCII-safe tier promises to narrow. Each one is in
|
||||
/// `crate::tui::glyphs::ascii_fallback`'s explicit table, so this asserts a
|
||||
/// published mapping rather than a guessed Unicode range.
|
||||
const DECORATIVE_GLYPHS: &[char] = &[
|
||||
'─', '│', '┌', '┐', '└', '┘', '├', '┤', '┬', '┴', '┼', '╭', '╮', '╰', '╯', '█', '▌', '▐', '▶',
|
||||
'◀', '●', '○', '■', '◆', '…',
|
||||
];
|
||||
|
||||
fn spawn(
|
||||
ws: &SealedWorkspace,
|
||||
profile: TerminalProfile,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
rust_log: &str,
|
||||
) -> Result<Harness> {
|
||||
let mut builder = Harness::builder(Harness::cargo_bin("codewhale-tui"))
|
||||
.cwd(ws.workspace())
|
||||
.clear_env()
|
||||
.seal_home(ws.home())
|
||||
// A stub key skips onboarding; a refused loopback base URL guarantees
|
||||
// no request can escape the box. Nothing in this file needs a
|
||||
// provider — the rows that need a running turn live in
|
||||
// `release_runtime_qa.rs` against a loopback mock.
|
||||
.env("DEEPSEEK_API_KEY", "ci-test-key-not-real")
|
||||
.env("DEEPSEEK_BASE_URL", "http://127.0.0.1:1")
|
||||
.env("NO_ANIMATIONS", "1")
|
||||
.env("RUST_LOG", rust_log)
|
||||
.env("TERM", profile.term)
|
||||
.env("COLORTERM", profile.colorterm)
|
||||
.args([
|
||||
"--workspace",
|
||||
ws.workspace().to_str().expect("utf-8 workspace path"),
|
||||
"--no-project-config",
|
||||
"--skip-onboarding",
|
||||
])
|
||||
.size(rows, cols);
|
||||
for (key, value) in profile.extra_env {
|
||||
builder = builder.env(*key, *value);
|
||||
}
|
||||
builder.spawn()
|
||||
}
|
||||
|
||||
fn default_profile() -> TerminalProfile {
|
||||
CAPABILITY_PROFILES[0]
|
||||
}
|
||||
|
||||
fn boot(ws: &SealedWorkspace, rows: u16, cols: u16) -> Result<Harness> {
|
||||
let mut harness = spawn(ws, default_profile(), rows, cols, "warn")?;
|
||||
expect_text(&mut harness, COMPOSER_READY_TEXT, BOOT_TIMEOUT, "boot")?;
|
||||
Ok(harness)
|
||||
}
|
||||
|
||||
/// Bounded wait for rendered text that fails with the frame *and* the
|
||||
/// terminal-mode ledger, so a CI timeout is diagnosable without a rerun.
|
||||
fn expect_text(
|
||||
harness: &mut Harness,
|
||||
needle: &str,
|
||||
timeout: Duration,
|
||||
context: &str,
|
||||
) -> Result<()> {
|
||||
match harness.wait_for_text(needle, timeout) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) => {
|
||||
let modes = harness.terminal_modes().debug_dump();
|
||||
Err(anyhow!("{context}: {err:#}\n{modes}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_frame<F>(
|
||||
harness: &mut Harness,
|
||||
predicate: F,
|
||||
timeout: Duration,
|
||||
context: &str,
|
||||
) -> Result<()>
|
||||
where
|
||||
F: FnMut(&Frame) -> bool,
|
||||
{
|
||||
match harness.wait_for(predicate, timeout) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) => {
|
||||
let modes = harness.terminal_modes().debug_dump();
|
||||
Err(anyhow!("{context}: {err:#}\n{modes}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Type a slash command and run it, waiting on the echo rather than sleeping.
|
||||
fn run_command(harness: &mut Harness, command: &str) -> Result<()> {
|
||||
harness.send(keys::key::text(command))?;
|
||||
expect_text(harness, command, KEY_TIMEOUT, &format!("echo of {command}"))?;
|
||||
std::thread::sleep(PASTE_GUARD_SETTLE);
|
||||
harness.pump();
|
||||
harness.send(keys::key::enter())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn assert_no_leaked_modes(harness: &Harness, context: &str) {
|
||||
let ledger = harness.terminal_modes();
|
||||
let leaked = ledger.leaked_modes();
|
||||
assert!(
|
||||
leaked.is_empty(),
|
||||
"{context}: terminal modes left enabled after exit: {leaked:?}\n{}",
|
||||
ledger.debug_dump()
|
||||
);
|
||||
assert!(
|
||||
ledger.keyboard_pops() >= ledger.keyboard_pushes(),
|
||||
"{context}: keyboard enhancement stack not unwound ({} pushed, {} popped) — this is the \
|
||||
`^[[>5u` shell pollution from #1583\n{}",
|
||||
ledger.keyboard_pushes(),
|
||||
ledger.keyboard_pops(),
|
||||
ledger.debug_dump()
|
||||
);
|
||||
if ledger.was_ever_enabled(mode::ALT_SCREEN) {
|
||||
assert_eq!(
|
||||
ledger.state(mode::CURSOR_VISIBLE),
|
||||
Some(true),
|
||||
"{context}: cursor left hidden on the restored primary screen\n{}",
|
||||
ledger.debug_dump()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Geometry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Every supported terminal size keeps the composer reachable and paints
|
||||
/// inside the viewport. Driven as live resizes on one process because that is
|
||||
/// also the resize contract: a user dragging a pane must never be left with a
|
||||
/// blank or overflowing frame.
|
||||
#[test]
|
||||
fn size_matrix_keeps_the_composer_visible_and_inside_the_viewport() -> Result<()> {
|
||||
let _guard = matrix_lock();
|
||||
let ws = make_sealed_workspace()?;
|
||||
let mut harness = boot(&ws, 40, 120)?;
|
||||
|
||||
for (rows, cols, label) in SIZE_MATRIX {
|
||||
let transcript_before_resize = harness.transcript().len();
|
||||
harness.resize(*rows, *cols)?;
|
||||
let deadline = Instant::now() + crate::qa_harness::harness::ci_scaled(KEY_TIMEOUT);
|
||||
loop {
|
||||
harness.pump();
|
||||
let saw_post_resize_output = harness.transcript().len() > transcript_before_resize;
|
||||
let frame = harness.frame();
|
||||
if saw_post_resize_output
|
||||
&& frame.rows() == *rows
|
||||
&& frame.cols() == *cols
|
||||
&& frame.any_visible_text()
|
||||
&& frame.contains(COMPOSER_READY_TEXT)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(anyhow!(
|
||||
"painted composer after resize to {label}: timed out\n{}",
|
||||
harness.diagnostics()
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(40));
|
||||
}
|
||||
|
||||
let frame = harness.frame();
|
||||
let dump = frame.debug_dump();
|
||||
assert!(
|
||||
frame.any_visible_text(),
|
||||
"{label}: viewport went blank after resize:\n{dump}"
|
||||
);
|
||||
assert!(
|
||||
frame.max_row_width() <= usize::from(*cols),
|
||||
"{label}: a row overflowed {cols} columns:\n{dump}"
|
||||
);
|
||||
let (cursor_row, cursor_col) = frame.cursor();
|
||||
assert!(
|
||||
cursor_row < *rows && cursor_col < *cols,
|
||||
"{label}: cursor left the viewport at {cursor_row}x{cursor_col}:\n{dump}"
|
||||
);
|
||||
}
|
||||
|
||||
// Typing must still land in the composer at the last (widest) size, so a
|
||||
// resize storm cannot silently detach input.
|
||||
harness.send(keys::key::text("post-resize input"))?;
|
||||
expect_text(
|
||||
&mut harness,
|
||||
"post-resize input",
|
||||
KEY_TIMEOUT,
|
||||
"composer input after the full resize sweep",
|
||||
)?;
|
||||
|
||||
let _ = harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Capability tiers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Boot under each `TERM`/`COLORTERM` tier and prove the palette honored it.
|
||||
/// The assertion reads parsed ANSI out of the PTY, not the renderer's
|
||||
/// intent — a terminal that only advertises 256 colors must never receive
|
||||
/// `38;2` truecolor SGR (#2494 item 3).
|
||||
#[test]
|
||||
fn capability_matrix_honors_the_advertised_color_tier() -> Result<()> {
|
||||
let _guard = matrix_lock();
|
||||
|
||||
for profile in CAPABILITY_PROFILES {
|
||||
let ws = make_sealed_workspace()?;
|
||||
let mut harness = spawn(&ws, *profile, 40, 120, "warn")?;
|
||||
expect_text(
|
||||
&mut harness,
|
||||
COMPOSER_READY_TEXT,
|
||||
BOOT_TIMEOUT,
|
||||
&format!("boot under {}", profile.name),
|
||||
)?;
|
||||
|
||||
let frame = harness.frame();
|
||||
let dump = frame.debug_dump();
|
||||
assert!(
|
||||
frame.any_visible_text(),
|
||||
"{}: booted to a blank screen:\n{dump}",
|
||||
profile.name
|
||||
);
|
||||
if !profile.truecolor_allowed {
|
||||
assert!(
|
||||
!frame.any_truecolor_cell(),
|
||||
"{}: 24-bit SGR reached a terminal that never advertised it:\n{dump}",
|
||||
profile.name
|
||||
);
|
||||
}
|
||||
|
||||
let _ = harness.shutdown();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `CODEWHALE_ASCII_SAFE=1` must narrow every CodeWhale-authored decorative
|
||||
/// glyph, and the default tier must actually differ — otherwise the fallback
|
||||
/// is untested and the "ASCII terminals are supported" claim is unbacked.
|
||||
#[test]
|
||||
fn ascii_safe_tier_removes_decorative_glyphs_the_default_tier_paints() -> Result<()> {
|
||||
let _guard = matrix_lock();
|
||||
|
||||
let rich_ws = make_sealed_workspace()?;
|
||||
let mut rich = spawn(&rich_ws, default_profile(), 40, 120, "warn")?;
|
||||
expect_text(&mut rich, COMPOSER_READY_TEXT, BOOT_TIMEOUT, "rich boot")?;
|
||||
let rich_painted = rich.frame().painted_chars();
|
||||
let rich_dump = rich.debug_dump();
|
||||
let _ = rich.shutdown();
|
||||
|
||||
let rich_decorative: Vec<char> = DECORATIVE_GLYPHS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|glyph| rich_painted.contains(glyph))
|
||||
.collect();
|
||||
assert!(
|
||||
!rich_decorative.is_empty(),
|
||||
"default tier painted no decorative glyph, so the ASCII fallback below \
|
||||
would pass vacuously:\n{rich_dump}"
|
||||
);
|
||||
|
||||
let ascii_profile = CAPABILITY_PROFILES
|
||||
.iter()
|
||||
.find(|profile| profile.name == "CODEWHALE_ASCII_SAFE=1")
|
||||
.copied()
|
||||
.expect("ascii-safe profile is declared in the capability matrix");
|
||||
let ascii_ws = make_sealed_workspace()?;
|
||||
let mut ascii = spawn(&ascii_ws, ascii_profile, 40, 120, "warn")?;
|
||||
expect_text(
|
||||
&mut ascii,
|
||||
COMPOSER_READY_TEXT,
|
||||
BOOT_TIMEOUT,
|
||||
"ascii-safe boot",
|
||||
)?;
|
||||
let ascii_painted = ascii.frame().painted_chars();
|
||||
let ascii_dump = ascii.debug_dump();
|
||||
let _ = ascii.shutdown();
|
||||
|
||||
let leaked: Vec<char> = DECORATIVE_GLYPHS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|glyph| ascii_painted.contains(glyph))
|
||||
.collect();
|
||||
assert!(
|
||||
leaked.is_empty(),
|
||||
"ASCII-safe tier still painted {leaked:?}:\n{ascii_dump}"
|
||||
);
|
||||
let braille: Vec<char> = ascii_painted
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|ch| ('\u{2800}'..='\u{28FF}').contains(ch))
|
||||
.collect();
|
||||
assert!(
|
||||
braille.is_empty(),
|
||||
"ASCII-safe tier still painted braille state markers {braille:?}:\n{ascii_dump}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Paste / IME
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Whether the frame shows a turn that actually left the composer. Boot is
|
||||
/// provider-free against a refused loopback port, so any dispatch surfaces as
|
||||
/// a connection failure rather than a reply.
|
||||
fn frame_shows_dispatched_turn(frame: &Frame) -> bool {
|
||||
frame.contains("Turn failed") || frame.contains("Connection refused") || frame.contains("error")
|
||||
}
|
||||
|
||||
/// Every paste shape a real terminal can deliver — bracketed, raw, multiline,
|
||||
/// CJK, and a very large payload — must land in the composer and must not
|
||||
/// auto-submit. #1073 and the v0.9.2 real-PTY paste trace are both regressions
|
||||
/// of exactly this cell.
|
||||
#[test]
|
||||
fn paste_matrix_lands_in_the_composer_without_autosubmitting() -> Result<()> {
|
||||
let _guard = matrix_lock();
|
||||
|
||||
// Each case is (label, payload, bracketed). The marker each payload ends
|
||||
// with is what the assertion looks for, so a silently truncated paste
|
||||
// fails instead of passing on its prefix.
|
||||
let cases: &[(&str, String, bool)] = &[
|
||||
(
|
||||
"bracketed single line",
|
||||
"matrix-bracketed-single-END".to_string(),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"bracketed multiline",
|
||||
"matrix-line-one\nmatrix-line-two\nmatrix-bracketed-multi-END".to_string(),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"bracketed with trailing newline",
|
||||
"matrix-bracketed-trailing-END\n".to_string(),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"bracketed CJK + wide glyphs",
|
||||
"你好世界 マトリクス 매트릭스 matrix-cjk-END".to_string(),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"raw unbracketed multiline",
|
||||
"matrix-raw-one\nmatrix-raw-two\nmatrix-raw-END".to_string(),
|
||||
false,
|
||||
),
|
||||
(
|
||||
"large bracketed payload",
|
||||
format!("{}matrix-large-END", "abcdefghij ".repeat(180)),
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
for (label, payload, bracketed) in cases {
|
||||
let ws = make_sealed_workspace()?;
|
||||
let mut harness = boot(&ws, 40, 120)?;
|
||||
|
||||
if *bracketed {
|
||||
harness.paste(payload)?;
|
||||
} else {
|
||||
harness.paste_unbracketed(payload)?;
|
||||
}
|
||||
|
||||
let marker = payload
|
||||
.trim_end()
|
||||
.rsplit(['\n', ' '])
|
||||
.next()
|
||||
.expect("every payload ends with a marker token")
|
||||
.to_string();
|
||||
expect_text(
|
||||
&mut harness,
|
||||
&marker,
|
||||
KEY_TIMEOUT,
|
||||
&format!("{label}: pasted tail never reached the composer"),
|
||||
)?;
|
||||
|
||||
// Give any trailing-newline-driven submit the chance to happen before
|
||||
// asserting that it did not. This window is the paste-burst guard
|
||||
// itself, not a synchronisation crutch.
|
||||
std::thread::sleep(PASTE_GUARD_SETTLE);
|
||||
harness.pump();
|
||||
let frame = harness.frame();
|
||||
let dump = frame.debug_dump();
|
||||
assert!(
|
||||
!frame_shows_dispatched_turn(frame),
|
||||
"{label}: paste auto-submitted; nothing may dispatch without an explicit Enter:\n{dump}"
|
||||
);
|
||||
|
||||
let _ = harness.shutdown();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An IME delivers each committed character as its own event with human-scale
|
||||
/// gaps. That is typing, not a paste: the Enter that follows a committed CJK
|
||||
/// sentence must submit rather than being absorbed as a paste's trailing
|
||||
/// newline. Covers the lone-commit short-window path from the v0.9.2 paste
|
||||
/// trace as well as a multi-character commit.
|
||||
#[test]
|
||||
fn ime_style_commits_are_typing_and_the_following_enter_submits() -> Result<()> {
|
||||
let _guard = matrix_lock();
|
||||
let ws = make_sealed_workspace()?;
|
||||
let mut harness = boot(&ws, 40, 120)?;
|
||||
|
||||
// Multi-character commit, one candidate at a time.
|
||||
for ch in "行列テスト".chars() {
|
||||
harness.send(keys::key::ch(ch))?;
|
||||
std::thread::sleep(Duration::from_millis(60));
|
||||
}
|
||||
expect_text(
|
||||
&mut harness,
|
||||
"行列テスト",
|
||||
KEY_TIMEOUT,
|
||||
"IME commits never echoed",
|
||||
)?;
|
||||
let frame = harness.frame();
|
||||
let dump = frame.debug_dump();
|
||||
assert!(
|
||||
!frame_shows_dispatched_turn(frame),
|
||||
"IME composition must not dispatch on its own:\n{dump}"
|
||||
);
|
||||
|
||||
// A lone trailing commit followed by Enter is the exact shape that used to
|
||||
// re-arm the paste-burst window and swallow the send.
|
||||
harness.send(keys::key::ch('了'))?;
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
harness.send(keys::key::enter())?;
|
||||
|
||||
expect_frame(
|
||||
&mut harness,
|
||||
frame_shows_dispatched_turn,
|
||||
Duration::from_secs(15),
|
||||
"IME-typed message never submitted on Enter",
|
||||
)?;
|
||||
|
||||
let _ = harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mouse / resize / focus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Mouse, resize and focus events must never be decoded as text, and
|
||||
/// `FocusGained` must re-establish the terminal modes the emulator may have
|
||||
/// dropped while the window was in the background.
|
||||
#[test]
|
||||
fn mouse_resize_and_focus_events_never_reach_the_composer_as_text() -> Result<()> {
|
||||
let _guard = matrix_lock();
|
||||
let ws = make_sealed_workspace()?;
|
||||
let mut harness = boot(&ws, 40, 120)?;
|
||||
|
||||
harness.send(keys::key::text("focus-sentinel"))?;
|
||||
expect_text(
|
||||
&mut harness,
|
||||
"focus-sentinel",
|
||||
KEY_TIMEOUT,
|
||||
"sentinel draft",
|
||||
)?;
|
||||
|
||||
harness.send(keys::mouse::click(10, 20))?;
|
||||
harness.send(keys::mouse::wheel_up(10, 20))?;
|
||||
harness.send(keys::mouse::wheel_down(10, 20))?;
|
||||
harness.send(keys::mouse::drag(12, 24))?;
|
||||
harness.send(keys::focus::lost())?;
|
||||
harness.resize(30, 100)?;
|
||||
harness.send(keys::focus::gained())?;
|
||||
harness.resize(40, 120)?;
|
||||
|
||||
expect_text(
|
||||
&mut harness,
|
||||
"focus-sentinel",
|
||||
KEY_TIMEOUT,
|
||||
"draft after the mouse/focus/resize storm",
|
||||
)?;
|
||||
let frame = harness.frame();
|
||||
let dump = frame.debug_dump();
|
||||
for residue in ["[<0;", "[<64;", "[<65;", "[<32;", "\u{1b}[I", "\u{1b}[O"] {
|
||||
assert!(
|
||||
!frame.contains(residue),
|
||||
"control sequence {residue:?} was painted as text:\n{dump}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!frame_shows_dispatched_turn(frame),
|
||||
"a mouse or focus event dispatched a turn:\n{dump}"
|
||||
);
|
||||
|
||||
// FocusGained runs `recover_terminal_modes`, so focus reporting and
|
||||
// bracketed paste must be *on* again while the process is still alive.
|
||||
let ledger = harness.terminal_modes();
|
||||
assert_eq!(
|
||||
ledger.state(mode::FOCUS),
|
||||
Some(true),
|
||||
"focus reporting was not re-established after FocusGained\n{}",
|
||||
ledger.debug_dump()
|
||||
);
|
||||
assert_eq!(
|
||||
ledger.state(mode::BRACKETED_PASTE),
|
||||
Some(true),
|
||||
"bracketed paste was not re-established after FocusGained\n{}",
|
||||
ledger.debug_dump()
|
||||
);
|
||||
|
||||
let _ = harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// How a modal is opened. Chords are the ones the help overlay advertises;
|
||||
/// commands are the ones the keybinding catalog documents as the guaranteed
|
||||
/// path for terminals that cannot encode the chord.
|
||||
enum Opener {
|
||||
Chord(&'static str, Vec<u8>),
|
||||
Command(&'static str),
|
||||
}
|
||||
|
||||
impl Opener {
|
||||
fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Chord(label, _) => label,
|
||||
Self::Command(command) => command,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every modal that can be opened deterministically without a provider must
|
||||
/// push exactly one view, and Esc must pop exactly that view and return the
|
||||
/// stack to empty. Evidence is the product's own `view_stack` trace records,
|
||||
/// not "the frame looked different" — a replaced modal and a closed modal
|
||||
/// render the same way.
|
||||
#[test]
|
||||
fn every_provider_free_modal_opens_and_escape_returns_to_the_composer() -> Result<()> {
|
||||
let _guard = matrix_lock();
|
||||
let ws = make_sealed_workspace()?;
|
||||
let mut harness = spawn(&ws, default_profile(), 44, 140, VIEW_STACK_RUST_LOG)?;
|
||||
expect_text(&mut harness, COMPOSER_READY_TEXT, BOOT_TIMEOUT, "boot")?;
|
||||
|
||||
let openers = [
|
||||
(Opener::Chord("F1", keys::key::f1()), "Help"),
|
||||
(
|
||||
Opener::Chord("Ctrl+K", keys::key::ctrl('k')),
|
||||
"CommandPalette",
|
||||
),
|
||||
(Opener::Command("/context"), "ContextInspector"),
|
||||
(Opener::Command("/transcript"), "LiveTranscript"),
|
||||
(Opener::Command("/theme"), "ThemePicker"),
|
||||
(Opener::Command("/skills"), "SkillsManager"),
|
||||
];
|
||||
|
||||
let mut transition_cursor = view_log::read_events(ws.home()).unwrap_or_default().len();
|
||||
for (opener, kind) in &openers {
|
||||
match opener {
|
||||
Opener::Chord(_, bytes) => harness.send(bytes.clone())?,
|
||||
Opener::Command(command) => run_command(&mut harness, command)?,
|
||||
}
|
||||
let (opened, last) =
|
||||
view_log::wait_for_event_after(ws.home(), transition_cursor, MODAL_TIMEOUT, |event| {
|
||||
event.is_open() && event.kind == *kind
|
||||
})
|
||||
.map_err(|err| anyhow!("{} did not open {kind}: {err:#}", opener.label()))?;
|
||||
transition_cursor = opened.len();
|
||||
assert_eq!(
|
||||
last.depth,
|
||||
1,
|
||||
"{} opened {kind} on top of a stack that was never unwound",
|
||||
opener.label()
|
||||
);
|
||||
|
||||
harness.send(keys::key::esc())?;
|
||||
let (closed, last) =
|
||||
view_log::wait_for_event_after(ws.home(), transition_cursor, MODAL_TIMEOUT, |event| {
|
||||
event.is_close() && event.kind == *kind
|
||||
})
|
||||
.map_err(|err| anyhow!("Esc did not close {kind}: {err:#}"))?;
|
||||
transition_cursor = closed.len();
|
||||
assert_eq!(
|
||||
last.depth, 0,
|
||||
"Esc left the view stack at depth {} after closing {kind}",
|
||||
last.depth
|
||||
);
|
||||
|
||||
expect_text(
|
||||
&mut harness,
|
||||
COMPOSER_READY_TEXT,
|
||||
MODAL_TIMEOUT,
|
||||
&format!("composer after closing {kind}"),
|
||||
)?;
|
||||
}
|
||||
|
||||
// The composer must be typable again after the whole sweep — a modal that
|
||||
// closed but kept input focus is the failure this row exists to catch.
|
||||
harness.send(keys::key::text("post-modal input"))?;
|
||||
expect_text(
|
||||
&mut harness,
|
||||
"post-modal input",
|
||||
KEY_TIMEOUT,
|
||||
"composer input after the modal sweep",
|
||||
)?;
|
||||
|
||||
let _ = harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The help overlay is the only place the product advertises chords, and it
|
||||
/// renders straight from `KEYBINDINGS`. Scraping it keeps this suite from
|
||||
/// growing a second shortcut table that can drift: if a chord stops being
|
||||
/// advertised, this row fails rather than silently testing a stale contract.
|
||||
#[test]
|
||||
fn help_overlay_advertises_the_chords_this_matrix_drives() -> Result<()> {
|
||||
let _guard = matrix_lock();
|
||||
let ws = make_sealed_workspace()?;
|
||||
let mut harness = boot(&ws, 44, 140)?;
|
||||
|
||||
harness.send(keys::key::f1())?;
|
||||
expect_frame(
|
||||
&mut harness,
|
||||
|frame| frame.contains("Ctrl+K") || frame.contains("F1"),
|
||||
MODAL_TIMEOUT,
|
||||
"help overlay never opened",
|
||||
)?;
|
||||
|
||||
// The overlay lists more rows than fit on any terminal, so each chord is
|
||||
// brought into view through the overlay's own substring filter instead of
|
||||
// being asserted against whatever happened to be scrolled into frame.
|
||||
// Chords this file drives, plus the command fallbacks the catalog
|
||||
// documents as guaranteed. macOS renders Alt chords with `⌥`, so only
|
||||
// spellings that are stable on every platform are asserted here.
|
||||
for advertised in ["F1", "Ctrl+K", "Enter", "/context", "/transcript"] {
|
||||
harness.send(keys::key::text(advertised))?;
|
||||
expect_text(
|
||||
&mut harness,
|
||||
advertised,
|
||||
KEY_TIMEOUT,
|
||||
&format!("help overlay no longer advertises {advertised}, but this matrix drives it"),
|
||||
)?;
|
||||
harness.send(keys::key::backspaces(advertised.chars().count()))?;
|
||||
expect_frame(
|
||||
&mut harness,
|
||||
|frame| {
|
||||
frame.contains("Type to filter")
|
||||
&& frame.contains("entries")
|
||||
&& !frame.contains("matches")
|
||||
},
|
||||
KEY_TIMEOUT,
|
||||
"help filter never cleared",
|
||||
)?;
|
||||
}
|
||||
|
||||
// #440 / #3758: Ctrl+G and Ctrl+S stash a draft, and nothing else. If
|
||||
// either ever appears beside send/queue/steer copy the advertised action
|
||||
// is ambiguous and the running-turn contract stops being teachable.
|
||||
for stash_chord in ["Ctrl+G", "Ctrl+S"] {
|
||||
harness.send(keys::key::text(stash_chord))?;
|
||||
expect_text(
|
||||
&mut harness,
|
||||
stash_chord,
|
||||
KEY_TIMEOUT,
|
||||
&format!("{stash_chord} is not advertised at all"),
|
||||
)?;
|
||||
let frame = harness.frame();
|
||||
let text = frame.text();
|
||||
for line in text.lines() {
|
||||
if !line.contains(stash_chord) {
|
||||
continue;
|
||||
}
|
||||
let lowered = line.to_ascii_lowercase();
|
||||
for forbidden in ["send", "queue", "steer", "submit"] {
|
||||
assert!(
|
||||
!lowered.contains(forbidden),
|
||||
"{stash_chord} must advertise exactly one action (stash), got {line:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
harness.send(keys::key::backspaces(stash_chord.chars().count()))?;
|
||||
}
|
||||
|
||||
harness.send(keys::key::esc())?;
|
||||
expect_text(
|
||||
&mut harness,
|
||||
COMPOSER_READY_TEXT,
|
||||
MODAL_TIMEOUT,
|
||||
"composer after closing help",
|
||||
)?;
|
||||
|
||||
let _ = harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Terminal-mode restoration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Every exit path must hand the terminal back the way it found it. Checked
|
||||
/// from the raw control stream, because a leaked alternate screen or a leaked
|
||||
/// kitty keyboard flag is invisible on the rendered frame and only shows up in
|
||||
/// the user's shell afterwards (#1583, #2494).
|
||||
#[test]
|
||||
fn terminal_modes_are_restored_on_every_exit_path() -> Result<()> {
|
||||
let _guard = matrix_lock();
|
||||
|
||||
// Ctrl+D on an empty composer: the cooperative exit.
|
||||
{
|
||||
let ws = make_sealed_workspace()?;
|
||||
let mut harness = boot(&ws, 40, 120)?;
|
||||
harness.send(keys::key::ctrl_d())?;
|
||||
let status = harness.wait_for_exit(EXIT_TIMEOUT);
|
||||
harness.pump();
|
||||
assert_eq!(
|
||||
status,
|
||||
Some(0),
|
||||
"Ctrl+D on an empty composer must exit cleanly\n{}",
|
||||
harness.diagnostics()
|
||||
);
|
||||
assert_no_leaked_modes(&harness, "Ctrl+D exit");
|
||||
}
|
||||
|
||||
// SIGINT: the signal handler's emergency restore path.
|
||||
{
|
||||
let ws = make_sealed_workspace()?;
|
||||
let mut harness = boot(&ws, 40, 120)?;
|
||||
let pid = harness.pid().ok_or_else(|| anyhow!("no child pid"))?;
|
||||
let signalled = std::process::Command::new("kill")
|
||||
.args(["-INT", &pid.to_string()])
|
||||
.status()?;
|
||||
assert!(signalled.success(), "could not signal the TUI child");
|
||||
harness.wait_for_exit(EXIT_TIMEOUT);
|
||||
harness.pump();
|
||||
assert_no_leaked_modes(&harness, "SIGINT exit");
|
||||
}
|
||||
|
||||
// A terminal that was never given the alternate screen still must not be
|
||||
// left with mouse capture or bracketed paste on.
|
||||
{
|
||||
let ws = make_sealed_workspace()?;
|
||||
let mut harness = spawn(&ws, CAPABILITY_PROFILES[2], 24, 80, "warn")?;
|
||||
expect_text(
|
||||
&mut harness,
|
||||
COMPOSER_READY_TEXT,
|
||||
BOOT_TIMEOUT,
|
||||
"boot on the unknown-tier terminal",
|
||||
)?;
|
||||
harness.send(keys::key::ctrl_d())?;
|
||||
harness.wait_for_exit(EXIT_TIMEOUT);
|
||||
harness.pump();
|
||||
assert_no_leaked_modes(&harness, "unknown-tier Ctrl+D exit");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Guard on the guard: `MODES_THAT_MUST_NOT_LEAK` has to actually cover the
|
||||
/// modes the TUI turns on, or `assert_no_leaked_modes` passes vacuously.
|
||||
#[test]
|
||||
fn the_leak_guard_covers_the_modes_the_tui_enables() -> Result<()> {
|
||||
let _guard = matrix_lock();
|
||||
let ws = make_sealed_workspace()?;
|
||||
let mut harness = boot(&ws, 40, 120)?;
|
||||
// Mouse capture is established at startup; touch the input path so the
|
||||
// sample is taken after the TUI has fully settled its modes.
|
||||
harness.send(keys::focus::gained())?;
|
||||
expect_text(
|
||||
&mut harness,
|
||||
COMPOSER_READY_TEXT,
|
||||
KEY_TIMEOUT,
|
||||
"composer before sampling modes",
|
||||
)?;
|
||||
|
||||
let ledger = harness.terminal_modes();
|
||||
let covered: Vec<u16> = MODES_THAT_MUST_NOT_LEAK
|
||||
.iter()
|
||||
.map(|(number, _)| *number)
|
||||
.filter(|number| ledger.was_ever_enabled(*number))
|
||||
.collect();
|
||||
assert!(
|
||||
covered.contains(&mode::ALT_SCREEN)
|
||||
&& covered.contains(&mode::BRACKETED_PASTE)
|
||||
&& covered.contains(&mode::FOCUS),
|
||||
"the running TUI did not enable the modes the exit guard checks; \
|
||||
covered={covered:?}\n{}",
|
||||
ledger.debug_dump()
|
||||
);
|
||||
|
||||
let _ = harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,724 +0,0 @@
|
||||
//! Owner report (2026-08-04): "the sub agents still aren't showing up in the
|
||||
//! top bar so they aren't inspectable."
|
||||
//!
|
||||
//! Static reading of `work_surface/model.rs` says the rows are built and are
|
||||
//! durable, so this probe refuses to reason about it: every assertion below is
|
||||
//! made against a real pseudo-terminal frame produced by the real event loop,
|
||||
//! with a loopback provider that dispatches genuine `agent` tool calls.
|
||||
//!
|
||||
//! The contract under test (`crates/tui/AGENTS.md`, "rows are objects"): every
|
||||
//! work-bar row is a door — click it and the world behind it opens — and
|
||||
//! keyboard Enter opens the same door a click does.
|
||||
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::qa_harness::harness::{Harness, SealedWorkspace, make_sealed_workspace};
|
||||
use crate::qa_harness::keys;
|
||||
use anyhow::{Result, anyhow};
|
||||
use serde_json::{Value, json};
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
|
||||
|
||||
const BOOT_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const INTERACTION_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const PASTE_GUARD_SETTLE: Duration = Duration::from_millis(180);
|
||||
const COMPOSER_READY_TEXT: &str = "Write a task";
|
||||
const MODEL: &str = "deepseek-v4-pro";
|
||||
/// The empty-composer hint while a worker is focused (its full transcript
|
||||
/// owns the conversation area and the composer addresses its fork).
|
||||
const FOCUS_MARKER: &str = "Esc returns to main";
|
||||
|
||||
/// The user prompt that triggers the fan-out. Only ever present in a *parent*
|
||||
/// request, so the responder can tell parent from child without guessing.
|
||||
const PARENT_PROMPT: &str = "spawn the work-bar probe workers now";
|
||||
/// The objective handed to each child. Also the text the work-bar row shows.
|
||||
const CHILD_MARKER: &str = "workbarprobe";
|
||||
|
||||
static WORK_BAR_PTY_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
fn sse_chunk(value: Value) -> String {
|
||||
format!(
|
||||
"data: {}\n\n",
|
||||
serde_json::to_string(&value).expect("SSE JSON")
|
||||
)
|
||||
}
|
||||
|
||||
fn text_sse(text: &str) -> String {
|
||||
[
|
||||
sse_chunk(json!({
|
||||
"id": "chatcmpl-workbar",
|
||||
"object": "chat.completion.chunk",
|
||||
"model": MODEL,
|
||||
"choices": [{"index": 0, "delta": {"content": text}, "finish_reason": null}]
|
||||
})),
|
||||
sse_chunk(json!({
|
||||
"id": "chatcmpl-workbar",
|
||||
"object": "chat.completion.chunk",
|
||||
"model": MODEL,
|
||||
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": 12, "completion_tokens": 4, "total_tokens": 16}
|
||||
})),
|
||||
"data: [DONE]\n\n".to_string(),
|
||||
]
|
||||
.join("")
|
||||
}
|
||||
|
||||
fn agent_tool_call_sse(count: usize) -> String {
|
||||
let tool_calls = (1..=count)
|
||||
.map(|worker| {
|
||||
json!({
|
||||
"index": worker - 1,
|
||||
"id": format!("call_workbar_{worker}"),
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "agent",
|
||||
"arguments": serde_json::to_string(&json!({
|
||||
"action": "start",
|
||||
// The work bar is the inspectable surface for agents
|
||||
// that outlive the parent wrap-up. Turn-owned starts
|
||||
// are cancelled when the mock parent finishes, which
|
||||
// is not the owner-reported "spawned and inspectable"
|
||||
// contract these probes pin.
|
||||
"detached": true,
|
||||
"prompt": format!("{CHILD_MARKER}{worker} keep working"),
|
||||
"type": "explorer",
|
||||
// Explicit fresh context: a forked child would carry the
|
||||
// parent prompt into its own requests and defeat the
|
||||
// parent/child discrimination in the responder.
|
||||
"fork_context": false,
|
||||
"session_name": format!("workbar-{worker}")
|
||||
}))
|
||||
.expect("agent arguments")
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
[
|
||||
sse_chunk(json!({
|
||||
"id": "chatcmpl-workbar-fanout",
|
||||
"object": "chat.completion.chunk",
|
||||
"model": MODEL,
|
||||
"choices": [{"index": 0, "delta": {"tool_calls": tool_calls}, "finish_reason": null}]
|
||||
})),
|
||||
sse_chunk(json!({
|
||||
"id": "chatcmpl-workbar-fanout",
|
||||
"object": "chat.completion.chunk",
|
||||
"model": MODEL,
|
||||
"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}],
|
||||
"usage": {"prompt_tokens": 20, "completion_tokens": 12, "total_tokens": 32}
|
||||
})),
|
||||
"data: [DONE]\n\n".to_string(),
|
||||
]
|
||||
.join("")
|
||||
}
|
||||
|
||||
fn sse_response(body: String) -> ResponseTemplate {
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "text/event-stream")
|
||||
.insert_header("cache-control", "no-cache")
|
||||
.set_body_string(body)
|
||||
}
|
||||
|
||||
fn json_response(value: Value) -> ResponseTemplate {
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "application/json")
|
||||
.set_body_json(value)
|
||||
}
|
||||
|
||||
/// Sub-agents intentionally use the non-streaming Chat Completions boundary:
|
||||
/// their result must be complete before the worker can decide whether to make
|
||||
/// another tool call. The parent TUI turn remains an SSE request. Keep the
|
||||
/// probe faithful to both real wire shapes instead of making a valid worker
|
||||
/// reject its fixture as malformed JSON.
|
||||
fn chat_message_response(text: &str) -> ResponseTemplate {
|
||||
json_response(json!({
|
||||
"id": "chatcmpl-workbar-child",
|
||||
"object": "chat.completion",
|
||||
"model": MODEL,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": text},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 12, "completion_tokens": 4, "total_tokens": 16}
|
||||
}))
|
||||
}
|
||||
|
||||
async fn mount_models(server: &MockServer) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/models"))
|
||||
.respond_with(json_response(json!({
|
||||
"object": "list",
|
||||
"data": [{"id": MODEL, "object": "model"}]
|
||||
})))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Dispatches one fan-out on the first parent turn, then answers the parent
|
||||
/// plainly. `child_hold` decides whether the workers stay running (a long
|
||||
/// delay) or finish immediately.
|
||||
struct ProbeResponder {
|
||||
child_requests: Arc<AtomicUsize>,
|
||||
parent_turns: Arc<AtomicUsize>,
|
||||
workers: usize,
|
||||
child_hold: Duration,
|
||||
}
|
||||
|
||||
impl Respond for ProbeResponder {
|
||||
fn respond(&self, request: &Request) -> ResponseTemplate {
|
||||
let raw = request
|
||||
.body_json::<Value>()
|
||||
.unwrap_or(Value::Null)
|
||||
.to_string();
|
||||
|
||||
if raw.contains(CHILD_MARKER) && !raw.contains(PARENT_PROMPT) {
|
||||
self.child_requests.fetch_add(1, Ordering::SeqCst);
|
||||
return chat_message_response("workbar child receipt").set_delay(self.child_hold);
|
||||
}
|
||||
if raw.contains(PARENT_PROMPT) {
|
||||
if self.parent_turns.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
return sse_response(agent_tool_call_sse(self.workers));
|
||||
}
|
||||
return sse_response(text_sse("workbar parent wrapped up"));
|
||||
}
|
||||
sse_response(text_sse("unexpected-request"))
|
||||
}
|
||||
}
|
||||
|
||||
fn tui_builder(
|
||||
ws: &SealedWorkspace,
|
||||
server_uri: &str,
|
||||
) -> crate::qa_harness::harness::HarnessBuilder {
|
||||
Harness::builder(Harness::cargo_bin("codewhale-tui"))
|
||||
.cwd(ws.workspace())
|
||||
.clear_env()
|
||||
.seal_home(ws.home())
|
||||
.env("RUST_LOG", "warn")
|
||||
.env("NO_ANIMATIONS", "1")
|
||||
.env("CODEWHALE_PROVIDER", "deepseek")
|
||||
.env("DEEPSEEK_API_KEY", "deepseek-local-test-key")
|
||||
.env("DEEPSEEK_BASE_URL", server_uri.to_string())
|
||||
.env("DEEPSEEK_MODEL", MODEL)
|
||||
.args([
|
||||
"--workspace",
|
||||
ws.workspace().to_str().expect("utf-8 workspace path"),
|
||||
"--no-project-config",
|
||||
"--skip-onboarding",
|
||||
"--mouse-capture",
|
||||
"--yolo",
|
||||
"--max-subagents",
|
||||
"2",
|
||||
])
|
||||
.size(42, 150)
|
||||
}
|
||||
|
||||
fn wait_for_counter(
|
||||
harness: &mut Harness,
|
||||
counter: &AtomicUsize,
|
||||
expected: usize,
|
||||
timeout: Duration,
|
||||
) -> Result<()> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
harness.pump();
|
||||
if counter.load(Ordering::SeqCst) >= expected {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(code) = harness.wait_for_exit(Duration::from_millis(0)) {
|
||||
return Err(anyhow!(
|
||||
"codewhale-tui exited with {code} before the counter reached {expected}\n{}",
|
||||
harness.debug_dump()
|
||||
));
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(anyhow!(
|
||||
"counter did not reach {expected} within {timeout:?}; observed {}\n{}",
|
||||
counter.load(Ordering::SeqCst),
|
||||
harness.debug_dump()
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(40));
|
||||
}
|
||||
}
|
||||
|
||||
fn type_and_submit(harness: &mut Harness, text: &str) -> Result<()> {
|
||||
harness.send(keys::key::text(text))?;
|
||||
harness.wait_for_text(text, Duration::from_secs(5))?;
|
||||
std::thread::sleep(PASTE_GUARD_SETTLE);
|
||||
harness.pump();
|
||||
harness.send(keys::key::enter())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_divider_row(frame: &crate::qa_harness::Frame, y: u16) -> bool {
|
||||
frame
|
||||
.row(y)
|
||||
.chars()
|
||||
.filter(|&c| c == '─' || c == '━')
|
||||
.count()
|
||||
>= 40
|
||||
}
|
||||
|
||||
/// The `▾ Subagents N` group header the Top strip paints above its worker
|
||||
/// rows. Its absence *is* the owner-reported bug, so it is the anchor every
|
||||
/// other strip probe hangs off rather than a screen-wide text search.
|
||||
fn subagents_header_row(harness: &mut Harness) -> Option<u16> {
|
||||
let frame = harness.frame();
|
||||
(0..frame.rows()).find(|&y| frame.row(y).contains("Subagents") && !is_divider_row(frame, y))
|
||||
}
|
||||
|
||||
/// Every row painted in the work bar, header included.
|
||||
fn work_bar_text(harness: &mut Harness) -> String {
|
||||
let frame = harness.frame();
|
||||
let rows = frame.rows();
|
||||
// The strip sits between the ocean header rule and the transcript rule.
|
||||
let dividers: Vec<u16> = (0..rows).filter(|&y| is_divider_row(frame, y)).collect();
|
||||
let (start, end) = match dividers.as_slice() {
|
||||
[first, second, ..] => (first.saturating_add(1), *second),
|
||||
_ => (0, rows),
|
||||
};
|
||||
(start..end)
|
||||
.map(|y| frame.row(y))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// A worker row inside the strip: the rows the `Subagents` header owns, up to
|
||||
/// the strip's closing rule. Returns `(row, column)` for a real SGR click.
|
||||
fn work_bar_worker_row(harness: &mut Harness) -> Option<(u16, u16)> {
|
||||
let header = subagents_header_row(harness)?;
|
||||
let frame = harness.frame();
|
||||
let rows = frame.rows();
|
||||
(header.saturating_add(1)..rows)
|
||||
.take_while(|&y| !is_divider_row(frame, y))
|
||||
.find_map(|y| {
|
||||
let text = frame.row(y);
|
||||
let trimmed = text.trim_start();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let col = u16::try_from(text.len() - trimmed.len()).ok()?;
|
||||
Some((y, col.saturating_add(2)))
|
||||
})
|
||||
}
|
||||
|
||||
fn session_with_todos(ws: &SealedWorkspace, count: usize) -> Result<std::path::PathBuf> {
|
||||
let session_path = ws.workspace().join("workbar-session.json");
|
||||
let todos = (0..count)
|
||||
.map(|index| {
|
||||
json!({
|
||||
"id": index + 1,
|
||||
"content": format!("todo-workbar-{index:02}"),
|
||||
"status": if index == 0 { "in_progress" } else { "pending" }
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
std::fs::write(
|
||||
&session_path,
|
||||
serde_json::to_vec_pretty(&json!({
|
||||
"schema_version": 1,
|
||||
"metadata": {
|
||||
"id": "pty-workbar",
|
||||
"title": "Work bar sub-agent probe",
|
||||
"created_at": "2026-08-04T00:00:00Z",
|
||||
"updated_at": "2026-08-04T00:00:00Z",
|
||||
"message_count": 0,
|
||||
"total_tokens": 0,
|
||||
"model": MODEL,
|
||||
"model_provider": "deepseek",
|
||||
"workspace": ws.workspace(),
|
||||
"mode": "agent",
|
||||
"cost": {},
|
||||
"cumulative_turn_secs": 0
|
||||
},
|
||||
"messages": [],
|
||||
"system_prompt": null,
|
||||
"work_state": {
|
||||
"todos": {"items": todos, "completion_pct": 0, "in_progress_id": 1},
|
||||
"plan": {"objective": "", "items": []}
|
||||
}
|
||||
}))?,
|
||||
)?;
|
||||
Ok(session_path)
|
||||
}
|
||||
|
||||
/// Baseline: with nothing competing for strip rows, a running sub-agent must
|
||||
/// appear in the top bar, a real SGR click must open its transcript (the
|
||||
/// primary destination since v0.9.7 "one agent, one destination"), and
|
||||
/// keyboard Enter must open the same door.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn work_bar_lists_a_running_subagent_and_opens_it_by_click_and_enter() -> Result<()> {
|
||||
let _guard = WORK_BAR_PTY_LOCK.lock().await;
|
||||
let server = MockServer::start().await;
|
||||
mount_models(&server).await;
|
||||
let child_requests = Arc::new(AtomicUsize::new(0));
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/chat/completions"))
|
||||
.respond_with(ProbeResponder {
|
||||
child_requests: Arc::clone(&child_requests),
|
||||
parent_turns: Arc::new(AtomicUsize::new(0)),
|
||||
workers: 2,
|
||||
child_hold: Duration::from_secs(25),
|
||||
})
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let ws = make_sealed_workspace()?;
|
||||
std::fs::write(
|
||||
ws.home().join(".codewhale").join("config.toml"),
|
||||
"[subagents]\nmax_concurrent = 2\nlaunch_concurrency = 2\nmax_admitted = 2\n",
|
||||
)?;
|
||||
let mut tui = tui_builder(&ws, &server.uri()).spawn()?;
|
||||
tui.wait_for_text(COMPOSER_READY_TEXT, BOOT_TIMEOUT)?;
|
||||
type_and_submit(&mut tui, PARENT_PROMPT)?;
|
||||
wait_for_counter(&mut tui, &child_requests, 2, INTERACTION_TIMEOUT)?;
|
||||
|
||||
tui.wait_for(
|
||||
|frame| frame.text().contains("Subagents"),
|
||||
Duration::from_secs(10),
|
||||
)?;
|
||||
|
||||
let strip = work_bar_text(&mut tui);
|
||||
assert!(
|
||||
strip.contains("Subagents"),
|
||||
"the `Subagents` header is not inside the top work bar:\n{strip}\n---full---\n{}",
|
||||
tui.debug_dump()
|
||||
);
|
||||
let (row, col) = work_bar_worker_row(&mut tui).ok_or_else(|| {
|
||||
anyhow!(
|
||||
"no running sub-agent row rendered in the top work bar\n{}",
|
||||
tui.debug_dump()
|
||||
)
|
||||
})?;
|
||||
|
||||
// Click the row: the door must open onto the agent's transcript.
|
||||
tui.send(keys::mouse::click(row, col))?;
|
||||
tui.wait_for_text(FOCUS_MARKER, Duration::from_secs(5))
|
||||
.map_err(|_| {
|
||||
anyhow!(
|
||||
"clicking the sub-agent row did not focus its transcript\n{}",
|
||||
tui.debug_dump()
|
||||
)
|
||||
})?;
|
||||
|
||||
// Leave focus, then prove keyboard parity: Alt+W focuses the strip, End
|
||||
// selects the last selectable row (a worker), Enter focuses the same
|
||||
// worker a click does.
|
||||
tui.send(keys::key::esc())?;
|
||||
tui.wait_for(
|
||||
|frame| !frame.text().contains(FOCUS_MARKER),
|
||||
Duration::from_secs(5),
|
||||
)?;
|
||||
tui.send(keys::key::alt('w'))?;
|
||||
tui.send(b"\x1b[F")?; // End
|
||||
tui.send(keys::key::enter())?;
|
||||
tui.wait_for_text(FOCUS_MARKER, Duration::from_secs(5))
|
||||
.map_err(|_| {
|
||||
anyhow!(
|
||||
"Enter on the selected work-bar row did not focus the worker a click focuses\n{}",
|
||||
tui.debug_dump()
|
||||
)
|
||||
})?;
|
||||
|
||||
let _ = tui.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The dogfood shape: a session already carrying a to-do list, then a fan-out.
|
||||
/// Sub-agents must remain visible and clickable in the top bar — a strip that
|
||||
/// spends every row it has on to-dos and pushes the workers off the bottom is
|
||||
/// exactly the owner-reported failure.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn work_bar_still_shows_subagents_when_todos_are_present() -> Result<()> {
|
||||
let _guard = WORK_BAR_PTY_LOCK.lock().await;
|
||||
let server = MockServer::start().await;
|
||||
mount_models(&server).await;
|
||||
let child_requests = Arc::new(AtomicUsize::new(0));
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/chat/completions"))
|
||||
.respond_with(ProbeResponder {
|
||||
child_requests: Arc::clone(&child_requests),
|
||||
parent_turns: Arc::new(AtomicUsize::new(0)),
|
||||
workers: 2,
|
||||
child_hold: Duration::from_secs(25),
|
||||
})
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let ws = make_sealed_workspace()?;
|
||||
std::fs::write(
|
||||
ws.home().join(".codewhale").join("config.toml"),
|
||||
"[subagents]\nmax_concurrent = 2\nlaunch_concurrency = 2\nmax_admitted = 2\n",
|
||||
)?;
|
||||
let session_path = session_with_todos(&ws, 8)?;
|
||||
let mut tui = tui_builder(&ws, &server.uri()).spawn()?;
|
||||
tui.wait_for_text(COMPOSER_READY_TEXT, BOOT_TIMEOUT)?;
|
||||
tui.send(keys::key::text(&format!(
|
||||
"/load {}",
|
||||
session_path.to_string_lossy()
|
||||
)))?;
|
||||
tui.wait_for_idle(Duration::from_millis(150), Duration::from_secs(3))?;
|
||||
tui.send(keys::key::enter())?;
|
||||
tui.wait_for_text("todo-workbar-00", Duration::from_secs(10))?;
|
||||
|
||||
type_and_submit(&mut tui, PARENT_PROMPT)?;
|
||||
wait_for_counter(&mut tui, &child_requests, 2, INTERACTION_TIMEOUT)?;
|
||||
tui.wait_for_idle(Duration::from_millis(250), Duration::from_secs(6))?;
|
||||
|
||||
let strip = work_bar_text(&mut tui);
|
||||
let worker = work_bar_worker_row(&mut tui);
|
||||
assert!(
|
||||
worker.is_some(),
|
||||
"a running sub-agent is not reachable in the top work bar while to-dos \
|
||||
occupy it — the strip painted only:\n{strip}\n---full---\n{}",
|
||||
tui.debug_dump()
|
||||
);
|
||||
let (row, col) = worker.expect("checked above");
|
||||
tui.send(keys::mouse::click(row, col))?;
|
||||
tui.wait_for_text(FOCUS_MARKER, Duration::from_secs(5))
|
||||
.map_err(|_| {
|
||||
anyhow!(
|
||||
"clicking the sub-agent row did not focus its transcript\n{}",
|
||||
tui.debug_dump()
|
||||
)
|
||||
})?;
|
||||
|
||||
let _ = tui.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The owner's *own* `~/.codewhale/settings.toml` (2026-08-04) carries
|
||||
/// `rail_panel = "pinned"`. That is the configuration the "I spawned a sub
|
||||
/// agent and the top bar showed nothing" screenshot was taken under, and it
|
||||
/// is the one the other probes here miss: they all run on a sealed HOME with
|
||||
/// no `settings.toml`, so they only ever exercise the default `tasks` panel.
|
||||
///
|
||||
/// With zero to-dos and no active goal the Pinned projection is empty, the
|
||||
/// strip collapses to height 0, and a running sub-agent has nowhere in the
|
||||
/// top bar to appear — there is no header chip or phase-strip fallback for
|
||||
/// it. A panel choice must not be able to make running work uninspectable.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn work_bar_shows_a_running_subagent_under_the_pinned_rail_panel() -> Result<()> {
|
||||
let _guard = WORK_BAR_PTY_LOCK.lock().await;
|
||||
let server = MockServer::start().await;
|
||||
mount_models(&server).await;
|
||||
let child_requests = Arc::new(AtomicUsize::new(0));
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/chat/completions"))
|
||||
.respond_with(ProbeResponder {
|
||||
child_requests: Arc::clone(&child_requests),
|
||||
parent_turns: Arc::new(AtomicUsize::new(0)),
|
||||
workers: 2,
|
||||
child_hold: Duration::from_secs(25),
|
||||
})
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let ws = make_sealed_workspace()?;
|
||||
std::fs::write(
|
||||
ws.home().join(".codewhale").join("config.toml"),
|
||||
"[subagents]\nmax_concurrent = 2\nlaunch_concurrency = 2\nmax_admitted = 2\n",
|
||||
)?;
|
||||
// Verbatim from the owner's settings.toml, minus the keys that do not
|
||||
// touch the rail.
|
||||
std::fs::write(
|
||||
ws.home().join(".codewhale").join("settings.toml"),
|
||||
"work_surface_placement = \"top\"\nwork_surface_top_height = 16\n\
|
||||
work_surface_side_width = 30\nrail_panel = \"pinned\"\n",
|
||||
)?;
|
||||
let mut tui = tui_builder(&ws, &server.uri()).spawn()?;
|
||||
tui.wait_for_text(COMPOSER_READY_TEXT, BOOT_TIMEOUT)?;
|
||||
type_and_submit(&mut tui, PARENT_PROMPT)?;
|
||||
wait_for_counter(&mut tui, &child_requests, 2, INTERACTION_TIMEOUT)?;
|
||||
tui.wait_for_idle(Duration::from_millis(250), Duration::from_secs(6))?;
|
||||
|
||||
let strip = work_bar_text(&mut tui);
|
||||
let worker = work_bar_worker_row(&mut tui);
|
||||
assert!(
|
||||
worker.is_some(),
|
||||
"a running sub-agent is invisible in the top bar under \
|
||||
`rail_panel = \"pinned\"` — the strip painted only:\n{strip}\n---full---\n{}",
|
||||
tui.debug_dump()
|
||||
);
|
||||
let (row, col) = worker.expect("checked above");
|
||||
tui.send(keys::mouse::click(row, col))?;
|
||||
tui.wait_for_text(FOCUS_MARKER, Duration::from_secs(5))
|
||||
.map_err(|_| {
|
||||
anyhow!(
|
||||
"clicking the sub-agent row did not focus its transcript\n{}",
|
||||
tui.debug_dump()
|
||||
)
|
||||
})?;
|
||||
|
||||
let _ = tui.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A finished agent collapses out of the Top strip (so fan-outs do not
|
||||
/// permanently eat the transcript) but stays counted in the header. The
|
||||
/// Agents panel remains the standing register — see
|
||||
/// `agents_panel_click_opens_the_transcript_even_for_finished_agents`.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn work_bar_collapses_a_finished_subagent_into_the_header() -> Result<()> {
|
||||
let _guard = WORK_BAR_PTY_LOCK.lock().await;
|
||||
let server = MockServer::start().await;
|
||||
mount_models(&server).await;
|
||||
let child_requests = Arc::new(AtomicUsize::new(0));
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/chat/completions"))
|
||||
.respond_with(ProbeResponder {
|
||||
child_requests: Arc::clone(&child_requests),
|
||||
parent_turns: Arc::new(AtomicUsize::new(0)),
|
||||
workers: 1,
|
||||
child_hold: Duration::from_millis(0),
|
||||
})
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let ws = make_sealed_workspace()?;
|
||||
std::fs::write(
|
||||
ws.home().join(".codewhale").join("config.toml"),
|
||||
"[subagents]\nmax_concurrent = 2\nlaunch_concurrency = 2\nmax_admitted = 2\n",
|
||||
)?;
|
||||
let mut tui = tui_builder(&ws, &server.uri()).spawn()?;
|
||||
tui.wait_for_text(COMPOSER_READY_TEXT, BOOT_TIMEOUT)?;
|
||||
type_and_submit(&mut tui, PARENT_PROMPT)?;
|
||||
wait_for_counter(&mut tui, &child_requests, 1, INTERACTION_TIMEOUT)?;
|
||||
// Let the child settle terminal and the parent turn finish.
|
||||
tui.wait_for_idle(Duration::from_millis(300), Duration::from_secs(10))?;
|
||||
|
||||
let strip = work_bar_text(&mut tui);
|
||||
assert!(
|
||||
strip.contains("Archived 1"),
|
||||
"settled workers must remain counted in the Subagents Archived header; strip:\n{strip}\n---full---\n{}",
|
||||
tui.debug_dump()
|
||||
);
|
||||
assert!(
|
||||
work_bar_worker_row(&mut tui).is_none(),
|
||||
"a finished sub-agent must leave the Top strip rows; strip:\n{strip}\n---full---\n{}",
|
||||
tui.debug_dump()
|
||||
);
|
||||
|
||||
let _ = tui.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A strip row between the work-bar rules whose text contains any of the
|
||||
/// given needles. Returns `(row, column)` for a real SGR click.
|
||||
fn strip_row_containing(harness: &mut Harness, needles: &[&str]) -> Option<(u16, u16)> {
|
||||
let frame = harness.frame();
|
||||
let rows = frame.rows();
|
||||
let dividers: Vec<u16> = (0..rows).filter(|&y| is_divider_row(frame, y)).collect();
|
||||
let (start, end) = match dividers.as_slice() {
|
||||
[first, second, ..] => (first.saturating_add(1), *second),
|
||||
_ => (0, rows),
|
||||
};
|
||||
(start..end).find_map(|y| {
|
||||
let text = frame.row(y);
|
||||
if !needles.iter().any(|needle| text.contains(needle)) {
|
||||
return None;
|
||||
}
|
||||
let trimmed = text.trim_start();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let col = u16::try_from(text.len() - trimmed.len()).ok()?;
|
||||
Some((y, col.saturating_add(2)))
|
||||
})
|
||||
}
|
||||
|
||||
/// v0.9.7 "one agent, one destination": activating a COMPLETED child agent's
|
||||
/// row in the standing Agents register lands directly in that agent's durable
|
||||
/// transcript, and the Alt+V secondary affordance still reaches the bounded
|
||||
/// Agent Details projection from there.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn finished_agent_row_opens_its_transcript_and_alt_v_reaches_details() -> Result<()> {
|
||||
let _guard = WORK_BAR_PTY_LOCK.lock().await;
|
||||
let server = MockServer::start().await;
|
||||
mount_models(&server).await;
|
||||
let child_requests = Arc::new(AtomicUsize::new(0));
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/chat/completions"))
|
||||
.respond_with(ProbeResponder {
|
||||
child_requests: Arc::clone(&child_requests),
|
||||
parent_turns: Arc::new(AtomicUsize::new(0)),
|
||||
workers: 1,
|
||||
child_hold: Duration::from_millis(0),
|
||||
})
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let ws = make_sealed_workspace()?;
|
||||
std::fs::write(
|
||||
ws.home().join(".codewhale").join("config.toml"),
|
||||
"[subagents]\nmax_concurrent = 2\nlaunch_concurrency = 2\nmax_admitted = 2\n",
|
||||
)?;
|
||||
let mut tui = tui_builder(&ws, &server.uri()).spawn()?;
|
||||
tui.wait_for_text(COMPOSER_READY_TEXT, BOOT_TIMEOUT)?;
|
||||
type_and_submit(&mut tui, PARENT_PROMPT)?;
|
||||
wait_for_counter(&mut tui, &child_requests, 1, INTERACTION_TIMEOUT)?;
|
||||
// Let the child settle terminal and the parent turn finish.
|
||||
tui.wait_for_idle(Duration::from_millis(300), Duration::from_secs(10))?;
|
||||
tui.wait_for(
|
||||
|frame| frame.text().contains("Archived 1"),
|
||||
Duration::from_secs(10),
|
||||
)?;
|
||||
|
||||
// The finished agent has left the Top strip; the Agents panel is the
|
||||
// standing register. The Subagents group header is the two-way door.
|
||||
let header = subagents_header_row(&mut tui).ok_or_else(|| {
|
||||
anyhow!(
|
||||
"no Subagents header to open the Agents register from\n{}",
|
||||
tui.debug_dump()
|
||||
)
|
||||
})?;
|
||||
let frame = tui.frame();
|
||||
let header_text = frame.row(header);
|
||||
let header_col = u16::try_from(header_text.len() - header_text.trim_start().len()).unwrap_or(0);
|
||||
tui.send(keys::mouse::click(header, header_col.saturating_add(2)))?;
|
||||
|
||||
// The register row carries the worker's objective/role; wait for it.
|
||||
let deadline = Instant::now() + INTERACTION_TIMEOUT;
|
||||
let (row, col) = loop {
|
||||
tui.pump();
|
||||
if let Some(hit) = strip_row_containing(&mut tui, &[CHILD_MARKER, "explorer"]) {
|
||||
break hit;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(anyhow!(
|
||||
"the finished worker never appeared in the Agents register\n{}",
|
||||
tui.debug_dump()
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(40));
|
||||
};
|
||||
|
||||
// Activation lands in the focused transcript directly — no details detour.
|
||||
tui.send(keys::mouse::click(row, col))?;
|
||||
tui.wait_for_text(FOCUS_MARKER, Duration::from_secs(5))
|
||||
.map_err(|_| {
|
||||
anyhow!(
|
||||
"clicking the finished agent row did not focus its transcript\n{}",
|
||||
tui.debug_dump()
|
||||
)
|
||||
})?;
|
||||
|
||||
// The secondary affordance still reaches the bounded details projection.
|
||||
tui.send(keys::key::alt('v'))?;
|
||||
tui.wait_for_text("Agent Details", Duration::from_secs(5))
|
||||
.map_err(|_| {
|
||||
anyhow!(
|
||||
"Alt+V from the transcript did not open Agent Details\n{}",
|
||||
tui.debug_dump()
|
||||
)
|
||||
})?;
|
||||
|
||||
let _ = tui.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
# Subagent focus — one child, one conversation, one composer target
|
||||
|
||||
Status: implemented on `work/v098-agent-focus-20260815` (TUI + runtime
|
||||
contract), on by default, no settings key. Evidence: unit tests in
|
||||
`crates/tui/src/tui/agent_focus.rs`, `crates/tui/src/tui/work_surface/mod.rs`,
|
||||
`crates/tui/src/tui/phase_strip.rs`, `crates/tui/src/tools/subagent/tests.rs`;
|
||||
real-PTY journey `crates/tui/tests/pty/agent_focus_pty.rs` (42×150, 32×120,
|
||||
16×60 frames captured with `CODEWHALE_FOCUS_CAPTURE_DIR`).
|
||||
contract), on by default, no settings key. Its acceptance surface is the actual
|
||||
terminal: focus a worker, send a follow-up, confirm that the composer target and
|
||||
transcript agree, and return to the main conversation. Historical automated PTY
|
||||
captures were removed because they froze layout geometry instead of protecting
|
||||
the runtime contract.
|
||||
|
||||
## What Claude Code does (the reference behaviour)
|
||||
|
||||
@@ -98,8 +98,8 @@ through `←`/`↓` and `/agents`.
|
||||
- Whale role badges: rail rows and the focus banner render the worker name
|
||||
through `agent_focus::agent_display_label`; a badge can be prefixed there
|
||||
without touching the focus logic.
|
||||
- Compact tier (< 60 cols): the strip already collapses; the focus banner and
|
||||
chip are one line each and were verified at 16×60.
|
||||
- Compact tier (< 60 cols): the strip collapses; keep the focus banner and chip
|
||||
to one line each and inspect the current product when this surface changes.
|
||||
|
||||
### Not done / follow-ups
|
||||
|
||||
|
||||
@@ -37,19 +37,16 @@ cargo test -p codewhale-config -p codewhale-protocol -p codewhale-cli \
|
||||
# 3. TUI test binaries, locked
|
||||
cargo test -p codewhale-tui --bins --locked
|
||||
|
||||
# 4. Real-PTY release runtime QA (sealed HOME + loopback providers)
|
||||
cargo test -p codewhale-tui --test release_runtime_qa --locked -- --test-threads=1
|
||||
|
||||
# 5. TUI debug build, locked
|
||||
# 4. TUI debug build, locked
|
||||
cargo build -p codewhale-tui --locked
|
||||
|
||||
# 6. Release build for the shipped binaries, locked
|
||||
# 5. Release build for the shipped binaries, locked
|
||||
cargo build --release --locked -p codewhale-cli -p codewhale-tui
|
||||
|
||||
# 7. Version-drift gate (workspace ↔ npm ↔ Cargo.lock ↔ changelog ↔ README)
|
||||
# 6. Version-drift gate (workspace ↔ npm ↔ Cargo.lock ↔ changelog ↔ README)
|
||||
./scripts/release/check-versions.sh
|
||||
|
||||
# 8. Binary smoke
|
||||
# 7. Binary smoke
|
||||
./target/release/codewhale --version
|
||||
```
|
||||
|
||||
@@ -66,10 +63,10 @@ A PR that is clean against `main` can still conflict with the release branch.
|
||||
|
||||
Unit/build gates do not cover the live TUI. Exercise all three and record what you saw:
|
||||
|
||||
The repeatable local baseline is `release_runtime_qa`: it boots real TUI
|
||||
processes in pseudo-terminals with sealed homes and loopback mock providers,
|
||||
then asserts each scenario below. Run it even when doing a separate hands-on
|
||||
visual pass; the test leaves no provider traffic or credentials behind.
|
||||
Use the built binary with a sealed local home and loopback fixtures, then
|
||||
exercise the relevant scenarios below in an actual terminal. Record dimensions,
|
||||
inputs, visible state, and side effects. Do not substitute a full-screen
|
||||
assertion harness for looking at and using the product.
|
||||
|
||||
1. **Six-worker fanout liveness (#3216/#2211).** Spawn 6 sub-agents. Confirm
|
||||
typing, render, cancel, and the sidebar stay live throughout, and that **Esc
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
#!/bin/bash
|
||||
# v0.9.6 runtime continuity suite — the provider-neutral release gate that
|
||||
# keeps the v0.9.6 failure family fixed (codewhale-ops v0.9.7 ledger, P0).
|
||||
#
|
||||
# The invariants under guard, and where they live:
|
||||
# - Compaction pressure comes from the latest parent request, never
|
||||
# cumulative billing or child usage -> tui --lib core turn/turn_loop
|
||||
# - Repeated compaction replaces the prior summary and keeps the stable
|
||||
# request prefix byte-stable -> tui --lib compaction,
|
||||
# --test integration cache_guard
|
||||
# - Manual /compact queues behind an active turn (including a saturated
|
||||
# op mailbox), auto compaction runs only at a safe boundary, outcomes
|
||||
# land as durable transcript receipts -> --test pty release_runtime_qa
|
||||
# - Truthful reasoning display and live shell-wait progress
|
||||
# -> --test pty release_runtime_qa
|
||||
#
|
||||
# Run from the repository root. Any failure is a release blocker for the
|
||||
# runtime; do not rename a failure a flake without an isolated rerun.
|
||||
set -u
|
||||
|
||||
overall=0
|
||||
run_gate() {
|
||||
local name="$1"; shift
|
||||
echo "=== CONTINUITY GATE: ${name} ==="
|
||||
if "$@"; then
|
||||
echo "PASS: ${name}"
|
||||
else
|
||||
overall=1
|
||||
echo "FAIL: ${name}"
|
||||
fi
|
||||
}
|
||||
|
||||
run_gate "turn-and-compaction-units" \
|
||||
cargo test -p codewhale-tui --lib -- compaction turn_loop turn::
|
||||
run_gate "cache-prefix-guard" \
|
||||
cargo test -p codewhale-tui --test integration cache_guard
|
||||
run_gate "release-runtime-qa-pty" \
|
||||
cargo test -p codewhale-tui --test pty release_runtime_qa
|
||||
|
||||
echo "=== CONTINUITY SUITE: $([ $overall -eq 0 ] && echo PASS || echo FAIL) ==="
|
||||
exit $overall
|
||||
@@ -14,7 +14,6 @@
|
||||
# scripts/dev-test.sh config
|
||||
# scripts/dev-test.sh tui elapsed::
|
||||
# scripts/dev-test.sh crates/tui/src/elapsed.rs
|
||||
# scripts/dev-test.sh tui-pty qa_pty
|
||||
#
|
||||
# Environment:
|
||||
# CODEWHALE_DEV_NEXTEST auto|1|0 (default auto: use cargo-nextest when
|
||||
@@ -58,7 +57,6 @@ telemetry cargo test -p codewhale-telemetry --lib --locked
|
||||
tools cargo test -p codewhale-tools --lib --locked
|
||||
tui cargo test -p codewhale-tui --lib --locked
|
||||
tui-integration cargo test -p codewhale-tui --test integration --locked
|
||||
tui-pty cargo test -p codewhale-tui --test pty --locked
|
||||
tui-cucumber cargo test -p codewhale-tui --test cucumber --locked
|
||||
workflow cargo test -p codewhale-workflow --lib --locked
|
||||
workflow-js cargo test -p codewhale-workflow-js --lib --locked
|
||||
@@ -72,7 +70,6 @@ crates/tui/src/core/ tui core::
|
||||
crates/tui/src/commands/ tui commands::
|
||||
crates/tui/src/<file>.rs tui <file>::
|
||||
crates/tui/tests/integration/ tui-integration <stem>
|
||||
crates/tui/tests/pty/ tui-pty <stem>
|
||||
crates/tui/tests/cucumber/ tui-cucumber <stem>
|
||||
crates/tui/tests/ tui-integration
|
||||
|
||||
@@ -110,10 +107,6 @@ if [ -e "$area" ] || printf '%s' "$area" | grep -q /; then
|
||||
area=tui-integration
|
||||
extra=$(basename "$rel" .rs)
|
||||
;;
|
||||
crates/tui/tests/pty/*)
|
||||
area=tui-pty
|
||||
extra=$(basename "$rel" .rs)
|
||||
;;
|
||||
crates/tui/tests/cucumber/*)
|
||||
area=tui-cucumber
|
||||
extra=$(basename "$rel" .rs)
|
||||
@@ -191,11 +184,6 @@ case $area in
|
||||
target=--test
|
||||
harness=integration
|
||||
;;
|
||||
tui-pty)
|
||||
pkg=codewhale-tui
|
||||
target=--test
|
||||
harness=pty
|
||||
;;
|
||||
tui-cucumber)
|
||||
pkg=codewhale-tui
|
||||
target=--test
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Hermetic tests for scripts/dev-test.sh: area routing, nextest vs
|
||||
# libtest selection, and that the cache helper is actually applied.
|
||||
# Uses a fake cargo; does not compile anything.
|
||||
set -eu
|
||||
|
||||
repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
DEV_TEST=$repo_root/scripts/dev-test.sh
|
||||
|
||||
fail=0
|
||||
pass=0
|
||||
|
||||
ok() {
|
||||
pass=$((pass + 1))
|
||||
printf 'ok - %s\n' "$1"
|
||||
}
|
||||
|
||||
bad() {
|
||||
fail=$((fail + 1))
|
||||
printf 'FAIL - %s\n' "$1"
|
||||
if [ -n "${2:-}" ]; then
|
||||
printf '%s\n' "$2" | sed 's/^/ /'
|
||||
fi
|
||||
}
|
||||
|
||||
work=$(mktemp -d "${TMPDIR:-/tmp}/codewhale-dev-test.XXXXXX")
|
||||
cleanup() { rm -rf "$work"; }
|
||||
trap cleanup EXIT INT HUP TERM
|
||||
|
||||
FAKEBIN=$work/bin
|
||||
mkdir -p "$FAKEBIN"
|
||||
NEW_WT=$work/new-wt
|
||||
mkdir -p "$NEW_WT"
|
||||
|
||||
cat >"$FAKEBIN/cargo" <<'EOF'
|
||||
#!/bin/sh
|
||||
printf 'CARGO:%s\n' "$*"
|
||||
printf 'CARGO_BUILD_BUILD_DIR=%s\n' "${CARGO_BUILD_BUILD_DIR-<unset>}"
|
||||
printf 'RUSTC_WRAPPER=%s\n' "${RUSTC_WRAPPER-<unset>}"
|
||||
printf 'CARGO_INCREMENTAL=%s\n' "${CARGO_INCREMENTAL-<unset>}"
|
||||
printf 'RUST_MIN_STACK=%s\n' "${RUST_MIN_STACK-<unset>}"
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$FAKEBIN/cargo"
|
||||
|
||||
BASE_PATH=$FAKEBIN:/usr/bin:/bin
|
||||
|
||||
run_dev_test() {
|
||||
# Leading KEY=VAL pairs are extra env; the rest are script arguments.
|
||||
_extra=$work/extra-env
|
||||
: >"$_extra"
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
*=*)
|
||||
printf '%s\n' "$1" >>"$_extra"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
# env -i + env-file keeps values with spaces intact and never treats
|
||||
# --list as an env(1) option.
|
||||
set -- /bin/sh "$DEV_TEST" "$@"
|
||||
while IFS= read -r _line; do
|
||||
set -- "$_line" "$@"
|
||||
done <"$_extra"
|
||||
env -i \
|
||||
PATH="$BASE_PATH" \
|
||||
HOME="$work/home" \
|
||||
CODEWHALE_DEV_CACHE_QUIET=1 \
|
||||
CODEWHALE_DEV_CACHE_REPO_ROOT="$NEW_WT" \
|
||||
CODEWHALE_CACHE_ROOT="$work/cache" \
|
||||
"$@"
|
||||
}
|
||||
|
||||
contains() {
|
||||
printf '%s' "$1" | grep -qF -- "$2"
|
||||
}
|
||||
|
||||
# --list names every workspace crate family, including the ones the old
|
||||
# mapper dropped on the floor.
|
||||
list_out=$(run_dev_test --list)
|
||||
for needle in \
|
||||
"codewhale-agent" \
|
||||
"codewhale-app-server" \
|
||||
"codewhale-build-support" \
|
||||
"codewhale-command-contract" \
|
||||
"codewhale-hooks" \
|
||||
"codewhale-lane" \
|
||||
"codewhale-mcp" \
|
||||
"codewhale-release" \
|
||||
"codewhale-state" \
|
||||
"codewhale-telemetry" \
|
||||
"codewhale-workflow" \
|
||||
"codewhale-workflow-js" \
|
||||
"codewhale-tui --lib" \
|
||||
"codewhale-tui --test pty"
|
||||
do
|
||||
if contains "$list_out" "$needle"; then
|
||||
ok "--list mentions $needle"
|
||||
else
|
||||
bad "--list mentions $needle" "$list_out"
|
||||
fi
|
||||
done
|
||||
|
||||
# Narrow routing: libtest forced so we assert cargo test argv.
|
||||
out=$(run_dev_test CODEWHALE_DEV_NEXTEST=0 config)
|
||||
if contains "$out" "+ cargo test -p codewhale-config --lib --locked" \
|
||||
&& contains "$out" "test -p codewhale-config --lib --locked"; then
|
||||
ok "area config maps to codewhale-config --lib"
|
||||
else
|
||||
bad "area config maps to codewhale-config --lib" "$out"
|
||||
fi
|
||||
|
||||
out=$(run_dev_test CODEWHALE_DEV_NEXTEST=0 crates/tui/src/elapsed.rs)
|
||||
if contains "$out" "test -p codewhale-tui --lib --locked elapsed::"; then
|
||||
ok "path crates/tui/src/elapsed.rs invents elapsed::"
|
||||
else
|
||||
bad "path crates/tui/src/elapsed.rs invents elapsed::" "$out"
|
||||
fi
|
||||
|
||||
out=$(run_dev_test CODEWHALE_DEV_NEXTEST=0 crates/workflow-js/src/eval.rs)
|
||||
if contains "$out" "test -p codewhale-workflow-js --lib --locked"; then
|
||||
ok "path crates/workflow-js maps to codewhale-workflow-js"
|
||||
else
|
||||
bad "path crates/workflow-js maps to codewhale-workflow-js" "$out"
|
||||
fi
|
||||
|
||||
out=$(run_dev_test CODEWHALE_DEV_NEXTEST=0 crates/app-server/src/lib.rs)
|
||||
if contains "$out" "test -p codewhale-app-server --lib --locked"; then
|
||||
ok "path crates/app-server maps to codewhale-app-server"
|
||||
else
|
||||
bad "path crates/app-server maps to codewhale-app-server" "$out"
|
||||
fi
|
||||
|
||||
out=$(run_dev_test CODEWHALE_DEV_NEXTEST=0 tui-pty qa_pty)
|
||||
if contains "$out" "test -p codewhale-tui --test pty --locked qa_pty"; then
|
||||
ok "tui-pty maps to the pty harness"
|
||||
else
|
||||
bad "tui-pty maps to the pty harness" "$out"
|
||||
fi
|
||||
|
||||
# Unknown area fails closed.
|
||||
rc=0
|
||||
out=$(run_dev_test CODEWHALE_DEV_NEXTEST=0 not-a-crate 2>&1) || rc=$?
|
||||
if [ "$rc" -ne 0 ] && contains "$out" "unknown area"; then
|
||||
ok "unknown area fails closed"
|
||||
else
|
||||
bad "unknown area fails closed" "rc=$rc $out"
|
||||
fi
|
||||
|
||||
# New worktree applies the isolated build-dir (compile-time topology).
|
||||
out=$(run_dev_test CODEWHALE_DEV_NEXTEST=0 config)
|
||||
if contains "$out" "CARGO_BUILD_BUILD_DIR=$work/cache/build/{workspace-path-hash}" \
|
||||
&& contains "$out" '--config build.build-dir = "'; then
|
||||
ok "dev-test applies isolated build-dir on a new worktree"
|
||||
else
|
||||
bad "dev-test applies isolated build-dir on a new worktree" "$out"
|
||||
fi
|
||||
if contains "$out" "RUST_MIN_STACK=16777216"; then
|
||||
ok "dev-test sets RUST_MIN_STACK when unset"
|
||||
else
|
||||
bad "dev-test sets RUST_MIN_STACK when unset" "$out"
|
||||
fi
|
||||
if contains "$out" "RUSTC_WRAPPER=<unset>"; then
|
||||
ok "dev-test does not wrap rustc on the incremental default"
|
||||
else
|
||||
bad "dev-test does not wrap rustc on the incremental default" "$out"
|
||||
fi
|
||||
|
||||
# CODEWHALE_DEV_CACHE=0 really disables the topology.
|
||||
out=$(run_dev_test CODEWHALE_DEV_NEXTEST=0 CODEWHALE_DEV_CACHE=0 config)
|
||||
if contains "$out" "CARGO_BUILD_BUILD_DIR=<unset>" \
|
||||
&& ! contains "$out" '--config build.build-dir'; then
|
||||
ok "CODEWHALE_DEV_CACHE=0 skips the cache topology"
|
||||
else
|
||||
bad "CODEWHALE_DEV_CACHE=0 skips the cache topology" "$out"
|
||||
fi
|
||||
|
||||
# nextest is used when cargo-nextest is on PATH (test-runtime).
|
||||
printf '%s\n' '#!/bin/sh' 'exit 0' >"$FAKEBIN/cargo-nextest"
|
||||
chmod +x "$FAKEBIN/cargo-nextest"
|
||||
out=$(run_dev_test tui elapsed::)
|
||||
if contains "$out" "+ cargo nextest run -p codewhale-tui --lib --locked elapsed::" \
|
||||
&& contains "$out" "nextest run -p codewhale-tui --lib --locked elapsed::"; then
|
||||
ok "nextest is used when cargo-nextest is installed"
|
||||
else
|
||||
bad "nextest is used when cargo-nextest is installed" "$out"
|
||||
fi
|
||||
|
||||
out=$(run_dev_test CODEWHALE_DEV_NEXTEST=0 tui elapsed::)
|
||||
if contains "$out" "+ cargo test -p codewhale-tui --lib --locked elapsed::" \
|
||||
&& contains "$out" "test -p codewhale-tui --lib --locked elapsed::"; then
|
||||
ok "CODEWHALE_DEV_NEXTEST=0 forces cargo test"
|
||||
else
|
||||
bad "CODEWHALE_DEV_NEXTEST=0 forces cargo test" "$out"
|
||||
fi
|
||||
|
||||
# Forced nextest without the binary fails closed instead of silent cargo.
|
||||
rm -f "$FAKEBIN/cargo-nextest"
|
||||
rc=0
|
||||
out=$(run_dev_test CODEWHALE_DEV_NEXTEST=1 config 2>&1) || rc=$?
|
||||
if [ "$rc" -ne 0 ] && contains "$out" "cargo-nextest is not on PATH"; then
|
||||
ok "CODEWHALE_DEV_NEXTEST=1 without nextest fails closed"
|
||||
else
|
||||
bad "CODEWHALE_DEV_NEXTEST=1 without nextest fails closed" "rc=$rc $out"
|
||||
fi
|
||||
|
||||
if [ "$fail" -eq 0 ]; then
|
||||
printf 'dev-test.test.sh: all %s checks passed\n' "$pass"
|
||||
exit 0
|
||||
fi
|
||||
printf 'dev-test.test.sh: %s/%s checks failed\n' "$fail" "$((pass + fail))" >&2
|
||||
exit 1
|
||||
Reference in new issue
Block a user