M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
# Synthetic bash-history corpus for permission-view / fuzzy-match tests.
|
||||
# All hostnames, paths, user names, branch names, and flags are fictionalized
|
||||
# or generic. No real production session content. (OSS fixture hygiene.)
|
||||
#
|
||||
### CMD 01
|
||||
gh api user --jq '.login' && echo "---" && gh search prs --author=@me --sort=updated --limit=15 --json number,title,url,state,updatedAt,repository,isDraft --jq '.[] | "\(.state)\t#\(.number)\t\(.updatedAt)\t\(.repository.nameWithOwner)\t\(.title)\t\(.url)"'
|
||||
### END
|
||||
### CMD 02
|
||||
cargo test \
|
||||
--all \
|
||||
-- --nocapture
|
||||
### END
|
||||
### CMD 03
|
||||
docker run \
|
||||
-v /tmp:/tmp \
|
||||
-e FOO=bar \
|
||||
alpine:latest echo hi
|
||||
### END
|
||||
### CMD 04
|
||||
cat <<'EOF' && echo after
|
||||
line with && not an operator
|
||||
line with | pipe text
|
||||
EOF
|
||||
### END
|
||||
### CMD 05
|
||||
echo "a && b" && echo real && echo 'x | y'
|
||||
### END
|
||||
### CMD 06
|
||||
git status --short --branch && cargo test --workspace --all-features
|
||||
### END
|
||||
### CMD 07
|
||||
ps aux | grep -v grep | grep cargo | head -n 20
|
||||
### END
|
||||
### CMD 08
|
||||
true&&false||true; echo done
|
||||
### END
|
||||
### CMD 09
|
||||
cd /tmp && sleep 1 && timeout 30 cargo check 2>&1 | tail -50
|
||||
### END
|
||||
### CMD 10
|
||||
python3 -c 'print("hello && world | not pipe")' && echo ok
|
||||
### END
|
||||
### CMD 11
|
||||
cd /tmp/compaction-repro && KIGI_AUTO_COMPACT_THRESHOLD_PERCENT=1 /tmp/pager-under-test -p 'reply with exactly: ok' --model example-model --always-approve --no-leader > /tmp/compaction-repro/headless2.log 2>&1; echo EXIT=$? >> /tmp/compaction-repro/headless2.log
|
||||
### END
|
||||
### CMD 12
|
||||
cd /tmp/compaction-repro && KIGI_AUTO_COMPACT_THRESHOLD_PERCENT=1 /tmp/pager-under-test -p 'reply with exactly: ok' --always-approve --no-leader > /tmp/compaction-repro/headless1.log 2>&1; echo EXIT=$? >> /tmp/compaction-repro/headless1.log
|
||||
### END
|
||||
### CMD 13
|
||||
cd /Users/alice/Documents/code/project && DYLD_INSERT_LIBRARIES=/opt/homebrew/opt/libfaketime/lib/faketime/libfaketime.1.dylib FAKETIME="@2026-06-11 23:58:30" FAKETIME_DONT_FAKE_MONOTONIC=1 /tmp/pager-under-test-dr --no-leader --always-approve 2>/tmp/ftroll.err
|
||||
### END
|
||||
### CMD 14
|
||||
! kubectl oidc-login get-token --oidc-issuer-url https://login.example.com --oidc-client-id example-client-id --oidc-extra-scope "openid profile email groups offline_access" >/dev/null && echo OIDC_OK
|
||||
### END
|
||||
### CMD 15
|
||||
ssh -t -L 8000:localhost:8000 user@build-host.example.internal 'cd /home/user/project && export PATH=$HOME/.cargo/bin:$PATH && ./scripts/login.sh'
|
||||
### END
|
||||
### CMD 16
|
||||
cd /Users/alice/Documents/code/project && DYLD_INSERT_LIBRARIES=/opt/homebrew/opt/libfaketime/lib/faketime/libfaketime.1.dylib FAKETIME_TIMESTAMP_FILE=/tmp/ftfile FAKETIME_NO_CACHE=1 FAKETIME_DONT_FAKE_MONOTONIC=1 /tmp/pager-under-test-dr --no-leader --always-approve 2>/tmp/ftdbg.err
|
||||
### END
|
||||
### CMD 17
|
||||
! kubectl --context kind-local -n example-system get secret example-db-password -o jsonpath='{.data.EXAMPLE_DB_PASSWORD}' | base64 -d
|
||||
### END
|
||||
### CMD 18
|
||||
curl -fsSL x.ai/cli/install.sh | grep jq
|
||||
### END
|
||||
### CMD 19
|
||||
curl -LsSf "http://file-server.example.internal/cicd/example/install.sh?nocache" | bash
|
||||
### END
|
||||
### CMD 20
|
||||
! sudo du -shx /.Spotlight-V100 /.DocumentRevisions-V100 /private/var/vm /private/var/folders /cores /Library 2>/dev/null | sort -rh
|
||||
### END
|
||||
### CMD 21
|
||||
cat ~/.grok/config.toml | grep composer
|
||||
### END
|
||||
### CMD 22
|
||||
KIGI_DEBUG_CONTEXT_WINDOW=100000 KIGI_AUTO_COMPACT_THRESHOLD_PERCENT=5 /tmp/pager-under-test -r 019e0000-0000-7000-8000-000000000001 -p 'reply with exactly: again' --model example-model --always-approve --no-leader > /tmp/compaction-repro/headless5.log 2>&1; echo EXIT=$? >> /tmp/compaction-repro/headless5.log
|
||||
### END
|
||||
### CMD 23
|
||||
KIGI_AUTO_COMPACT_THRESHOLD_PERCENT=1 /tmp/pager-under-test -r 019e0000-0000-7000-8000-000000000001 -p 'now reply with exactly: done' --model example-model --always-approve --no-leader > /tmp/compaction-repro/headless3.log 2>&1; echo EXIT=$? >> /tmp/compaction-repro/headless3.log
|
||||
### END
|
||||
### CMD 24
|
||||
KIGI_AUTO_COMPACT_THRESHOLD_PERCENT=1 /tmp/pager-under-test -r 019e0000-0000-7000-8000-000000000001 -p 'reply with exactly: final' --model example-model --always-approve --no-leader > /tmp/compaction-repro/headless4.log 2>&1; echo EXIT=$? >> /tmp/compaction-repro/headless4.log
|
||||
### END
|
||||
### CMD 25
|
||||
KIGI_AUTO_COMPACT_THRESHOLD_PERCENT=1 /tmp/pager-under-test -p 'reply with exactly: ok' --model example-model --always-approve --no-leader > /tmp/compaction-repro/headless1.log 2>&1; echo EXIT=$? >> /tmp/compaction-repro/headless1.log
|
||||
### END
|
||||
### CMD 26
|
||||
cd /Users/alice/Documents/code/project-wt/fix-prompt-wheel-history && \\
|
||||
### END
|
||||
### CMD 27
|
||||
strings target/debug/kigi-tui | grep -c slash-mru-writer # must print >=1\
|
||||
### END
|
||||
### CMD 28
|
||||
SSH_CONNECTION='10.0.0.1 51234 10.0.0.2 22' \\
|
||||
### END
|
||||
### CMD 29
|
||||
DYLD_FORCE_FLAT_NAMESPACE=1 DYLD_INSERT_LIBRARIES=/opt/homebrew/lib/faketime/libfaketime.1.dylib FAKETIME_TIMESTAMP_FILE=/tmp/ft.rc FAKETIME_NO_CACHE=1 FAKETIME_DONT_FAKE_MONOTONIC=1 /tmp/pager-date-test --model grok-build --always-approve --no-leader
|
||||
### END
|
||||
### CMD 30
|
||||
DYLD_FORCE_FLAT_NAMESPACE=1 DYLD_INSERT_LIBRARIES=/opt/homebrew/lib/faketime/libfaketime.1.dylib FAKETIME_TIMESTAMP_FILE=/tmp/ft.rc FAKETIME_NO_CACHE=1 FAKETIME_DONT_FAKE_MONOTONIC=1 /tmp/pager-date-test --always-approve --no-leader
|
||||
### END
|
||||
@@ -0,0 +1,37 @@
|
||||
//! `KIGI_SHARE_DIR` override tests in an isolated binary so `kigi_home()`'s
|
||||
//! process-wide `OnceLock` initializes from the overridden env var.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn kigi_home_override_path_helpers() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let kigi_home = tmp.path().to_path_buf();
|
||||
unsafe {
|
||||
std::env::set_var("KIGI_SHARE_DIR", &kigi_home);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
kigi_tui::util::pager_toml_path(),
|
||||
kigi_home.join("pager.toml")
|
||||
);
|
||||
assert_eq!(
|
||||
kigi_tui::util::display_kigi_home_prefix(),
|
||||
"$KIGI_SHARE_DIR"
|
||||
);
|
||||
assert_eq!(
|
||||
kigi_tui::util::display_user_grok_path("config.toml"),
|
||||
"$KIGI_SHARE_DIR/config.toml"
|
||||
);
|
||||
|
||||
let memory_path = kigi_home.join("memory/MEMORY.md");
|
||||
assert_eq!(
|
||||
kigi_tui::util::abbreviate_path(&memory_path.display().to_string()),
|
||||
"$KIGI_SHARE_DIR/memory/MEMORY.md"
|
||||
);
|
||||
|
||||
assert!(kigi_tui::util::is_under_user_kigi_home(&memory_path));
|
||||
assert!(!kigi_tui::util::is_under_user_kigi_home(
|
||||
PathBuf::from("/tmp/other").as_path()
|
||||
));
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
// Per-test-case module for the `leader_pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// **Leader mode: a `/model` pick in the TUI dismisses a remote campaign.**
|
||||
///
|
||||
/// The dismiss chokepoint (`persist_user_choice`) runs in the **TUI process**,
|
||||
/// but in leader mode no in-process agent ever seeds the TUI's remote campaign
|
||||
/// cache — only `app::run`'s own seed makes a remote campaign visible to
|
||||
/// `resolve_dismissable_campaigns`. Without that seed this test times out in
|
||||
/// the dismiss phase: the pick persists but no dismissal is recorded, and the
|
||||
/// leader re-nudges every new session over the user's explicit choice.
|
||||
///
|
||||
/// The TUI's settings prefetch is deliberately 2s-capped, so on a loaded
|
||||
/// runner a spawn can miss the fetch (unseeded cache — the documented
|
||||
/// transient leader-mode divergence). The test retries with fresh TUI spawns
|
||||
/// (same leader) until a pick lands the dismissal, then proves it sticks.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "PTY e2e; run with cargo test -p kigi-tui --test leader_pty_e2e -- --ignored --test-threads=1"]
|
||||
async fn campaign_leader_mode_remote_dismiss_on_model_pick() {
|
||||
const CONFIG_MODEL: &str = "config-model";
|
||||
const CAMPAIGN_MODEL: &str = "campaign-model";
|
||||
const CAMPAIGN_ID: &str = "e2e-leader-remote-nudge";
|
||||
|
||||
let content = ContentController::start_with_models(vec![
|
||||
MockModel::new(CONFIG_MODEL),
|
||||
MockModel::new(CAMPAIGN_MODEL),
|
||||
])
|
||||
.await
|
||||
.expect("start content with two models");
|
||||
|
||||
// Serve the campaign from the settings endpoint (restating `allow_access`,
|
||||
// which the preset otherwise provides).
|
||||
content.server().set_settings(json!({
|
||||
"allow_access": true,
|
||||
"campaigns": [
|
||||
{ "id": CAMPAIGN_ID, "models": { "default": CAMPAIGN_MODEL } }
|
||||
]
|
||||
}));
|
||||
|
||||
// Seed config.toml with the user's own default model; a fixed leader
|
||||
// socket under the shared KIGI_SHARE_DIR so every spawn elects/attaches to the
|
||||
// same leader (mirrors `LeaderCluster`).
|
||||
let kigi_home = content.home().join(".kigi");
|
||||
std::fs::create_dir_all(&kigi_home).expect("create KIGI_SHARE_DIR");
|
||||
std::fs::write(
|
||||
kigi_home.join("config.toml"),
|
||||
format!("[models]\ndefault = \"{CONFIG_MODEL}\"\n"),
|
||||
)
|
||||
.expect("write config.toml");
|
||||
let socket = kigi_home.join("leader-e2e.sock");
|
||||
let socket = socket.to_str().expect("socket path is utf-8").to_owned();
|
||||
|
||||
// Session (OAuth) auth, not the harness's default XAI_API_KEY: the
|
||||
// settings fetch requires `auth_manager.auth()` — in ApiKey/BYOK mode the
|
||||
// pager never requests `/v1/settings`, so a remote campaign would be
|
||||
// structurally unreachable (see `spawn_polling_session`'s doc).
|
||||
seed_fake_oauth(&content, "pty-campaign-leader");
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let env = oauth_env_for_pager(&content);
|
||||
let spawn = || -> PtyHarness {
|
||||
let env_refs: Vec<(&str, &str)> =
|
||||
env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
PtyHarness::new(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&["--leader", "--leader-socket", &socket],
|
||||
&env_refs,
|
||||
)
|
||||
.expect("spawn leader-mode pager")
|
||||
};
|
||||
let state_path = kigi_home.join("campaigns_state.json");
|
||||
let dismissed = |state_path: &std::path::Path| {
|
||||
std::fs::read_to_string(state_path)
|
||||
.map(|s| s.contains(CAMPAIGN_ID))
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
// ── Phase 1+2: nudge on a new session; a pick records the dismissal in
|
||||
// the TUI process. Retries fresh TUI spawns (same leader) so a missed
|
||||
// 2s prefetch window on a loaded runner can't wedge the test.
|
||||
let mut recorded = false;
|
||||
'attempts: for attempt in 0..3 {
|
||||
let mut h = spawn();
|
||||
h.wait_for_text(WELCOME_SCREEN_SENTINEL, LEADER_TIMEOUT)
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"leader-mode welcome never rendered (attempt {attempt})\nscreen:\n{}",
|
||||
h.screen_contents()
|
||||
)
|
||||
});
|
||||
if !wait_for_model_via_new_sessions(&mut h, CAMPAIGN_MODEL, Duration::from_secs(60)) {
|
||||
// Campaign never applied on this spawn; try a fresh TUI.
|
||||
h.quit().expect("clean quit");
|
||||
continue;
|
||||
}
|
||||
h.inject_keys(format!("/model {CONFIG_MODEL}\r").as_bytes())
|
||||
.expect("pick model");
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
while Instant::now() < deadline {
|
||||
h.update(Duration::from_millis(200));
|
||||
if dismissed(&state_path) {
|
||||
recorded = true;
|
||||
h.quit().expect("clean quit");
|
||||
break 'attempts;
|
||||
}
|
||||
}
|
||||
// The regression under test: pick persisted but dismissal missing.
|
||||
// With the app::run seed present this only happens when the prefetch
|
||||
// missed on this spawn; retry once more before declaring failure.
|
||||
h.quit().expect("clean quit");
|
||||
}
|
||||
assert!(
|
||||
recorded,
|
||||
"leader-mode TUI must record the remote campaign dismissal in {state_path:?}"
|
||||
);
|
||||
|
||||
// ── Phase 3: the dismissal is durable and the pick is persisted. The
|
||||
// user's choice must be in config.toml (campaign value never laundered
|
||||
// in), and the dismissed id on disk is what every future resolution —
|
||||
// leader or not — filters on (`dismissed_id_is_dropped_from_override`
|
||||
// pins the filter; the sibling remote-settings e2e pins the full
|
||||
// no-re-nudge reboot in-process). A fresh same-leader-socket client is
|
||||
// deliberately not asserted on-screen here: reattach paint timing is the
|
||||
// one flaky piece and adds no coverage over the disk + sibling asserts.
|
||||
let config = std::fs::read_to_string(kigi_home.join("config.toml")).expect("read config.toml");
|
||||
assert!(
|
||||
config.contains(&format!("default = \"{CONFIG_MODEL}\"")),
|
||||
"the user's pick must be persisted to config.toml:\n{config}"
|
||||
);
|
||||
assert!(
|
||||
!config.contains(CAMPAIGN_MODEL),
|
||||
"the campaign value must never be written to config.toml:\n{config}"
|
||||
);
|
||||
assert!(
|
||||
dismissed(&state_path),
|
||||
"the dismissal must survive on disk after the client exits"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Shared constants for the leader-mode PTY e2e tests.
|
||||
//!
|
||||
//! Drive/seed helpers live in `kigi_pager_pty_harness::flows` (one
|
||||
//! canonical copy shared with `pty_e2e`); only suite-local constants stay
|
||||
//! here.
|
||||
|
||||
pub(crate) use kigi_pager_pty_harness::{
|
||||
ContentController, LeaderCluster, MockModel, PtyHarness, inference_request_count, keys,
|
||||
oauth_env_for_pager, pager_binary, seed_fake_oauth, submit_turn, wait_for_labels_absent,
|
||||
wait_for_model_via_new_sessions,
|
||||
};
|
||||
pub(crate) use serde_json::json;
|
||||
pub(crate) use std::time::{Duration, Instant};
|
||||
|
||||
/// Default PTY size used by every e2e test (same as `pty_e2e`).
|
||||
pub(crate) const DEFAULT_ROWS: u16 = 50;
|
||||
|
||||
pub(crate) const DEFAULT_COLS: u16 = 120;
|
||||
|
||||
/// Substring we wait for on the welcome screen (matches the menu label).
|
||||
pub(crate) const WELCOME_SCREEN_SENTINEL: &str = "Quit";
|
||||
|
||||
/// Prompt sent to the agent in content-driven tests.
|
||||
pub(crate) const PROMPT: &str = "go";
|
||||
|
||||
/// Response sentinel the mock server streams back.
|
||||
pub(crate) const MOCK_RESPONSE_SENTINEL: &str = "MOCKRESPONSE";
|
||||
|
||||
/// Cold leader-client bring-up budget. History: 60s → 120s → 240s while these
|
||||
/// cases ran interleaved with the full `pty_e2e` suite (each leader case
|
||||
/// spawns multiple full pager processes; suite-wide contention pushed cold
|
||||
/// bring-up past two minutes). Now that the leader cases run in their own
|
||||
/// serialized target the budget can come back down after a green soak week —
|
||||
/// tracked in the leader test-infra plan.
|
||||
pub(crate) const LEADER_TIMEOUT: Duration = Duration::from_secs(240);
|
||||
|
||||
/// Streamed-turn deadline in leader mode (same contention rationale).
|
||||
pub(crate) const STREAM_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
/// Sentinel for leader-test turn `n`, short enough to never wrap at 120 cols
|
||||
/// (wrapping would break the exactly-once occurrence counts).
|
||||
pub(crate) fn turn_sentinel(n: u8) -> String {
|
||||
format!("{MOCK_RESPONSE_SENTINEL}_T{n}")
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Per-test-case module for the `leader_pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// 23. **Leader mode — N clients share one session.**
|
||||
/// Scales the multi-client fan-out to N = 1 driver + `VIEWERS` viewers (bump
|
||||
/// `VIEWERS` to widen it). The driver spawns the leader and runs a turn; every
|
||||
/// viewer attaches through the SAME leader and must replay that turn exactly
|
||||
/// once (duplicated replay history and an empty/stuck pane both fail); a later
|
||||
/// turn driven from the driver must stream live into ALL viewers exactly once;
|
||||
/// and the leader + viewers must survive the spawning client's exit.
|
||||
/// Deliberately NOT a superset of `leader_two_clients_shared_session`: the
|
||||
/// driver/viewer role-flip (a turn driven from a viewer back to the driver)
|
||||
/// and the multi-turn scrollback exactly-once integrity stay unique to that
|
||||
/// test, so it must not be deleted as redundant.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 6)]
|
||||
#[ignore = "PTY e2e; run with cargo test -p kigi-tui --test leader_pty_e2e -- --ignored --test-threads=1"]
|
||||
async fn leader_n_clients_shared_session() {
|
||||
// N = 1 driver + VIEWERS viewers; bump to scale the live fan-out. Keep N
|
||||
// small: worker_threads above and the per-viewer survival pump below are
|
||||
// sized for it — raise them together if you scale VIEWERS up.
|
||||
const VIEWERS: usize = 2;
|
||||
|
||||
let cluster = LeaderCluster::start(DEFAULT_ROWS, DEFAULT_COLS)
|
||||
.await
|
||||
.expect("start cluster");
|
||||
cluster
|
||||
.content()
|
||||
.set_response(format!("{} first turn payload.", turn_sentinel(1)));
|
||||
|
||||
// The driver spawns the leader and runs turn 1.
|
||||
let mut a = cluster.spawn_leader(&[]).expect("spawn driver");
|
||||
a.wait_for_text(WELCOME_SCREEN_SENTINEL, LEADER_TIMEOUT)
|
||||
.expect("driver welcome");
|
||||
a.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("driver submit turn 1");
|
||||
a.wait_for_text(&turn_sentinel(1), STREAM_TIMEOUT)
|
||||
.expect("driver turn 1");
|
||||
|
||||
// Every viewer attaches through the shared leader and must replay the
|
||||
// driver's transcript exactly once (duplicated replay or an empty pane
|
||||
// both fail).
|
||||
let mut viewers: Vec<PtyHarness> = Vec::new();
|
||||
for i in 0..VIEWERS {
|
||||
let mut v = cluster
|
||||
.attach(&[])
|
||||
.unwrap_or_else(|e| panic!("spawn viewer {i}: {e}"));
|
||||
v.wait_for_text(&turn_sentinel(1), LEADER_TIMEOUT)
|
||||
.unwrap_or_else(|e| panic!("viewer {i} replayed driver's transcript: {e}"));
|
||||
// Settle the PTY before counting: wait_for_text returns on first match,
|
||||
// so a duplicate replay arriving in a later batch would slip past an
|
||||
// immediate count. A pump can only reveal a duplicate, never hide one.
|
||||
v.update(Duration::from_millis(500));
|
||||
let screen = v.screen_contents();
|
||||
assert_eq!(
|
||||
screen.matches(&turn_sentinel(1)).count(),
|
||||
1,
|
||||
"turn 1 must appear in viewer {i} exactly once (duplicated replay?)\nscreen:\n{screen}"
|
||||
);
|
||||
viewers.push(v);
|
||||
}
|
||||
|
||||
// Turn 2 driven from the driver streams live into EVERY viewer (fan-out).
|
||||
cluster
|
||||
.content()
|
||||
.set_response(format!("{} second turn payload.", turn_sentinel(2)));
|
||||
a.inject_keys(b"again\r").expect("driver submit turn 2");
|
||||
a.wait_for_text(&turn_sentinel(2), STREAM_TIMEOUT)
|
||||
.expect("driver turn 2");
|
||||
for (i, v) in viewers.iter_mut().enumerate() {
|
||||
v.wait_for_text(&turn_sentinel(2), STREAM_TIMEOUT)
|
||||
.unwrap_or_else(|e| panic!("viewer {i} received driver's live turn: {e}"));
|
||||
// Same settle-then-count guard as the replay check, now for the LIVE
|
||||
// stream: a duplicated fan-out frame must fail too.
|
||||
v.update(Duration::from_millis(500));
|
||||
let screen = v.screen_contents();
|
||||
assert_eq!(
|
||||
screen.matches(&turn_sentinel(2)).count(),
|
||||
1,
|
||||
"turn 2 must appear in viewer {i} exactly once (duplicated live stream?)\nscreen:\n{screen}"
|
||||
);
|
||||
}
|
||||
|
||||
// The spawning client's exit must not take the leader (or the viewers)
|
||||
// down: each viewer keeps its transcript and stays attached.
|
||||
drop(a);
|
||||
for (i, v) in viewers.iter_mut().enumerate() {
|
||||
v.update(Duration::from_secs(3));
|
||||
assert!(
|
||||
v.is_running(),
|
||||
"viewer {i} exited after the driver quit\nscreen:\n{}",
|
||||
v.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
!v.contains_text("panicked"),
|
||||
"viewer {i} rendered a panic\nscreen:\n{}",
|
||||
v.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
v.contains_text(&turn_sentinel(2)),
|
||||
"viewer {i} lost its transcript after the driver quit\nscreen:\n{}",
|
||||
v.screen_contents()
|
||||
);
|
||||
}
|
||||
|
||||
for mut v in viewers {
|
||||
v.quit().expect("quit viewer");
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
// Per-test-case module for the `leader_pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// 25. **Leader reattach — cancellation round-trips through the durable log.**
|
||||
/// A turn driven on leader client A is Ctrl+C-cancelled mid-stream; the leader
|
||||
/// must persist a `turn_completed` terminal with `stop_reason == cancelled`
|
||||
/// (the producer fail-before). A FRESH client must replay the cancelled
|
||||
/// transcript through the same leader and land clean — running, no panic, and
|
||||
/// not stranded on the "Waiting"/"Cancelling" spinners — and must still hold
|
||||
/// that transcript after A (the original driver) exits. A keep-alive viewer
|
||||
/// holds the leader up across A's exit (the leader stops with its last client).
|
||||
///
|
||||
/// C attaches *before* A is dropped: `PtyHarness` Drop SIGKILLs the child, and
|
||||
/// under full-suite contention a cold `--resume` handshake racing that teardown
|
||||
/// flakes with an empty screen for the whole `LEADER_TIMEOUT` (the observed
|
||||
/// "C replayed the cancelled transcript" timeout). Replaying while A is still
|
||||
/// up, then proving C survives A's exit, covers the durable-log + multi-client
|
||||
/// survival invariants without that race.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "PTY e2e; run with cargo test -p kigi-tui --test leader_pty_e2e -- --ignored --test-threads=1"]
|
||||
async fn leader_reattach_cancellation_roundtrips_durable_log() {
|
||||
let cluster = LeaderCluster::start(DEFAULT_ROWS, DEFAULT_COLS)
|
||||
.await
|
||||
.expect("start leader cluster");
|
||||
// Paced enough to cancel mid-stream, short enough that the heavy
|
||||
// multi-client cancel drain does not dominate suite-wide contention.
|
||||
let long_response = format!(
|
||||
"{} {}",
|
||||
turn_sentinel(1),
|
||||
"more streamed filler to hold the turn open. ".repeat(40)
|
||||
);
|
||||
cluster.content().set_response(long_response);
|
||||
cluster
|
||||
.content()
|
||||
.set_chunk_delay(Some(Duration::from_millis(40)));
|
||||
|
||||
let mut a = cluster.spawn_leader(&[]).expect("spawn leader client A");
|
||||
a.wait_for_text(WELCOME_SCREEN_SENTINEL, LEADER_TIMEOUT)
|
||||
.expect("A welcome");
|
||||
a.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("A submit turn");
|
||||
// Wait until the turn is clearly streaming (sentinel visible); this also
|
||||
// closes the leader's rewind window so cancel is not confused with rewind.
|
||||
a.wait_for_text(&turn_sentinel(1), STREAM_TIMEOUT)
|
||||
.expect("A turn streaming");
|
||||
|
||||
// Ctrl+C on an empty prompt cancels while streaming (Esc no longer cancels).
|
||||
a.inject_keys(keys::CTRL_C).expect("A press ctrl+c");
|
||||
a.update(Duration::from_millis(200));
|
||||
// Generous budget: the heavy multi-client leader cluster drains the paced
|
||||
// cancel slower than the single-client path, so match the test's other
|
||||
// waits (LEADER/STREAM_TIMEOUT) rather than the single-client 15s.
|
||||
a.wait_for_text("Turn cancelled by user", STREAM_TIMEOUT)
|
||||
.expect("A turn cancelled marker");
|
||||
|
||||
// Producer fail-before: the cancel must have persisted a durable terminal
|
||||
// carrying the cancelled stop reason.
|
||||
let rec = cluster
|
||||
.wait_for_turn_completed(STREAM_TIMEOUT)
|
||||
.expect("turn_completed persisted to updates.jsonl");
|
||||
assert_eq!(
|
||||
rec["stop_reason"], "cancelled",
|
||||
"cancelled turn must record stop_reason=cancelled, got {rec}"
|
||||
);
|
||||
|
||||
// Keep-alive viewer attaches AFTER the cancel so the leader survives A's
|
||||
// exit; waiting for it to replay proves it is attached to the same session.
|
||||
let mut keep = cluster.attach(&[]).expect("spawn keep-alive viewer");
|
||||
keep.wait_for_text(&turn_sentinel(1), LEADER_TIMEOUT)
|
||||
.expect("keep-alive replayed the cancelled transcript");
|
||||
|
||||
// Reattach must replay from the durable log, not re-drive a turn: the mock
|
||||
// must see no new inference request while C catches up.
|
||||
let inference_before_reattach = inference_request_count(cluster.content());
|
||||
|
||||
// Fresh reattach while A is still up (see module comment), then prove the
|
||||
// original driver's exit does not take C (or the transcript) down.
|
||||
let mut c = cluster.attach(&[]).expect("spawn fresh reattach client C");
|
||||
c.wait_for_text(&turn_sentinel(1), LEADER_TIMEOUT)
|
||||
.expect("C replayed the cancelled transcript");
|
||||
|
||||
drop(a);
|
||||
// Leader processes A's disconnect; brief settle then re-check C still has
|
||||
// the durable replay (not a fixed long sleep that would mask a hang).
|
||||
c.update(Duration::from_millis(500));
|
||||
assert!(
|
||||
c.is_running(),
|
||||
"C exited after A quit\nscreen:\n{}",
|
||||
c.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
c.contains_text(&turn_sentinel(1)),
|
||||
"C lost the cancelled transcript after A quit\nscreen:\n{}",
|
||||
c.screen_contents()
|
||||
);
|
||||
|
||||
// Same fidelity caveat as the completion case: a fresh reattach lands Idle
|
||||
// either way, so absent spinners are regression guards, not fail-befores.
|
||||
// Bare substrings (not the full `…`-suffixed labels) are simple stable
|
||||
// matches for the spinner labels. Bound the replay wait rather than a fixed
|
||||
// settle.
|
||||
wait_for_labels_absent(&mut c, &["Waiting", "Cancelling"], Duration::from_secs(5));
|
||||
|
||||
assert!(
|
||||
!c.contains_text("panicked"),
|
||||
"C rendered a panic\nscreen:\n{}",
|
||||
c.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
!c.contains_text("Waiting"),
|
||||
"C is stranded on the active-turn spinner\nscreen:\n{}",
|
||||
c.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
!c.contains_text("Cancelling"),
|
||||
"C is stranded on the cancelling spinner\nscreen:\n{}",
|
||||
c.screen_contents()
|
||||
);
|
||||
assert_eq!(
|
||||
inference_request_count(cluster.content()),
|
||||
inference_before_reattach,
|
||||
"reattach must replay from the durable log, not re-drive a turn (no new inference request)"
|
||||
);
|
||||
|
||||
c.quit().expect("quit C");
|
||||
keep.quit().expect("quit keep-alive viewer");
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
// Per-test-case module for the `leader_pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// 24. **Leader reattach — completion round-trips through the durable log.**
|
||||
/// A turn driven on the leader-electing client A completes; the leader must
|
||||
/// persist a replayable `turn_completed` terminal — the producer fail-before:
|
||||
/// without the producer the record simply would not exist. A FRESH client that
|
||||
/// re-attaches after A exits must then replay the completed transcript exactly
|
||||
/// once through the same leader and land clean: running, no panic, and not
|
||||
/// stranded on the active-turn "Waiting" spinner. A keep-alive viewer holds
|
||||
/// the leader up across A's exit (the leader stops with its last client).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "PTY e2e; run with cargo test -p kigi-tui --test leader_pty_e2e -- --ignored --test-threads=1"]
|
||||
async fn leader_reattach_completion_roundtrips_durable_log() {
|
||||
let cluster = LeaderCluster::start(DEFAULT_ROWS, DEFAULT_COLS)
|
||||
.await
|
||||
.expect("start leader cluster");
|
||||
cluster
|
||||
.content()
|
||||
.set_response(format!("{} completed turn payload.", turn_sentinel(1)));
|
||||
|
||||
// A elects the leader and drives one turn to completion.
|
||||
let mut a = cluster.spawn_leader(&[]).expect("spawn leader client A");
|
||||
a.wait_for_text(WELCOME_SCREEN_SENTINEL, LEADER_TIMEOUT)
|
||||
.expect("A welcome");
|
||||
a.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("A submit turn");
|
||||
a.wait_for_text(&turn_sentinel(1), STREAM_TIMEOUT)
|
||||
.expect("A turn rendered");
|
||||
|
||||
// Attach the keep-alive viewer AFTER the turn so the leader survives A's
|
||||
// exit; waiting for it to replay the transcript proves it is attached.
|
||||
let mut keep = cluster.attach(&[]).expect("spawn keep-alive viewer");
|
||||
keep.wait_for_text(&turn_sentinel(1), LEADER_TIMEOUT)
|
||||
.expect("keep-alive replayed A's transcript");
|
||||
|
||||
// Producer fail-before: the completed turn must have persisted a durable,
|
||||
// replayable terminal carrying the real stop reason.
|
||||
let rec = cluster
|
||||
.wait_for_turn_completed(STREAM_TIMEOUT)
|
||||
.expect("turn_completed persisted to updates.jsonl");
|
||||
assert_eq!(
|
||||
rec["stop_reason"], "end_turn",
|
||||
"completed turn must record stop_reason=end_turn, got {rec}"
|
||||
);
|
||||
assert!(
|
||||
rec["prompt_id"].as_str().is_some_and(|s| !s.is_empty()),
|
||||
"turn_completed must carry a non-empty prompt_id, got {rec}"
|
||||
);
|
||||
|
||||
// Reattach must replay from the durable log, not re-drive a turn: the mock
|
||||
// must see no new inference request while C catches up.
|
||||
let inference_before_reattach = inference_request_count(cluster.content());
|
||||
|
||||
// Fresh reattach AFTER A exits: it replays the completed transcript via the
|
||||
// durable rail through the surviving leader.
|
||||
drop(a);
|
||||
let mut c = cluster.attach(&[]).expect("spawn fresh reattach client C");
|
||||
c.wait_for_text(&turn_sentinel(1), LEADER_TIMEOUT)
|
||||
.expect("C replayed the completed transcript");
|
||||
|
||||
// A finished turn clears the leader's prompt slot, so a fresh reattach lands
|
||||
// Idle regardless of the consumer guard; absent "Waiting" is thus a
|
||||
// regression guard, not a fail-before. The bare substring (not the full
|
||||
// `…`-suffixed label) is a simple stable match for the spinner label, and
|
||||
// nothing else renders "Waiting" in a settled reattached session. Bound the
|
||||
// replay wait rather than a fixed settle that could flake under load.
|
||||
wait_for_labels_absent(&mut c, &["Waiting"], Duration::from_secs(5));
|
||||
|
||||
assert!(
|
||||
c.is_running(),
|
||||
"C exited unexpectedly\nscreen:\n{}",
|
||||
c.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
!c.contains_text("panicked"),
|
||||
"C rendered a panic\nscreen:\n{}",
|
||||
c.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
!c.contains_text("Waiting"),
|
||||
"C is stranded on the active-turn spinner\nscreen:\n{}",
|
||||
c.screen_contents()
|
||||
);
|
||||
assert_eq!(
|
||||
inference_request_count(cluster.content()),
|
||||
inference_before_reattach,
|
||||
"reattach must replay from the durable log, not re-drive a turn (no new inference request)"
|
||||
);
|
||||
// Pump once more so a late or duplicate replay batch would be painted before
|
||||
// the exactly-once count below — this test's headline guard.
|
||||
c.update(Duration::from_millis(500));
|
||||
let screen = c.screen_contents();
|
||||
assert_eq!(
|
||||
screen.matches(&turn_sentinel(1)).count(),
|
||||
1,
|
||||
"C must replay the turn exactly once (duplicated replay?)\nscreen:\n{screen}"
|
||||
);
|
||||
|
||||
c.quit().expect("quit C");
|
||||
keep.quit().expect("quit keep-alive viewer");
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Per-test-case module for the `leader_pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// 12. **Leader mode — two clients share one session.**
|
||||
/// The two-terminal flow: pager A starts with `--leader` on an isolated
|
||||
/// socket (spawning the leader), runs a turn; pager B starts with the same
|
||||
/// socket + `--resume` and attaches to A's session through the SAME leader.
|
||||
/// Discriminating for the multi-client replay surface: B must render A's
|
||||
/// transcript exactly once (duplicated replay history and an empty/stuck
|
||||
/// pane both fail), later turns must stream live into BOTH panes regardless
|
||||
/// of which client drives, and the leader + viewer must survive the
|
||||
/// spawning client's exit.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "PTY e2e; run with cargo test -p kigi-tui --test leader_pty_e2e -- --ignored --test-threads=1"]
|
||||
async fn leader_two_clients_shared_session() {
|
||||
// One shared leader (shared HOME/KIGI_SHARE_DIR hold the sessions AND the
|
||||
// explicit leader socket), so B attaches to the leader A spawned instead
|
||||
// of the machine's default one.
|
||||
let cluster = LeaderCluster::start(DEFAULT_ROWS, DEFAULT_COLS)
|
||||
.await
|
||||
.expect("start cluster");
|
||||
cluster
|
||||
.content()
|
||||
.set_response(format!("{} first turn payload.", turn_sentinel(1)));
|
||||
|
||||
let mut a = cluster.spawn_leader(&[]).expect("spawn pager A");
|
||||
a.wait_for_text(WELCOME_SCREEN_SENTINEL, LEADER_TIMEOUT)
|
||||
.expect("A welcome");
|
||||
|
||||
a.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("A submit turn 1");
|
||||
a.wait_for_text(&turn_sentinel(1), STREAM_TIMEOUT)
|
||||
.expect("A turn 1");
|
||||
|
||||
// B attaches to A's session (most recent in the shared cwd) via the
|
||||
// shared leader and must replay A's transcript.
|
||||
let mut b = cluster.attach(&[]).expect("spawn pager B");
|
||||
b.wait_for_text(&turn_sentinel(1), LEADER_TIMEOUT)
|
||||
.expect("B replayed A's transcript");
|
||||
|
||||
let b_screen = b.screen_contents();
|
||||
assert_eq!(
|
||||
b_screen.matches(&turn_sentinel(1)).count(),
|
||||
1,
|
||||
"A's turn must appear in B exactly once (duplicated replay?)\nB screen:\n{b_screen}"
|
||||
);
|
||||
|
||||
// Turn 2 driven from A streams live into the attached viewer B.
|
||||
cluster
|
||||
.content()
|
||||
.set_response(format!("{} second turn payload.", turn_sentinel(2)));
|
||||
submit_turn(&mut a, "again", &turn_sentinel(2), STREAM_TIMEOUT);
|
||||
b.wait_for_text(&turn_sentinel(2), STREAM_TIMEOUT)
|
||||
.expect("B received A's live turn");
|
||||
|
||||
// Turn 3 driven from B reaches A (driver/viewer flip).
|
||||
cluster
|
||||
.content()
|
||||
.set_response(format!("{} third turn payload.", turn_sentinel(3)));
|
||||
submit_turn(&mut b, "more", &turn_sentinel(3), STREAM_TIMEOUT);
|
||||
a.wait_for_text(&turn_sentinel(3), STREAM_TIMEOUT)
|
||||
.expect("A received B's live turn");
|
||||
|
||||
// Each submitted prompt top-anchors its turn, scrolling earlier turns
|
||||
// above the viewport. Grow the viewport and wheel-scroll to the top
|
||||
// (keyboard scroll keys are captured by the focused input box) so the
|
||||
// whole 3-turn transcript is on screen, then count every sentinel
|
||||
// exactly once — a duplicated replay or a dropped turn both fail here,
|
||||
// on either pane.
|
||||
//
|
||||
// Non-dev builds no longer metronome on tracing_rx; scroll finalize runs
|
||||
// via has_active_stream ticks and cadence-suppressed wheels must still
|
||||
// schedule ticks (handle_input returns Changed). Bazel/linux amd64 is
|
||||
// slower than macOS local: keep retrying wheel bursts until every turn
|
||||
// sentinel is visible (or time out), not a single fixed burst budget.
|
||||
fn wheel_scroll_to_top(h: &mut PtyHarness) {
|
||||
for burst in 0..4 {
|
||||
for _ in 0..50 {
|
||||
let _ = h.inject_keys(b"\x1b[<64;40;10M");
|
||||
}
|
||||
h.update(Duration::from_millis(350 + burst * 120));
|
||||
}
|
||||
h.update(Duration::from_millis(500));
|
||||
}
|
||||
|
||||
fn all_turns_once(screen: &str) -> bool {
|
||||
(1..=3).all(|turn| screen.matches(&turn_sentinel(turn)).count() == 1)
|
||||
}
|
||||
|
||||
for (name, h) in [("A", &mut a), ("B", &mut b)] {
|
||||
h.resize(200, DEFAULT_COLS).expect("grow viewport");
|
||||
h.update(Duration::from_millis(600));
|
||||
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(20);
|
||||
let mut screen = h.screen_contents();
|
||||
while !all_turns_once(&screen) && std::time::Instant::now() < deadline {
|
||||
wheel_scroll_to_top(h);
|
||||
// Home/goto-top via Esc then wheel again if turn 1 still missing.
|
||||
if screen.matches(&turn_sentinel(1)).count() == 0 {
|
||||
let _ = h.inject_keys(keys::ESC);
|
||||
h.update(Duration::from_millis(200));
|
||||
let _ = h.inject_keys(keys::ESC);
|
||||
h.update(Duration::from_millis(200));
|
||||
wheel_scroll_to_top(h);
|
||||
}
|
||||
screen = h.screen_contents();
|
||||
}
|
||||
|
||||
assert!(
|
||||
h.is_running(),
|
||||
"pager {name} exited\nscreen:\n{}",
|
||||
h.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
!h.contains_text("panicked"),
|
||||
"pager {name} rendered a panic\nscreen:\n{}",
|
||||
h.screen_contents()
|
||||
);
|
||||
let screen = h.screen_contents();
|
||||
for turn in 1..=3 {
|
||||
assert_eq!(
|
||||
screen.matches(&turn_sentinel(turn)).count(),
|
||||
1,
|
||||
"turn {turn} must appear in {name} exactly once\nscreen:\n{screen}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The spawning client's exit must not take the leader (or B) down: B
|
||||
// keeps its transcript and stays attached.
|
||||
drop(a);
|
||||
b.update(Duration::from_secs(3));
|
||||
assert!(
|
||||
b.is_running(),
|
||||
"B exited after A quit\nscreen:\n{}",
|
||||
b.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
b.contains_text(&turn_sentinel(3)),
|
||||
"B lost its transcript after A quit\nscreen:\n{}",
|
||||
b.screen_contents()
|
||||
);
|
||||
|
||||
b.quit().expect("quit B");
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//! Leader-mode PTY e2e tests, split out of the shared `pty_e2e` target.
|
||||
//!
|
||||
//! These cases spawn multi-process leader clusters (each test boots 2-3 full
|
||||
//! pager processes plus a leader subprocess), so running them interleaved
|
||||
//! with the ~45-test `pty_e2e` suite was the root of the 60→240s
|
||||
//! `LEADER_TIMEOUT` flake history. As their own `[[test]]` target they get
|
||||
//! their own Bazel test action (serialized from the main PTY pool) and can be
|
||||
//! invoked in isolation:
|
||||
//!
|
||||
//! ```bash
|
||||
//! cargo test -p kigi-tui --test leader_pty_e2e -- --ignored --test-threads=1 --nocapture
|
||||
//! ```
|
||||
//!
|
||||
//! Binary resolution and harness plumbing are identical to `pty_e2e` (see
|
||||
//! that target's `mod.rs`); the shared helpers these tests need live in this
|
||||
//! directory's `common.rs`.
|
||||
|
||||
mod common;
|
||||
|
||||
mod campaign_leader_mode_remote_dismiss_on_model_pick;
|
||||
mod leader_n_clients_shared_session;
|
||||
mod leader_reattach_cancellation_roundtrips_durable_log;
|
||||
mod leader_reattach_completion_roundtrips_durable_log;
|
||||
mod leader_two_clients_shared_session;
|
||||
@@ -0,0 +1,153 @@
|
||||
//! End-to-end coverage of the out-of-process Mermaid render path.
|
||||
//!
|
||||
//! Spawns the **real** built pager binary as the hidden `__mermaid-render`
|
||||
//! child via [`render_via_subprocess`] — the exact function the render worker
|
||||
//! uses in production — and asserts:
|
||||
//! * a valid diagram (the cyclic login-flow) renders to a decodable PNG;
|
||||
//! * an oversized / invalid diagram is *contained*: the child exits non-zero,
|
||||
//! the parent returns `Err`, and no PNG is written;
|
||||
//! * a tight timeout makes the parent kill the child and return `Err` quickly
|
||||
//! (a real, process-killable timeout — the crash/timeout containment gate).
|
||||
//!
|
||||
//! These exercise the cross-platform `Command` + `current_exe()`/`Child::kill`
|
||||
//! machinery against the actual binary, which the in-process worker unit tests
|
||||
//! (under `cargo test`, where the harness binary is not the pager) cannot.
|
||||
//!
|
||||
//! Every test is `#[ignore]` (like `pty_e2e`): it needs the built binary, so
|
||||
//! `cargo test` skips it by default and CI opts in via `-- --ignored`. The
|
||||
//! binary path is resolved at runtime by [`pager_binary`], so the file still
|
||||
//! compiles where `CARGO_BIN_EXE_*` is unset (e.g. Bazel), where it is skipped.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use kigi_pager_pty_harness::pager_binary;
|
||||
use kigi_tui::app::mermaid_worker::render_via_subprocess;
|
||||
use kigi_tui::scrollback::blocks::mermaid_content::MermaidRenderQuality;
|
||||
|
||||
/// A cyclic login-flow whose back-edge (`Attempts -->|No| Enter`) routes back
|
||||
/// into the cycle — the tricky case for flowchart edge routing.
|
||||
const LOGIN_FLOW: &str = "flowchart TD\n\
|
||||
Start([User visits login page]) --> Enter[Enter username & password]\n\
|
||||
Enter --> Submit[Submit credentials]\n\
|
||||
Submit --> Validate{Credentials valid?}\n\
|
||||
Validate -->|No| Fail[Show error message]\n\
|
||||
Fail --> Attempts{Too many failed attempts?}\n\
|
||||
Attempts -->|Yes| Lock[Lock account]\n\
|
||||
Attempts -->|No| Enter\n\
|
||||
Validate -->|Yes| Session[Create session]";
|
||||
|
||||
#[test]
|
||||
#[ignore = "spawns the built pager binary; run with cargo test -- --ignored"]
|
||||
fn child_renders_login_flow_to_png() {
|
||||
let bin = pager_binary().expect("resolve pager binary");
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let out = dir.path().join("login.png");
|
||||
|
||||
let result = render_via_subprocess(
|
||||
&bin,
|
||||
LOGIN_FLOW,
|
||||
false,
|
||||
1024,
|
||||
MermaidRenderQuality::Open,
|
||||
&out,
|
||||
Duration::from_secs(30),
|
||||
);
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"the login-flow must render via the __mermaid-render child: {result:?}"
|
||||
);
|
||||
assert!(out.exists(), "the child wrote the PNG to the out-path");
|
||||
let bytes = std::fs::read(&out).expect("read PNG");
|
||||
let img = image::load_from_memory(&bytes).expect("output is a decodable PNG");
|
||||
assert!(img.width() > 0 && img.height() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "spawns the built pager binary; run with cargo test -- --ignored"]
|
||||
fn oversized_source_is_contained() {
|
||||
// Source over the 64 KiB cap: the child rejects it and exits non-zero, so
|
||||
// the parent returns Err and no PNG is produced (degrades to the fallback).
|
||||
let bin = pager_binary().expect("resolve pager binary");
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let out = dir.path().join("huge.png");
|
||||
let huge = format!("flowchart TD\n{}", "A-->B\n".repeat(100_000));
|
||||
|
||||
let result = render_via_subprocess(
|
||||
&bin,
|
||||
&huge,
|
||||
false,
|
||||
1024,
|
||||
MermaidRenderQuality::Terminal,
|
||||
&out,
|
||||
Duration::from_secs(30),
|
||||
);
|
||||
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"oversized source must be contained: {result:?}"
|
||||
);
|
||||
assert!(!out.exists(), "a contained (failed) child writes no PNG");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "spawns the built pager binary; run with cargo test -- --ignored"]
|
||||
fn invalid_diagram_is_contained() {
|
||||
// An unrenderable diagram: the child's render errors and it exits non-zero;
|
||||
// the parent returns Err, no PNG. This is the same containment path a child
|
||||
// panic (abort -> non-success exit) would take.
|
||||
let bin = pager_binary().expect("resolve pager binary");
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let out = dir.path().join("bad.png");
|
||||
|
||||
let result = render_via_subprocess(
|
||||
&bin,
|
||||
"this is not a mermaid diagram at all",
|
||||
false,
|
||||
1024,
|
||||
MermaidRenderQuality::Terminal,
|
||||
&out,
|
||||
Duration::from_secs(30),
|
||||
);
|
||||
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"an invalid diagram must be contained: {result:?}"
|
||||
);
|
||||
assert!(!out.exists(), "no PNG for an unrenderable diagram");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "spawns the built pager binary; run with cargo test -- --ignored"]
|
||||
fn tight_timeout_kills_child_and_returns_err() {
|
||||
// A 1 ms budget cannot cover spawning + rendering, so the parent must kill
|
||||
// and reap the child and return Err, then return promptly (not block on the
|
||||
// child finishing). That the kill actually terminates the child's process
|
||||
// group is asserted directly (and without the heavy binary) by the
|
||||
// `kigi_mermaid::subprocess` `reap_terminates_the_process` unit test;
|
||||
// here the loose ceiling just guards against the parent blocking on a child
|
||||
// that outlived its budget, while tolerating slow-CI spawn of the real
|
||||
// binary.
|
||||
let bin = pager_binary().expect("resolve pager binary");
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let out = dir.path().join("slow.png");
|
||||
|
||||
let started = Instant::now();
|
||||
let result = render_via_subprocess(
|
||||
&bin,
|
||||
LOGIN_FLOW,
|
||||
false,
|
||||
1024,
|
||||
MermaidRenderQuality::Open,
|
||||
&out,
|
||||
Duration::from_millis(1),
|
||||
);
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(result.is_err(), "a 1ms budget must time out: {result:?}");
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(10),
|
||||
"the parent must return at the deadline (real kill), took {elapsed:?}",
|
||||
);
|
||||
assert!(!out.exists(), "a killed child leaves no PNG");
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
//! PTY e2e: permission Auto mode is distinct on the real pager screen.
|
||||
//!
|
||||
//! Uses `kigi-pager-pty-harness` (`PtyHarness`) + Shift+Tab (CSI Z,
|
||||
//! compatible with `ptyctl` key injection) to cycle Normal → Plan → Auto
|
||||
//! and assert the mode banner / status shows Auto without conflating
|
||||
//! Always-Approve.
|
||||
//!
|
||||
//! Auth: seeds `HOME/.kigi/auth.json` from `KIGI_AUTH_JSON` (path) or the
|
||||
//! developer's `~/.kigi/auth.json` so the pager skips device-login when
|
||||
//! credentials exist. Without auth the test records an environmental
|
||||
//! failure (login screen) and still asserts the harness API surface.
|
||||
//!
|
||||
//! Run with:
|
||||
//! `cargo test -p kigi-tui --test pty_auto_mode -- --ignored --nocapture`
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use kigi_pager_pty_harness::{PtyHarness, pager_binary};
|
||||
|
||||
const ROWS: u16 = 40;
|
||||
const COLS: u16 = 120;
|
||||
const WELCOME_TIMEOUT: Duration = Duration::from_secs(25);
|
||||
const WELCOME_SCREEN_SENTINEL: &str = "Quit";
|
||||
|
||||
/// Back-tab / Shift+Tab (CSI Z) — pager binds this to CycleMode.
|
||||
const SHIFT_TAB: &[u8] = b"\x1b[Z";
|
||||
|
||||
/// Prefer explicit path, else the user's real `~/.kigi/auth.json`.
|
||||
fn auth_json_source() -> Option<PathBuf> {
|
||||
if let Ok(p) = std::env::var("KIGI_AUTH_JSON") {
|
||||
let pb = PathBuf::from(p);
|
||||
if pb.is_file() {
|
||||
return Some(pb);
|
||||
}
|
||||
}
|
||||
dirs_next_home()
|
||||
.map(|h| h.join(".kigi/auth.json"))
|
||||
.filter(|p| p.is_file())
|
||||
}
|
||||
|
||||
fn dirs_next_home() -> Option<PathBuf> {
|
||||
std::env::var_os("HOME")
|
||||
.or_else(|| std::env::var_os("USERPROFILE"))
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
/// Sandbox HOME + optional auth.json seed (no secrets logged), with the
|
||||
/// auto-permission-mode feature gate pinned explicitly via `gate_on` so each
|
||||
/// test is self-contained and deterministic regardless of the runner's shell.
|
||||
fn prepare_sandbox(home: &Path, gate_on: bool) -> Vec<(String, String)> {
|
||||
let grok = home.join(".kigi");
|
||||
let _ = std::fs::create_dir_all(&grok);
|
||||
if let Some(src) = auth_json_source() {
|
||||
let dest = grok.join("auth.json");
|
||||
if let Err(e) = std::fs::copy(&src, &dest) {
|
||||
eprintln!("pty_auto_mode: could not copy auth.json ({e}); login may block mode cycle");
|
||||
} else {
|
||||
eprintln!(
|
||||
"pty_auto_mode: seeded auth from {} ({} bytes)",
|
||||
src.display(),
|
||||
std::fs::metadata(&dest).map(|m| m.len()).unwrap_or(0)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
eprintln!("pty_auto_mode: no ~/.kigi/auth.json — may hit device login");
|
||||
}
|
||||
|
||||
let home_s = home.display().to_string();
|
||||
let mut env = vec![
|
||||
("HOME".into(), home_s.clone()),
|
||||
("KIGI_SHARE_DIR".into(), grok.display().to_string()),
|
||||
("XDG_CONFIG_HOME".into(), format!("{home_s}/.config")),
|
||||
("XDG_DATA_HOME".into(), format!("{home_s}/.local/share")),
|
||||
("XDG_CACHE_HOME".into(), format!("{home_s}/.cache")),
|
||||
("TERM".into(), "xterm-256color".into()),
|
||||
("COLORTERM".into(), "truecolor".into()),
|
||||
("NO_COLOR".into(), "0".into()),
|
||||
("TERM_PROGRAM".into(), "".into()),
|
||||
("TMUX".into(), "".into()),
|
||||
// Do not set XAI_API_KEY — prefer OIDC entry in auth.json (pty_e2e pattern).
|
||||
];
|
||||
// Pin the feature gate explicitly so the cycle is deterministic regardless
|
||||
// of the developer's shell. `KIGI_AUTO_PERMISSION_MODE` is the highest gate
|
||||
// layer below requirements; "1"/"0" parse to on/off (kigi_config::
|
||||
// env_bool), and portable-pty merges this over the inherited environment —
|
||||
// so an exported value can't flip the result (Auto is present in the ring
|
||||
// with the gate on, skipped with it off).
|
||||
env.push((
|
||||
"KIGI_AUTO_PERMISSION_MODE".into(),
|
||||
if gate_on { "1" } else { "0" }.into(),
|
||||
));
|
||||
env
|
||||
}
|
||||
|
||||
fn is_login_screen(screen: &str) -> bool {
|
||||
screen.contains("Waiting for approval")
|
||||
|| screen.contains("Approve in your browser")
|
||||
|| screen.contains("finish signing in")
|
||||
}
|
||||
|
||||
/// Whether the caller expects seeded auth (CI / a deliberate e2e run). When set,
|
||||
/// hitting the login screen is a real failure (broken auth seeding), not an
|
||||
/// environmental skip — so the test hard-fails instead of passing vacuously.
|
||||
fn require_auth() -> bool {
|
||||
std::env::var("KIGI_PTY_REQUIRE_AUTH").is_ok_and(|v| v == "1" || v == "true")
|
||||
}
|
||||
|
||||
/// Cycle into Auto on the welcome / pre-session path and assert screen text
|
||||
/// shows Auto (mode banner) while not stuck on Always-Approve alone.
|
||||
#[test]
|
||||
#[ignore = "spawns real pager PTY; run with cargo test -- --ignored"]
|
||||
fn pty_shift_tab_cycles_to_auto_mode_banner() {
|
||||
let binary = match pager_binary() {
|
||||
Ok(b) => b,
|
||||
Err(e) => panic!("resolve pager binary via harness env: {e:#}"),
|
||||
};
|
||||
let tmp = tempfile::tempdir().expect("temp HOME");
|
||||
let env_owned = prepare_sandbox(tmp.path(), true);
|
||||
let env_refs: Vec<(&str, &str)> = env_owned
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.as_str()))
|
||||
.collect();
|
||||
|
||||
let mut harness = PtyHarness::new(&binary, ROWS, COLS, &[], &env_refs)
|
||||
.expect("spawn pager in PTY (kigi-pager-pty-harness)");
|
||||
|
||||
// Drain startup; welcome or agent chrome.
|
||||
let _ = harness.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT);
|
||||
let early = harness.screen_contents();
|
||||
if is_login_screen(&early) {
|
||||
assert!(
|
||||
!require_auth(),
|
||||
"KIGI_PTY_REQUIRE_AUTH set but pager hit the login screen — auth seeding broke"
|
||||
);
|
||||
// Auth still blocking (expired token / no network). Honest env failure:
|
||||
// the UI-ring guarantee is covered by the dispatch-level unit tests; save
|
||||
// the screen for debugging.
|
||||
if let Ok(dump) = std::env::var("PTY_AUTO_MODE_SCREEN_DUMP") {
|
||||
let _ = std::fs::write(&dump, &early);
|
||||
}
|
||||
eprintln!(
|
||||
"pty_auto_mode: login/device-auth screen blocked Shift+Tab cycle \
|
||||
(seeded auth may be expired). Screen saved; treating as env limit — \
|
||||
see the permission_auto_mode SessionActor wire tests for coverage."
|
||||
);
|
||||
// Still prove we exercised PtyHarness spawn (not a no-op).
|
||||
assert!(
|
||||
harness.is_running() || !early.is_empty(),
|
||||
"pager must have produced output even on login screen"
|
||||
);
|
||||
let _ = harness.inject_keys(b"\x11"); // ctrl+q if bound
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal → Plan
|
||||
harness
|
||||
.inject_keys(SHIFT_TAB)
|
||||
.expect("inject Shift+Tab (Plan)");
|
||||
let _ = harness.wait_for_text("Plan", Duration::from_secs(8));
|
||||
|
||||
// Plan → Auto
|
||||
harness
|
||||
.inject_keys(SHIFT_TAB)
|
||||
.expect("inject Shift+Tab (Auto)");
|
||||
let saw_auto = harness
|
||||
.wait_for_text("Auto", Duration::from_secs(12))
|
||||
.is_ok();
|
||||
let screen = harness.screen_contents();
|
||||
|
||||
if let Ok(dump) = std::env::var("PTY_AUTO_MODE_SCREEN_DUMP") {
|
||||
let _ = std::fs::write(&dump, &screen);
|
||||
}
|
||||
|
||||
if is_login_screen(&screen) {
|
||||
eprintln!("pty_auto_mode: landed on login after key inject; env auth limit");
|
||||
return;
|
||||
}
|
||||
|
||||
assert!(
|
||||
saw_auto || screen.contains("Auto") || screen.contains("Switched to mode: Auto"),
|
||||
"after Plan → Auto cycle, screen must show Auto (distinct mode). screen=\n{screen}"
|
||||
);
|
||||
if screen.contains("Always-Approve") && !screen.contains("Auto") {
|
||||
panic!("landed on Always-Approve without Auto — cycle skipped Auto mode");
|
||||
}
|
||||
|
||||
let _ = harness.inject_keys(b"q");
|
||||
}
|
||||
|
||||
/// Gate OFF (the shipped default): the Shift+Tab ring must SKIP Auto entirely
|
||||
/// (Normal → Plan → Always-Approve → Normal), so the feature is inert and the
|
||||
/// classifier never launches. Negative companion to the gate-ON cycle test;
|
||||
/// proves the gate governs the UI ring, not just the engine.
|
||||
#[test]
|
||||
#[ignore = "spawns real pager PTY; run with cargo test -- --ignored"]
|
||||
fn pty_shift_tab_skips_auto_when_gate_off() {
|
||||
let binary = match pager_binary() {
|
||||
Ok(b) => b,
|
||||
Err(e) => panic!("resolve pager binary via harness env: {e:#}"),
|
||||
};
|
||||
let tmp = tempfile::tempdir().expect("temp HOME");
|
||||
let env_owned = prepare_sandbox(tmp.path(), false);
|
||||
let env_refs: Vec<(&str, &str)> = env_owned
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.as_str()))
|
||||
.collect();
|
||||
|
||||
let mut harness = PtyHarness::new(&binary, ROWS, COLS, &[], &env_refs)
|
||||
.expect("spawn pager in PTY (kigi-pager-pty-harness)");
|
||||
|
||||
let _ = harness.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT);
|
||||
let early = harness.screen_contents();
|
||||
if is_login_screen(&early) {
|
||||
assert!(
|
||||
!require_auth(),
|
||||
"KIGI_PTY_REQUIRE_AUTH set but pager hit the login screen — auth seeding broke"
|
||||
);
|
||||
eprintln!(
|
||||
"pty_auto_mode(gate off): login/device-auth screen blocked cycle; env auth limit"
|
||||
);
|
||||
assert!(
|
||||
harness.is_running() || !early.is_empty(),
|
||||
"pager must have produced output even on login screen"
|
||||
);
|
||||
let _ = harness.inject_keys(b"\x11");
|
||||
return;
|
||||
}
|
||||
|
||||
// Cycle the full ring (4 presses returns to Normal). With the gate off the
|
||||
// ring is Normal → Plan → Always-Approve → Normal: Auto must never appear,
|
||||
// while Plan and Always-Approve still must (the ring otherwise works).
|
||||
let mut saw_plan = false;
|
||||
let mut saw_always = false;
|
||||
let mut saw_auto = false;
|
||||
for _ in 0..4 {
|
||||
harness.inject_keys(SHIFT_TAB).expect("inject Shift+Tab");
|
||||
// Drain output so the NEW mode banner renders before we read. A
|
||||
// `wait_for_text("Switched to mode:")` would hit its fast-path and
|
||||
// return instantly on the prior press's banner still on screen, so we
|
||||
// explicitly pump for a fixed window instead (the screen tracker keeps
|
||||
// only the latest frame, so each read reflects the current mode).
|
||||
harness.update(Duration::from_secs(2));
|
||||
let s = harness.screen_contents();
|
||||
if is_login_screen(&s) {
|
||||
eprintln!("pty_auto_mode(gate off): landed on login after key inject; env auth limit");
|
||||
return;
|
||||
}
|
||||
if s.contains("Switched to mode: Plan") {
|
||||
saw_plan = true;
|
||||
}
|
||||
if s.contains("Switched to mode: Always-Approve") {
|
||||
saw_always = true;
|
||||
}
|
||||
if s.contains("Switched to mode: Auto") {
|
||||
saw_auto = true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(dump) = std::env::var("PTY_AUTO_MODE_SCREEN_DUMP") {
|
||||
let _ = std::fs::write(&dump, harness.screen_contents());
|
||||
}
|
||||
|
||||
assert!(
|
||||
!saw_auto,
|
||||
"gate OFF: Shift+Tab ring must skip Auto, but the Auto banner appeared"
|
||||
);
|
||||
assert!(
|
||||
saw_plan && saw_always,
|
||||
"gate OFF: ring should still cycle Plan and Always-Approve \
|
||||
(saw_plan={saw_plan}, saw_always={saw_always})"
|
||||
);
|
||||
|
||||
let _ = harness.inject_keys(b"q");
|
||||
}
|
||||
|
||||
/// Structural: harness crate exports used by this e2e (fails compile if removed).
|
||||
#[test]
|
||||
fn pty_harness_api_surface_for_auto_mode_e2e() {
|
||||
let _ = std::any::type_name::<PtyHarness>();
|
||||
assert_eq!(SHIFT_TAB, b"\x1b[Z");
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// 2. **Agent response.**
|
||||
/// Submitting a prompt produces the mock server's response text on screen.
|
||||
/// This is the full loop: pager → shell agent → mock inference → streamed
|
||||
/// chunks → pager render.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn agent_response() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!(
|
||||
"{MOCK_RESPONSE_SENTINEL} hello from the mock inference server."
|
||||
));
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness =
|
||||
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
|
||||
.expect("spawn pager with content");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
|
||||
// Type the prompt and submit with Enter.
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("mock response on screen");
|
||||
assert!(
|
||||
content.has_chat_completion(),
|
||||
"mock inference server never received a chat completion request\nrequests: {:?}",
|
||||
content.requests()
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// 5. **Agent type mismatch — modal appears on `/model` switch.**
|
||||
/// After sending a prompt (turn_count > 0), switching to a model with a
|
||||
/// different agent type shows the question modal instead of a raw error.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn agent_type_mismatch_modal_on_model_switch() {
|
||||
let content = start_dual_agent_type_content().await;
|
||||
content.set_response(format!(
|
||||
"{MOCK_RESPONSE_SENTINEL} hello from the default harness."
|
||||
));
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness =
|
||||
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
|
||||
// Send a prompt to establish turn_count > 0.
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("response rendered");
|
||||
|
||||
harness
|
||||
.inject_keys(b"/model cursor-model\r")
|
||||
.expect("type model switch");
|
||||
|
||||
// The question modal should appear.
|
||||
harness
|
||||
.wait_for_text("requires starting a new session", Duration::from_secs(15))
|
||||
.expect("agent type mismatch modal should appear");
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// 7. **Agent type mismatch — "No" returns to current session.**
|
||||
/// Selecting "No" dismisses the modal and keeps the current session
|
||||
/// with its original model.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn agent_type_mismatch_no_keeps_current_session() {
|
||||
let content = start_dual_agent_type_content().await;
|
||||
content.set_response(format!(
|
||||
"{MOCK_RESPONSE_SENTINEL} hello from the default harness."
|
||||
));
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness =
|
||||
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
|
||||
// Send a prompt to establish turn_count > 0.
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("response rendered");
|
||||
|
||||
harness
|
||||
.inject_keys(b"/model cursor-model\r")
|
||||
.expect("type model switch");
|
||||
|
||||
// Wait for the modal.
|
||||
harness
|
||||
.wait_for_text("requires starting a new session", Duration::from_secs(15))
|
||||
.expect("modal appeared");
|
||||
|
||||
// Select "No" (second option — press j then Enter).
|
||||
harness.inject_keys(keys::J).expect("move to No");
|
||||
harness.inject_keys(keys::ENTER).expect("select no");
|
||||
|
||||
// Modal should dismiss. Wait a moment for UI to settle.
|
||||
harness.update(Duration::from_millis(500));
|
||||
|
||||
// The original response should still be visible (same session).
|
||||
assert!(
|
||||
harness.contains_text(MOCK_RESPONSE_SENTINEL),
|
||||
"original response should still be visible after cancelling\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("requires starting a new session"),
|
||||
"modal should be dismissed\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
harness.is_running(),
|
||||
"pager exited\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// 6. **Agent type mismatch — "Yes" starts a new session.**
|
||||
/// Selecting "Yes" on the modal creates a new session with the target
|
||||
/// model active.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn agent_type_mismatch_yes_starts_new_session() {
|
||||
let content = start_dual_agent_type_content().await;
|
||||
content.set_response(format!(
|
||||
"{MOCK_RESPONSE_SENTINEL} hello from the default harness."
|
||||
));
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness =
|
||||
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
|
||||
// Send a prompt to establish turn_count > 0.
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("response rendered");
|
||||
|
||||
harness
|
||||
.inject_keys(b"/model cursor-model\r")
|
||||
.expect("type model switch");
|
||||
|
||||
// Wait for the modal.
|
||||
harness
|
||||
.wait_for_text("requires starting a new session", Duration::from_secs(15))
|
||||
.expect("modal appeared");
|
||||
|
||||
// Select "Yes" (first option, already focused by default).
|
||||
harness.inject_keys(keys::ENTER).expect("select yes");
|
||||
|
||||
// A new session should be created — look for the session tip or
|
||||
// the fresh prompt (no scrollback from the previous session).
|
||||
harness
|
||||
.wait_for_text("Session", Duration::from_secs(15))
|
||||
.expect("new session created");
|
||||
|
||||
assert!(
|
||||
harness.is_running(),
|
||||
"pager exited after starting new session\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// **ANSI scrollback content integrity (minimal mode).**
|
||||
///
|
||||
/// Regression guard for the `kigi-ratatui-inline` rewrite (termwiz →
|
||||
/// anstyle-parse). Minimal mode commits finalized assistant blocks into the
|
||||
/// terminal's NATIVE scrollback through `kigi_ratatui_inline::Terminal::
|
||||
/// insert_before` — the production seam of the rewritten crate — so a parsing
|
||||
/// or offset regression there shows up as truncated, duplicated, or corrupted
|
||||
/// committed content.
|
||||
///
|
||||
/// The response stresses the rendered-ANSI surface: a syntax-highlighted code
|
||||
/// block (SGR color sequences interleave the text), CJK + emoji wide
|
||||
/// characters (multi-byte UTF-8, width-2 cells), and enough unique code-block
|
||||
/// rows (which never markdown-reflow) to overflow the 50-row screen and force
|
||||
/// the head of the block into scrollback.
|
||||
///
|
||||
/// Assertions read scrollback + screen after the commit: head/tail sentinels
|
||||
/// and every payload row present exactly once (dropped or doubled emissions
|
||||
/// change the count), wide-char markers intact.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn ansi_scrollback_content_integrity() {
|
||||
const HEAD: &str = "HEADSENTINEL7431";
|
||||
const TAIL: &str = "TAILSENTINEL7431";
|
||||
const ROWS: usize = 80;
|
||||
|
||||
// Code-block rows render verbatim (no markdown reflow) and syntect
|
||||
// highlights the python fence, so committed rows carry SGR sequences.
|
||||
let mut response = String::new();
|
||||
response.push_str("```python\n");
|
||||
response.push_str(&format!("# {HEAD} wide: 你好 WIDEMARK 世界 🚀 EMOJIMARK\n"));
|
||||
for i in 0..ROWS {
|
||||
response.push_str(&format!(
|
||||
"payload_row_{i:02} = \"qzjvxk\" # comment {i:02}\n"
|
||||
));
|
||||
}
|
||||
response.push_str(&format!("# {TAIL}\n"));
|
||||
response.push_str("```\n");
|
||||
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(response);
|
||||
|
||||
let mut harness = spawn_minimal(&content);
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
|
||||
// The block commits to native scrollback when the turn finalizes; poll
|
||||
// until the head sentinel (which scrolled far above the live region)
|
||||
// lands in scrollback.
|
||||
let deadline = Instant::now() + Duration::from_secs(40);
|
||||
while Instant::now() < deadline && !harness.scrollback_text().contains(HEAD) {
|
||||
harness.update(Duration::from_millis(100));
|
||||
}
|
||||
// Precondition: the block head actually committed to NATIVE scrollback.
|
||||
// Without this, a timeout above would fall through to `full_text()`
|
||||
// (scrollback + screen) assertions, which content that never left the
|
||||
// live region could partially satisfy — and fail confusingly.
|
||||
assert!(
|
||||
harness.scrollback_text().contains(HEAD),
|
||||
"committed block head must reach native scrollback\nscrollback:\n{}\nscreen:\n{}",
|
||||
harness.scrollback_text(),
|
||||
harness.screen_contents(),
|
||||
);
|
||||
|
||||
let full = harness.full_text();
|
||||
|
||||
// Head and tail sentinels present exactly once and in order: a duplicated
|
||||
// segment emission repeats them, a dropped one loses them.
|
||||
for sentinel in [HEAD, TAIL] {
|
||||
assert_eq!(
|
||||
full.matches(sentinel).count(),
|
||||
1,
|
||||
"sentinel {sentinel} should appear exactly once\nfull text:\n{full}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
full.find(HEAD).unwrap() < full.find(TAIL).unwrap(),
|
||||
"sentinels out of order\nfull text:\n{full}"
|
||||
);
|
||||
|
||||
// Every committed row survives exactly once, in full — an offset bug at a
|
||||
// segment boundary truncates or doubles rows.
|
||||
for i in 0..ROWS {
|
||||
let row = format!("payload_row_{i:02} = \"qzjvxk\" # comment {i:02}");
|
||||
assert_eq!(
|
||||
full.matches(&row).count(),
|
||||
1,
|
||||
"payload row {i:02} should appear exactly once and unmangled\nfull text:\n{full}"
|
||||
);
|
||||
}
|
||||
|
||||
// Wide chars survive the multi-byte wrap-point handling. Checked as
|
||||
// individual chars: the harness's text extraction renders each width-2
|
||||
// char followed by its spacer cell ("你 好", not "你好").
|
||||
for marker in ["你", "好", "世", "界", "🚀", "WIDEMARK", "EMOJIMARK"] {
|
||||
assert!(
|
||||
full.contains(marker),
|
||||
"wide-char marker {marker:?} missing\nfull text:\n{full}"
|
||||
);
|
||||
}
|
||||
|
||||
assert!(
|
||||
content.has_chat_completion(),
|
||||
"mock inference server never received a chat completion\nrequests: {:?}",
|
||||
content.requests()
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
// ── Auto-compact: the top padding row disappears on tiny terminals ─────────
|
||||
//
|
||||
// The default layout reserves a blank top padding row (`outer_vpad`) above the
|
||||
// status bar. When the terminal is `AUTO_COMPACT_MAX_ROWS` (20) or shorter,
|
||||
// the render-value compact flag is derived ON (auto-compact) and the padding
|
||||
// goes away, so the status bar moves to screen row 0. Growing the terminal
|
||||
// back restores the padding — the user's persisted compact setting never
|
||||
// changes.
|
||||
//
|
||||
// A YAML scenario cannot assert on-screen positions, so this test reads the
|
||||
// first non-blank screen row directly: row 0 blank = padding present, row 0
|
||||
// populated (by the status bar) = auto-compact engaged.
|
||||
|
||||
/// Short height that engages auto-compact. Above `SHORT_TERMINAL_ROWS` (16)
|
||||
/// but at most `AUTO_COMPACT_MAX_ROWS` (20), so it pins the auto-compact
|
||||
/// derivation specifically — not the older short-terminal layout trims.
|
||||
const SHORT_ROWS: u16 = 18;
|
||||
|
||||
/// Index of the first screen row with any non-whitespace content, panicking
|
||||
/// with the screen when the whole screen is blank.
|
||||
fn first_content_row(harness: &PtyHarness, when: &str) -> u16 {
|
||||
let screen = harness.screen_contents();
|
||||
screen
|
||||
.lines()
|
||||
.position(|line| !line.trim().is_empty())
|
||||
.unwrap_or_else(|| panic!("{when}: screen is entirely blank\nscreen:\n{screen}")) as u16
|
||||
}
|
||||
|
||||
/// **Auto-compact drops the top padding row on tiny terminals.**
|
||||
/// Tall: row 0 is the blank top padding row (first content below it).
|
||||
/// At `SHORT_ROWS`: auto-compact removes the padding, status bar lands on
|
||||
/// row 0. Back tall: padding (and the blank row 0) restored.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn auto_compact_top_row() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} auto-compact probe."));
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness =
|
||||
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
|
||||
.expect("spawn pager with content");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
|
||||
// Enter the agent view (the layout under test) and let the turn end.
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("mock response rendered");
|
||||
harness.update(Duration::from_millis(500));
|
||||
|
||||
let tall_row = first_content_row(&harness, "tall spawn");
|
||||
assert!(
|
||||
tall_row > 0,
|
||||
"tall terminal must keep the blank top padding row (content starts below \
|
||||
row 0), got first content on row {tall_row}\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
// Shrink to an auto-compact height: the padding row must vanish.
|
||||
harness
|
||||
.resize(SHORT_ROWS, DEFAULT_COLS)
|
||||
.expect("resize short");
|
||||
harness.update(Duration::from_millis(900));
|
||||
assert!(
|
||||
harness.is_running(),
|
||||
"pager exited during resize\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
let short_row = first_content_row(&harness, "after shrink");
|
||||
assert_eq!(
|
||||
short_row,
|
||||
0,
|
||||
"auto-compact must drop the top padding row at {SHORT_ROWS} rows \
|
||||
(status bar on row 0)\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
// Grow back: the derived value reverts to the user's setting.
|
||||
harness
|
||||
.resize(DEFAULT_ROWS, DEFAULT_COLS)
|
||||
.expect("resize tall");
|
||||
harness.update(Duration::from_millis(900));
|
||||
let restored_row = first_content_row(&harness, "after grow");
|
||||
assert!(
|
||||
restored_row > 0,
|
||||
"growing back must restore the blank top padding row, got first content \
|
||||
on row {restored_row}\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
!harness.screen_contents().contains("panicked"),
|
||||
"pager rendered 'panicked'\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
|
||||
/// **Auto-compact engages from the startup seed alone.**
|
||||
/// Spawning already-tiny (no resize event ever fires) must still land the
|
||||
/// status bar on row 0: the startup seed (`crossterm::terminal::size()` into
|
||||
/// the initial appearance) is the only path that can have derived it.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn auto_compact_at_startup() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} startup probe."));
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness =
|
||||
PtyHarness::spawn_with_content(&binary, SHORT_ROWS, DEFAULT_COLS, &content, &[])
|
||||
.expect("spawn pager with content");
|
||||
|
||||
// The welcome menu may be trimmed on a short spawn; the prompt marker
|
||||
// always paints. The first char promotes to the agent view under test.
|
||||
harness
|
||||
.wait_for_text("\u{276f}", WELCOME_TIMEOUT)
|
||||
.expect("prompt marker");
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("mock response rendered");
|
||||
harness.update(Duration::from_millis(500));
|
||||
|
||||
let row = first_content_row(&harness, "tiny spawn");
|
||||
assert_eq!(
|
||||
row,
|
||||
0,
|
||||
"a {SHORT_ROWS}-row spawn must start auto-compacted (status bar on \
|
||||
row 0, no top padding row) without any resize\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
!harness.screen_contents().contains("panicked"),
|
||||
"pager rendered 'panicked'\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
//! PTY: a user prompt queued behind a running auto-wake turn must survive a
|
||||
//! Ctrl+C cancel — it runs next and is durable across `--continue`.
|
||||
//!
|
||||
//! The failure chain this guards: a background task completes while the agent
|
||||
//! is idle, so the shell injects a synthetic `task-completed-<id>` prompt
|
||||
//! (auto-wake) whose reminder tells the model to poll the task-output tool.
|
||||
//! That tool result triggers the consumed-completion sweep of
|
||||
//! `pending_inputs`, which must NOT delete the running auto-wake turn's own
|
||||
//! front slot. If it does, a user prompt queued behind it (the pager doesn't
|
||||
//! adopt synthetic turns, so a typed message dispatches immediately) shifts to
|
||||
//! the front, and the next Ctrl+C resolves THE USER'S prompt as Cancelled: it
|
||||
//! never reaches the model, and — since user messages are only persisted when
|
||||
//! their turn starts — it is silently gone after a `--continue` resume.
|
||||
//!
|
||||
//! Set `KIGI_PTY_CAST_DIR` to also dump asciinema casts of both pager runs
|
||||
//! (written before the final asserts so a failing run still produces them).
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// Marker for the user's mid-auto-wake message. Unique enough to grep for in
|
||||
/// request bodies and replayed history without false positives.
|
||||
#[cfg(unix)]
|
||||
const CLARIFY_MARKER: &str = "CLARIFY_MARKER_XYZ";
|
||||
|
||||
/// Background sleep that triggers the auto-wake on completion. Long enough
|
||||
/// that turn 1 settles and the auto-wake scripts are enqueued before it fires,
|
||||
/// even on a loaded CI host.
|
||||
#[cfg(unix)]
|
||||
const BG_SLEEP_SECS: &str = "6";
|
||||
|
||||
/// Foreground sleep that holds the auto-wake turn deterministically running
|
||||
/// while the user message and Ctrl+C are injected. Never runs to completion —
|
||||
/// the cancel kills it on both the broken and fixed paths — so a generous
|
||||
/// bound costs nothing and removes the settle-before-cancel race.
|
||||
#[cfg(unix)]
|
||||
const HOLD_SLEEP_SECS: &str = "15";
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
|
||||
async fn auto_wake_cancel_preserves_queued_user_prompt() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
|
||||
// Turn 1: the model backgrounds a sleep via run_terminal_command, then the
|
||||
// follow-up turn settles to plain text so the agent goes idle while the
|
||||
// background task runs (the precondition for an auto-wake).
|
||||
let bg_args = json!({
|
||||
"command": format!("/bin/sleep {BG_SLEEP_SECS}"),
|
||||
"description": "auto-wake trigger",
|
||||
"is_background": true
|
||||
})
|
||||
.to_string();
|
||||
content.enqueue_response(
|
||||
"/v1/responses",
|
||||
ScriptedResponse::sse(responses_api_tool_call_events(
|
||||
"call_bg_wake",
|
||||
"run_terminal_command",
|
||||
&bg_args,
|
||||
)),
|
||||
);
|
||||
content.enqueue_response(
|
||||
"/v1/chat/completions",
|
||||
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
|
||||
"call_bg_wake",
|
||||
"run_terminal_command",
|
||||
&bg_args,
|
||||
)),
|
||||
);
|
||||
content.set_response("TURN1_SETTLED");
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
// --yolo skips the bash permission prompt; --trust skips the folder-trust gate.
|
||||
let mut harness = PtyHarness::spawn_with_content_in_dir(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&content,
|
||||
&["--yolo", "--trust"],
|
||||
Some(content.home()),
|
||||
)
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome");
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text("TURN1_SETTLED", Duration::from_secs(45))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"background tool call never settled; screen:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
|
||||
// The runtime task id (a UUID minted by the terminal actor, NOT the
|
||||
// scripted tool_call_id) rides in the tool result of turn 1's follow-up
|
||||
// request, inside a <task-id>…</task-id> envelope.
|
||||
let task_id = poll_for(Duration::from_secs(10), || {
|
||||
content
|
||||
.request_bodies()
|
||||
.iter()
|
||||
.find_map(|b| extract_task_id(&b.to_string()))
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"no <task-id> in any request body\n--- non-system messages ---\n{}",
|
||||
dump_non_system_messages(&content.request_bodies())
|
||||
)
|
||||
});
|
||||
|
||||
// Enqueue the auto-wake turn's scripts BEFORE the background sleep
|
||||
// completes: first a task-output poll (whose completed result triggers the
|
||||
// consumed-completion sweep), then a foreground sleep that pins the turn
|
||||
// running while the user message and Ctrl+C land.
|
||||
let poll_args = json!({ "task_ids": [task_id.clone()] }).to_string();
|
||||
content.enqueue_response(
|
||||
"/v1/responses",
|
||||
ScriptedResponse::sse(responses_api_tool_call_events(
|
||||
"call_wake_poll",
|
||||
"get_command_or_subagent_output",
|
||||
&poll_args,
|
||||
)),
|
||||
);
|
||||
content.enqueue_response(
|
||||
"/v1/chat/completions",
|
||||
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
|
||||
"call_wake_poll",
|
||||
"get_command_or_subagent_output",
|
||||
&poll_args,
|
||||
)),
|
||||
);
|
||||
let hold_args = json!({
|
||||
"command": format!("/bin/sleep {HOLD_SLEEP_SECS}"),
|
||||
"description": "hold turn"
|
||||
})
|
||||
.to_string();
|
||||
content.enqueue_response(
|
||||
"/v1/responses",
|
||||
ScriptedResponse::sse(responses_api_tool_call_events(
|
||||
"call_wake_hold",
|
||||
"run_terminal_command",
|
||||
&hold_args,
|
||||
)),
|
||||
);
|
||||
content.enqueue_response(
|
||||
"/v1/chat/completions",
|
||||
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
|
||||
"call_wake_hold",
|
||||
"run_terminal_command",
|
||||
&hold_args,
|
||||
)),
|
||||
);
|
||||
// Fallback for every unscripted request after the queues drain (and the
|
||||
// response the surviving user prompt streams on the fixed path).
|
||||
content.set_response("AUTO_WAKE_SETTLED");
|
||||
|
||||
// Auto-wake mid-flight gate: the request AFTER the task-output tool call
|
||||
// executed carries its result ("=== Task <id> ==="). At that point the
|
||||
// sweep has run and the foreground hold is about to start.
|
||||
let wake_polled = poll_for(Duration::from_secs(30), || {
|
||||
let marker = format!("=== Task {task_id} ===");
|
||||
content
|
||||
.request_bodies()
|
||||
.iter()
|
||||
.any(|b| b.to_string().contains(&marker))
|
||||
.then_some(())
|
||||
})
|
||||
.is_some();
|
||||
assert!(
|
||||
wake_polled,
|
||||
"auto-wake turn never polled the task output\n--- non-system messages ---\n{}\n--- screen ---\n{}",
|
||||
dump_non_system_messages(&content.request_bodies()),
|
||||
harness.screen_contents()
|
||||
);
|
||||
harness.update(Duration::from_millis(500));
|
||||
|
||||
// The pager does not adopt synthetic turns, so it believes it is idle and
|
||||
// dispatches the typed message immediately — it queues server-side behind
|
||||
// the running auto-wake turn. Text and Enter go separately so a bulk
|
||||
// inject can't be paste-coalesced past the submit.
|
||||
harness
|
||||
.inject_keys(format!("{CLARIFY_MARKER} please stop").as_bytes())
|
||||
.expect("type clarifying message");
|
||||
harness.update(Duration::from_millis(500));
|
||||
harness
|
||||
.inject_keys(b"\r")
|
||||
.expect("submit clarifying message");
|
||||
harness.update(Duration::from_millis(500));
|
||||
|
||||
// One Ctrl+C: must cancel the auto-wake turn (killing the held sleep),
|
||||
// not the queued user prompt.
|
||||
harness.inject_keys(keys::CTRL_C).expect("press ctrl+c");
|
||||
harness.update(Duration::from_secs(2));
|
||||
|
||||
// The surviving prompt is promoted after the cancel and reaches the model.
|
||||
let marker_on_wire = poll_for(Duration::from_secs(20), || {
|
||||
content
|
||||
.request_bodies()
|
||||
.iter()
|
||||
.any(|b| b.to_string().contains(CLARIFY_MARKER))
|
||||
.then_some(())
|
||||
})
|
||||
.is_some();
|
||||
if marker_on_wire {
|
||||
// Let the promoted turn finish streaming so the resumed replay below
|
||||
// is deterministic on the fixed path.
|
||||
let _ = harness.wait_for_full_text("AUTO_WAKE_SETTLED", Duration::from_secs(15));
|
||||
}
|
||||
|
||||
write_cast_if_requested(&harness, "auto_wake_repro_main.cast");
|
||||
|
||||
// Graceful quit (Ctrl+Q double-press: focus is in the prompt, 'q' would type).
|
||||
harness.update(Duration::from_millis(500));
|
||||
harness.inject_keys(b"\x11").expect("ctrl-q once");
|
||||
harness.update(Duration::from_millis(200));
|
||||
harness.inject_keys(b"\x11").expect("ctrl-q confirm");
|
||||
harness.quit().expect("reap pager");
|
||||
|
||||
// Resume the same session: the user's message must have been persisted
|
||||
// (user messages are only written once their turn starts) and replay.
|
||||
let mut resumed = PtyHarness::spawn_with_content_in_dir(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&content,
|
||||
&["--continue", "--yolo", "--trust"],
|
||||
Some(content.home()),
|
||||
)
|
||||
.expect("spawn resumed pager");
|
||||
// The replay follows the transcript tail, so on the fixed path turn 1 may
|
||||
// have scrolled above the viewport — the marker near the tail is an
|
||||
// equally valid replay-finished signal.
|
||||
let replay_ok = resumed
|
||||
.wait_for_full_text("TURN1_SETTLED", Duration::from_secs(30))
|
||||
.is_ok();
|
||||
resumed.update(Duration::from_secs(1));
|
||||
let resumed_full_text = resumed.full_text();
|
||||
let marker_in_replay = resumed.contains_full_text(CLARIFY_MARKER);
|
||||
|
||||
write_cast_if_requested(&resumed, "auto_wake_repro_continue.cast");
|
||||
resumed.quit().expect("quit resumed pager");
|
||||
|
||||
assert!(
|
||||
marker_on_wire,
|
||||
"Ctrl+C during the auto-wake turn destroyed the queued user prompt: \
|
||||
{CLARIFY_MARKER} never reached the model\nrequests: {}\n--- non-system messages ---\n{}",
|
||||
content.request_count(),
|
||||
dump_non_system_messages(&content.request_bodies())
|
||||
);
|
||||
assert!(
|
||||
replay_ok || marker_in_replay,
|
||||
"--continue never replayed the session history\nfull contents:\n{resumed_full_text}"
|
||||
);
|
||||
assert!(
|
||||
marker_in_replay,
|
||||
"queued user prompt missing from --continue replay (lost from history)\n\
|
||||
full contents:\n{resumed_full_text}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
//! PTY: a background shell command spawned by a tool call must be reaped when
|
||||
//! the pager exits, so it can't outlive the TUI. This is the `/loop`
|
||||
//! orphaned-watcher quota-drain bug: background `run_terminal_command` / `monitor`
|
||||
//! commands are `setsid`-detached, so they escape the terminal's process group
|
||||
//! and survive a quit/kill unless the exit path reaps them.
|
||||
//!
|
||||
//! Drives the real pager against the mock (spawn args are only `--yolo --trust`;
|
||||
//! the prompt is typed in via `inject_keys` after the welcome screen), scripts a
|
||||
//! background command that records its PID then sleeps, quits via a real SIGINT,
|
||||
//! and asserts the PID is gone. Without the fix the detached process reparents to
|
||||
//! init and keeps running, so the final poll times out.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// 10 min: comfortably outlasts the test's worst-case runtime yet self-exits, so
|
||||
/// a reap regression can't leak a ~68-year `/bin/sleep` on CI (tracked by PID
|
||||
/// file, so the exact bound only needs to outlast the test).
|
||||
#[cfg(unix)]
|
||||
const SLEEP_SECS: &str = "600";
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
|
||||
async fn background_task_reaped_on_quit() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
let pidfile = content.home().join("orphan_bg.pid");
|
||||
let donefile = content.home().join("orphan_bg.done");
|
||||
let errfile = content.home().join("orphan_bg.err");
|
||||
|
||||
// Turn 1: the model runs a background command via run_terminal_command. It writes
|
||||
// its PID, then sleeps; the done/err files capture an unexpected early exit
|
||||
// for diagnostics. Absolute /bin/sleep avoids PATH surprises in the spawn env.
|
||||
let command = format!(
|
||||
"echo $$ > {pid}; /bin/sleep {SLEEP_SECS} 2> {err}; echo rc=$? > {done}",
|
||||
pid = pidfile.display(),
|
||||
err = errfile.display(),
|
||||
done = donefile.display(),
|
||||
);
|
||||
let args = json!({
|
||||
"command": command,
|
||||
"description": "background orphan reap test",
|
||||
"is_background": true
|
||||
})
|
||||
.to_string();
|
||||
content.enqueue_response(
|
||||
"/v1/responses",
|
||||
ScriptedResponse::sse(responses_api_tool_call_events(
|
||||
"call_bg",
|
||||
"run_terminal_command",
|
||||
&args,
|
||||
)),
|
||||
);
|
||||
content.enqueue_response(
|
||||
"/v1/chat/completions",
|
||||
ScriptedResponse::sse(chat_completions_tool_call_events(
|
||||
"run_terminal_command",
|
||||
&args,
|
||||
)),
|
||||
);
|
||||
// Follow-up turns settle to plain text so the session goes idle.
|
||||
content.set_response("BG_TASK_STARTED");
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
// --yolo skips the bash permission prompt; --trust skips the folder-trust gate.
|
||||
let mut harness = PtyHarness::spawn_with_content_in_dir(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&content,
|
||||
&["--yolo", "--trust"],
|
||||
Some(content.home()),
|
||||
)
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome");
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
|
||||
// The settle text confirms the tool call ran and the follow-up turn finished.
|
||||
harness
|
||||
.wait_for_text("BG_TASK_STARTED", Duration::from_secs(45))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"background tool call never settled; screen:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
|
||||
// The background command writes its PID once it runs (proof the tool executed).
|
||||
if !wait_until(Duration::from_secs(10), || pidfile.exists()) {
|
||||
panic!(
|
||||
"background command never ran (no pidfile)\n--- non-system messages ---\n{}\n--- files under home ---\n{}\n--- screen ---\n{}",
|
||||
dump_non_system_messages(&content.request_bodies()),
|
||||
dump_files(content.home()),
|
||||
harness.screen_contents()
|
||||
);
|
||||
}
|
||||
let pid = read_pid(&pidfile);
|
||||
assert!(pid > 1, "pidfile did not contain a valid pid: {pid}");
|
||||
|
||||
// Sanity: the detached sleep is alive before we quit.
|
||||
if !wait_until(Duration::from_secs(5), || pid_alive(pid)) {
|
||||
panic!(
|
||||
"background sleep (pid {pid}) exited immediately; nothing to test\n\
|
||||
done={:?} err={:?}\n--- files under home ---\n{}\n--- screen ---\n{}",
|
||||
std::fs::read_to_string(&donefile).ok(),
|
||||
std::fs::read_to_string(&errfile).ok(),
|
||||
dump_files(content.home()),
|
||||
harness.screen_contents()
|
||||
);
|
||||
}
|
||||
|
||||
// A real SIGINT (not an injected Ctrl+C key byte) drives the OS-signal exit.
|
||||
// Both the graceful-quit teardown and the hard-exit tail reap spawned
|
||||
// children via the process-global ProcessScope, so the orphan dies either way.
|
||||
harness.send_signal(libc::SIGINT).expect("send SIGINT");
|
||||
let code = harness.wait_exit_code(Duration::from_secs(15));
|
||||
assert!(code.is_some(), "pager did not exit after SIGINT");
|
||||
|
||||
// The fix: no orphaned background process survives the quit. Without it the
|
||||
// setsid-detached sleep reparents to init and keeps running -> this times out.
|
||||
assert!(
|
||||
wait_until(Duration::from_secs(15), || !pid_alive(pid)),
|
||||
"background sleep (pid {pid}) survived pager exit (orphaned)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Read a PID written by `echo $$`.
|
||||
#[cfg(unix)]
|
||||
fn read_pid(p: &Path) -> i32 {
|
||||
std::fs::read_to_string(p)
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse().ok())
|
||||
.unwrap_or(-1)
|
||||
}
|
||||
|
||||
/// Whether `pid` exists (running or not-yet-reaped zombie) via `kill(pid, 0)`.
|
||||
#[cfg(unix)]
|
||||
fn pid_alive(pid: i32) -> bool {
|
||||
if pid <= 1 {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: kill with signal 0 performs only an existence/permission check.
|
||||
unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
|
||||
}
|
||||
|
||||
/// Recursive listing of files under `home` (with sizes), to find where tool
|
||||
/// output / our marker files landed.
|
||||
#[cfg(unix)]
|
||||
fn dump_files(home: &Path) -> String {
|
||||
let mut out = String::new();
|
||||
let mut stack = vec![home.to_path_buf()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(rd) = std::fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
} else {
|
||||
let len = e.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
out.push_str(&format!("{} ({len}B)\n", p.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
// Demo artifact generator (and smoke assert) for basename-only collapsed
|
||||
// Read headers. Writes asciicast + HTML under /tmp/basename_path_video for
|
||||
// agg → gif/mp4. Not meant as a permanent product regression pin.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
const DONE_SENTINEL: &str = "BASENAME_PATH_DEMO_DONE";
|
||||
const ARTIFACT_DIR: &str = "/tmp/basename_path_video";
|
||||
/// Unique basename so on-screen match is unambiguous.
|
||||
const FILE_NAME: &str = "basename_demo_target.rs";
|
||||
/// Nested dirs so full abs path is long and would dominate a collapsed header.
|
||||
const NEST: &str = "very/deep/nested/project/src/module";
|
||||
|
||||
fn screen_shows_full_path(screen: &str, full_path: &str) -> bool {
|
||||
if screen.contains(full_path) {
|
||||
return true;
|
||||
}
|
||||
// Modal wraps long paths mid-segment-list (e.g. `…/src/` then `module/file`).
|
||||
// Require nest markers that never appear in HOME chrome alone.
|
||||
let joined = screen.replace('\n', "");
|
||||
if joined.contains(NEST) && joined.contains(FILE_NAME) {
|
||||
return true;
|
||||
}
|
||||
screen.contains("very/deep") && screen.contains("nested/project") && screen.contains(FILE_NAME)
|
||||
}
|
||||
|
||||
fn write_asciicast(path: &Path, cols: u16, rows: u16, events: &[(f64, String)]) {
|
||||
let mut f = fs::File::create(path).expect("create cast");
|
||||
let header = serde_json::json!({
|
||||
"version": 2,
|
||||
"width": cols,
|
||||
"height": rows,
|
||||
"timestamp": std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0),
|
||||
"env": {"TERM": "xterm-256color", "SHELL": "/bin/zsh"},
|
||||
});
|
||||
writeln!(f, "{header}").expect("header");
|
||||
for (t, out) in events {
|
||||
let line = serde_json::json!([t, "o", out]);
|
||||
writeln!(f, "{line}").expect("event");
|
||||
}
|
||||
}
|
||||
|
||||
fn enqueue_tool_turn(content: &ContentController, call_id: &str, name: &str, args: String) {
|
||||
content.enqueue_response(
|
||||
"/v1/responses",
|
||||
ScriptedResponse::sse(responses_api_tool_call_events(call_id, name, &args)),
|
||||
);
|
||||
content.enqueue_response(
|
||||
"/v1/chat/completions",
|
||||
ScriptedResponse::sse(chat_completions_tool_call_events(name, &args)),
|
||||
);
|
||||
}
|
||||
|
||||
/// PTY demo: long abs path → collapsed header shows basename only; open block
|
||||
/// viewer and show full path in modal preamble. Dumps asciicast for video.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "demo video generator; run with cargo test -p kigi-tui --test pty_e2e_smoke basename_path_demo_pty -- --ignored --nocapture"]
|
||||
async fn basename_path_demo_pty() {
|
||||
fs::create_dir_all(ARTIFACT_DIR).expect("artifact dir");
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
|
||||
let nest_dir = content.home().join(NEST);
|
||||
fs::create_dir_all(&nest_dir).expect("nest dirs");
|
||||
let target = nest_dir.join(FILE_NAME);
|
||||
fs::write(&target, "// basename path demo fixture\npub fn demo() {}\n").expect("write fixture");
|
||||
let abs = dunce::canonicalize(&target).unwrap_or(target.clone());
|
||||
let full_path = abs.to_string_lossy().into_owned();
|
||||
|
||||
enqueue_tool_turn(
|
||||
&content,
|
||||
"call_basename_read",
|
||||
"read_file",
|
||||
json!({ "target_file": full_path }).to_string(),
|
||||
);
|
||||
content.set_response(DONE_SENTINEL);
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let rows = DEFAULT_ROWS;
|
||||
let cols = DEFAULT_COLS;
|
||||
// The demo shows the RAW basename-only `Read {path}` header; with
|
||||
// verb-group folding on (default), a lone read folds into "Read 1 file"
|
||||
// and the basename row never renders.
|
||||
seed_ui_config(&content, "group_tool_verbs = false");
|
||||
let mut harness = PtyHarness::spawn_with_content_in_dir(
|
||||
&binary,
|
||||
rows,
|
||||
cols,
|
||||
&content,
|
||||
&["--yolo", "--trust"],
|
||||
Some(content.home()),
|
||||
)
|
||||
.expect("spawn pager");
|
||||
|
||||
let t0 = Instant::now();
|
||||
let mut events: Vec<(f64, String)> = Vec::new();
|
||||
let mut raw_cursor = 0usize;
|
||||
let mut sample = |harness: &mut PtyHarness| {
|
||||
harness.update(Duration::from_millis(50));
|
||||
let raw = harness.raw_output();
|
||||
if raw.len() > raw_cursor {
|
||||
let chunk = &raw[raw_cursor..];
|
||||
raw_cursor = raw.len();
|
||||
let s = String::from_utf8_lossy(chunk).into_owned();
|
||||
if !s.is_empty() {
|
||||
events.push((t0.elapsed().as_secs_f64(), s));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let welcome_deadline = Instant::now() + WELCOME_TIMEOUT;
|
||||
loop {
|
||||
sample(&mut harness);
|
||||
if harness.contains_text(WELCOME_SCREEN_SENTINEL) {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < welcome_deadline,
|
||||
"welcome timeout; screen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
}
|
||||
// Hold welcome for the video.
|
||||
for _ in 0..15 {
|
||||
sample(&mut harness);
|
||||
}
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
|
||||
// Wait for collapsed Read header with basename.
|
||||
let read_deadline = Instant::now() + Duration::from_secs(60);
|
||||
loop {
|
||||
sample(&mut harness);
|
||||
let screen = harness.screen_contents();
|
||||
if screen.contains(FILE_NAME) && screen.contains("Read ") {
|
||||
break;
|
||||
}
|
||||
if Instant::now() > read_deadline {
|
||||
panic!("timeout waiting for Read {FILE_NAME}; screen:\n{screen}");
|
||||
}
|
||||
}
|
||||
|
||||
// Settle turn.
|
||||
let settle_deadline = Instant::now() + Duration::from_secs(45);
|
||||
loop {
|
||||
sample(&mut harness);
|
||||
if harness.contains_text(DONE_SENTINEL) {
|
||||
break;
|
||||
}
|
||||
if Instant::now() > settle_deadline {
|
||||
panic!(
|
||||
"timeout waiting for {DONE_SENTINEL}; screen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Hold collapsed view so the video can show basename-only header.
|
||||
for _ in 0..40 {
|
||||
sample(&mut harness);
|
||||
}
|
||||
|
||||
let collapsed = harness.screen_contents();
|
||||
fs::write(
|
||||
PathBuf::from(ARTIFACT_DIR).join("collapsed.txt"),
|
||||
&collapsed,
|
||||
)
|
||||
.ok();
|
||||
fs::write(
|
||||
PathBuf::from(ARTIFACT_DIR).join("collapsed.html"),
|
||||
harness.screen_html(),
|
||||
)
|
||||
.ok();
|
||||
|
||||
// Collapsed header must show basename, not the nested parent segments.
|
||||
assert!(
|
||||
collapsed.contains(FILE_NAME),
|
||||
"collapsed screen must show basename; screen:\n{collapsed}"
|
||||
);
|
||||
assert!(
|
||||
collapsed.contains("Read "),
|
||||
"collapsed screen must show Read label; screen:\n{collapsed}"
|
||||
);
|
||||
// Parent nest segment should not appear next to the tool label on the
|
||||
// collapsed one-liner (full path may still appear elsewhere rarely; the
|
||||
// header itself is basename-only — check the Read line specifically).
|
||||
let mut saw_basename_header = false;
|
||||
for line in collapsed.lines() {
|
||||
if line.contains("Read ") && line.contains(FILE_NAME) {
|
||||
saw_basename_header = true;
|
||||
assert!(
|
||||
!line.contains("very/deep")
|
||||
&& !line.contains(&*full_path)
|
||||
&& !line.contains("/module/"),
|
||||
"collapsed Read header must not show full/nested path; line={line:?} full={full_path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
saw_basename_header,
|
||||
"expected a Read {FILE_NAME} header line; screen:\n{collapsed}"
|
||||
);
|
||||
|
||||
// Open block viewer: Tab → select Read line (click) → Ctrl+f (OpenBlockViewer
|
||||
// alt binding; Enter alone may toggle a verb-group header instead).
|
||||
harness.inject_keys(b"\t").expect("focus scrollback");
|
||||
for _ in 0..10 {
|
||||
sample(&mut harness);
|
||||
}
|
||||
let _ = harness.wait_for_text("Space:prompt", Duration::from_secs(10));
|
||||
for _ in 0..10 {
|
||||
sample(&mut harness);
|
||||
}
|
||||
|
||||
let screen_for_click = harness.screen_contents();
|
||||
if let Some((row, col)) = locate_screen_text(&screen_for_click, FILE_NAME) {
|
||||
let click = format!(
|
||||
"{}{}",
|
||||
sgr_mouse(0, row, col, 'M'),
|
||||
sgr_mouse(0, row, col, 'm'),
|
||||
);
|
||||
harness
|
||||
.inject_keys(click.as_bytes())
|
||||
.expect("click Read header");
|
||||
for _ in 0..8 {
|
||||
sample(&mut harness);
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+f = OpenBlockViewer (alt key).
|
||||
harness.inject_keys(b"\x06").expect("Ctrl+f open viewer");
|
||||
|
||||
let modal_deadline = Instant::now() + Duration::from_secs(15);
|
||||
let mut expanded_screen = loop {
|
||||
sample(&mut harness);
|
||||
let screen = harness.screen_contents();
|
||||
if screen_shows_full_path(&screen, &full_path) || Instant::now() > modal_deadline {
|
||||
break screen;
|
||||
}
|
||||
};
|
||||
|
||||
// If viewer didn't open, fold-expand the Read block (Truncated header uses
|
||||
// full path via width: None) — still demos the dual display.
|
||||
if !screen_shows_full_path(&expanded_screen, &full_path) {
|
||||
// Esc out of any partial overlay, then click + Enter (toggle fold / group).
|
||||
harness.inject_keys(b"\x1b").expect("esc");
|
||||
for _ in 0..6 {
|
||||
sample(&mut harness);
|
||||
}
|
||||
if let Some((row, col)) = locate_screen_text(&harness.screen_contents(), FILE_NAME) {
|
||||
let click = format!(
|
||||
"{}{}",
|
||||
sgr_mouse(0, row, col, 'M'),
|
||||
sgr_mouse(0, row, col, 'm'),
|
||||
);
|
||||
harness
|
||||
.inject_keys(click.as_bytes())
|
||||
.expect("re-click Read");
|
||||
for _ in 0..6 {
|
||||
sample(&mut harness);
|
||||
}
|
||||
}
|
||||
harness.inject_keys(b"\r").expect("Enter expand/view");
|
||||
let expand_deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
sample(&mut harness);
|
||||
expanded_screen = harness.screen_contents();
|
||||
if screen_shows_full_path(&expanded_screen, &full_path) {
|
||||
break;
|
||||
}
|
||||
if Instant::now() > expand_deadline {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hold expanded/modal view for the video.
|
||||
for _ in 0..50 {
|
||||
sample(&mut harness);
|
||||
}
|
||||
expanded_screen = harness.screen_contents();
|
||||
fs::write(
|
||||
PathBuf::from(ARTIFACT_DIR).join("expanded.txt"),
|
||||
&expanded_screen,
|
||||
)
|
||||
.ok();
|
||||
fs::write(
|
||||
PathBuf::from(ARTIFACT_DIR).join("expanded.html"),
|
||||
harness.screen_html(),
|
||||
)
|
||||
.ok();
|
||||
|
||||
let expanded_has_full = screen_shows_full_path(&expanded_screen, &full_path);
|
||||
fs::write(
|
||||
PathBuf::from(ARTIFACT_DIR).join("assertions.txt"),
|
||||
format!(
|
||||
"full_path={full_path}\n\
|
||||
basename={FILE_NAME}\n\
|
||||
collapsed_has_basename={}\n\
|
||||
collapsed_has_full_on_header={}\n\
|
||||
expanded_has_full_path={expanded_has_full}\n",
|
||||
collapsed.contains(FILE_NAME),
|
||||
collapsed
|
||||
.lines()
|
||||
.any(|l| l.contains("Read ") && l.contains(&*full_path)),
|
||||
),
|
||||
)
|
||||
.ok();
|
||||
|
||||
assert!(
|
||||
expanded_has_full,
|
||||
"expanded / modal view should show full path ({full_path}); screen:\n{expanded_screen}"
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
let cast_path = PathBuf::from(ARTIFACT_DIR).join("basename-path-demo.cast");
|
||||
write_asciicast(&cast_path, cols, rows, &events);
|
||||
fs::write(
|
||||
PathBuf::from(ARTIFACT_DIR).join("raw.ansi"),
|
||||
harness.raw_output(),
|
||||
)
|
||||
.ok();
|
||||
fs::write(
|
||||
PathBuf::from(ARTIFACT_DIR).join("final.txt"),
|
||||
harness.screen_contents(),
|
||||
)
|
||||
.ok();
|
||||
fs::write(
|
||||
PathBuf::from(ARTIFACT_DIR).join("final.html"),
|
||||
harness.screen_html(),
|
||||
)
|
||||
.ok();
|
||||
|
||||
eprintln!(
|
||||
"basename path demo artifacts → {ARTIFACT_DIR} (cast events={}, duration≈{:.1}s, full_path={full_path})",
|
||||
events.len(),
|
||||
t0.elapsed().as_secs_f64()
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// PTY, against the built binary with real SGR clicks: a finished `!`
|
||||
/// command shows its full output (success and failure), double-click folds
|
||||
/// the block, and a second double-click restores the full output — never
|
||||
/// the first/last preview.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
|
||||
#[cfg(unix)]
|
||||
async fn bash_full_output_double_click_fold_pty() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} session ready."));
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness = PtyHarness::spawn_with_content_in_dir(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&content,
|
||||
&[],
|
||||
Some(content.home()),
|
||||
)
|
||||
.expect("spawn pager");
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome");
|
||||
|
||||
// Establish a session so `!` runs as an execute tool with Run chrome.
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("start session");
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("session ready");
|
||||
|
||||
// 1. Success: 12 lines exceed the streaming window; all visible on finish.
|
||||
harness
|
||||
.inject_keys(b"! printf 'L%02d\\n' $(seq 1 12)\r")
|
||||
.expect("submit bash-mode command");
|
||||
harness
|
||||
.wait_for_text("L12", Duration::from_secs(30))
|
||||
.expect("bash output tail");
|
||||
harness
|
||||
.wait_for_text("L06", Duration::from_secs(10))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"finished ! command must show its full output (middle lines); got:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
for line in ["L01", "L03", "L09"] {
|
||||
assert!(
|
||||
harness.contains_text(line),
|
||||
"finished ! command must not truncate output ({line} missing)\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Double-click folds; a second double-click restores the full output.
|
||||
harness.inject_keys(b"\t").expect("focus scrollback");
|
||||
harness
|
||||
.wait_for_text("Ctrl+e:", Duration::from_secs(10))
|
||||
.expect("scrollback owns keys");
|
||||
let screen = harness.screen_contents();
|
||||
let (row, col) = locate_screen_text(&screen, "Run (user)")
|
||||
.unwrap_or_else(|| panic!("locate ! block header; screen:\n{screen}"));
|
||||
let dbl = format!(
|
||||
"{}{}{}{}",
|
||||
sgr_mouse(0, row, col, 'M'),
|
||||
sgr_mouse(0, row, col, 'm'),
|
||||
sgr_mouse(0, row, col, 'M'),
|
||||
sgr_mouse(0, row, col, 'm'),
|
||||
);
|
||||
harness
|
||||
.inject_keys(dbl.as_bytes())
|
||||
.expect("double-click to fold");
|
||||
let gone = std::time::Instant::now() + Duration::from_secs(5);
|
||||
while harness.contains_text("L06") && std::time::Instant::now() < gone {
|
||||
harness.update(Duration::from_millis(100));
|
||||
}
|
||||
assert!(
|
||||
!harness.contains_text("L06"),
|
||||
"double-click must collapse the ! block; got:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
harness.update(Duration::from_millis(500)); // let the multi-click window lapse
|
||||
harness
|
||||
.inject_keys(dbl.as_bytes())
|
||||
.expect("double-click to expand");
|
||||
harness
|
||||
.wait_for_text("L06", Duration::from_secs(10))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"double-click must restore the FULL output (middle lines); got:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
|
||||
// 3. A failing command finishes fully expanded too.
|
||||
harness.inject_keys(b"\t").expect("refocus prompt");
|
||||
harness
|
||||
.wait_for_text("Shift+Tab:mode", Duration::from_secs(10))
|
||||
.expect("prompt owns keys");
|
||||
harness
|
||||
.inject_keys(b"! printf 'E%02d\\n' $(seq 1 12); false\r")
|
||||
.expect("submit failing bash-mode command");
|
||||
harness
|
||||
.wait_for_text("E12", Duration::from_secs(30))
|
||||
.expect("failed bash output tail");
|
||||
harness
|
||||
.wait_for_text("E06", Duration::from_secs(10))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"FAILED ! command must show its full output; got:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// Content of the nested file — `cat`ing it through the completed, quoted
|
||||
/// command must print this into the scrollback (proves the inserted quoting
|
||||
/// parses as a valid command in the real shell).
|
||||
const INNER_SENTINEL: &str = "INNER-NOTE-SENTINEL-4173";
|
||||
|
||||
/// Env: NO suggestion flag — Tab completion in bash mode is always on, and
|
||||
/// this test is the acceptance proof. `KIGI_SUGGESTIONS=0` pins the
|
||||
/// as-you-type pipeline OFF hermetically (the PTY child inherits the parent
|
||||
/// env, so a dev shell exporting the flag must not turn it on here); the
|
||||
/// history tier is pinned to a nonexistent file so file completions are the
|
||||
/// ONLY dropdown source.
|
||||
fn suggestions_env(content: &ContentController) -> Vec<(String, String)> {
|
||||
let mut env = content.env_for_pager();
|
||||
env.push(("SHELL".into(), "/bin/bash".into()));
|
||||
env.push(("KIGI_SUGGESTIONS".into(), "0".into()));
|
||||
env.push((
|
||||
"HISTFILE".into(),
|
||||
content
|
||||
.home()
|
||||
.join(".no_such_history")
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
));
|
||||
env
|
||||
}
|
||||
|
||||
/// Seed the session cwd the file provider lists:
|
||||
/// - `alpha_one.txt` + `alpha_two.txt` — the common-prefix-fill pair;
|
||||
/// - `notes.md` + `Notes Archive/inner_note.txt` — exact vs case-insensitive
|
||||
/// candidates, a spaced directory to drill into, and the file to run;
|
||||
/// - `script.sh` — unrelated noise that must never match either prefix.
|
||||
fn seed_cwd(cwd: &Path) {
|
||||
std::fs::create_dir_all(cwd.join(".git")).expect("create .git");
|
||||
std::fs::write(cwd.join("alpha_one.txt"), "").expect("seed alpha_one");
|
||||
std::fs::write(cwd.join("alpha_two.txt"), "").expect("seed alpha_two");
|
||||
std::fs::write(cwd.join("notes.md"), "").expect("seed notes.md");
|
||||
std::fs::write(cwd.join("script.sh"), "").expect("seed script.sh");
|
||||
let dir = cwd.join("Notes Archive");
|
||||
std::fs::create_dir_all(&dir).expect("seed spaced dir");
|
||||
std::fs::write(dir.join("inner_note.txt"), format!("{INNER_SENTINEL}\n"))
|
||||
.expect("seed inner note");
|
||||
}
|
||||
|
||||
/// The Tab-armed fetch and the post-accept/fill refreshes are async; give a
|
||||
/// landed response comfortable slack before the next Tab consumes it.
|
||||
async fn settle() {
|
||||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||
}
|
||||
|
||||
/// **Bash-mode file completion behaves like a real shell — with NO env
|
||||
/// flag.** In a seeded sandbox cwd:
|
||||
/// - `!cat al` + Tab fills the shared prefix `alpha_` in place (no dropdown
|
||||
/// flash); the next Tab opens the dropdown listing both candidates;
|
||||
/// - `cat "no` + Tab opens the dropdown (exact `notes.md` above the
|
||||
/// case-insensitive `Notes Archive/`); Down+Tab accepts the directory,
|
||||
/// preserving the open quote and keeping it open for drill-down;
|
||||
/// - the next Tab finds exactly one candidate inside the directory and
|
||||
/// accepts it immediately, closing the quote;
|
||||
/// - Enter runs the completed command through the real shell — the file's
|
||||
/// sentinel content reaching the scrollback proves the quoting produced a
|
||||
/// valid command.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
#[cfg(unix)]
|
||||
async fn bash_mode_file_completion_shell_like() {
|
||||
let project = tempfile::tempdir().expect("create project dir");
|
||||
seed_cwd(project.path());
|
||||
let cwd = dunce::canonicalize(project.path()).expect("canonicalize project");
|
||||
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} session up."));
|
||||
|
||||
let env = suggestions_env(&content);
|
||||
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness = PtyHarness::new_in_dir(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&[],
|
||||
&env_refs,
|
||||
Some(&cwd),
|
||||
)
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
|
||||
// Establish a session: bash mode lives on the agent-view prompt (the
|
||||
// welcome prompt never completes).
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("start session");
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("session ready");
|
||||
|
||||
// ── Leg 1: Tab fills the common prefix, second Tab opens the list ───
|
||||
harness
|
||||
.inject_keys(b"!cat al")
|
||||
.expect("type bash prefix with shared-prefix candidates");
|
||||
harness.inject_keys(b"\t").expect("first Tab");
|
||||
harness
|
||||
.wait_for_text("cat alpha_", Duration::from_secs(10))
|
||||
.expect("first Tab filled the common prefix in place");
|
||||
let screen = harness.screen_contents();
|
||||
assert!(
|
||||
!screen.contains("alpha_one.txt"),
|
||||
"prefix fill must not flash the dropdown:\n{screen}"
|
||||
);
|
||||
|
||||
settle().await;
|
||||
harness.inject_keys(b"\t").expect("second Tab");
|
||||
harness
|
||||
.wait_for_text("alpha_one.txt", Duration::from_secs(10))
|
||||
.expect("second Tab opened the dropdown");
|
||||
harness
|
||||
.wait_for_text("alpha_two.txt", Duration::from_secs(5))
|
||||
.expect("both candidates listed");
|
||||
|
||||
// Close the dropdown and clear the draft (non-empty: bash mode sticks).
|
||||
harness.inject_keys(b"\x1b").expect("Esc closes dropdown");
|
||||
harness.inject_keys(b"\x15").expect("Ctrl+U clears draft");
|
||||
|
||||
// ── Leg 2: quoted dropdown → dir accept → drill-down insta-accept ───
|
||||
harness
|
||||
.inject_keys(b"cat \"no")
|
||||
.expect("type quoted prefix");
|
||||
harness.inject_keys(b"\t").expect("Tab opens dropdown");
|
||||
// Case-differing candidates share no common prefix: no fill, plain open.
|
||||
harness
|
||||
.wait_for_text("Notes Archive/", Duration::from_secs(10))
|
||||
.expect("dropdown lists the case-insensitive directory match");
|
||||
|
||||
harness
|
||||
.inject_keys(b"\x1b[B")
|
||||
.expect("Down to the directory");
|
||||
harness
|
||||
.inject_keys(b"\t")
|
||||
.expect("Tab accepts the directory");
|
||||
harness
|
||||
.wait_for_text("cat \"Notes Archive/", Duration::from_secs(10))
|
||||
.expect("directory accepted with the quote preserved and open");
|
||||
|
||||
// The accept re-fetched inside the directory; its single candidate
|
||||
// insta-accepts and closes the quote.
|
||||
settle().await;
|
||||
harness.inject_keys(b"\t").expect("Tab drills down");
|
||||
harness
|
||||
.wait_for_text("Notes Archive/inner_note.txt\"", Duration::from_secs(10))
|
||||
.expect("single inner candidate accepted immediately, quote closed");
|
||||
|
||||
// ── The completed command actually RUNS through the real shell ──────
|
||||
harness.inject_keys(b"\r").expect("Enter runs the command");
|
||||
harness
|
||||
.wait_for_text(INNER_SENTINEL, Duration::from_secs(30))
|
||||
.expect("cat printed the nested file through the completed quoting");
|
||||
|
||||
let screen = harness.screen_contents();
|
||||
assert!(
|
||||
!screen.contains("panicked"),
|
||||
"pager panicked\nscreen:\n{screen}"
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// 23. **Bash-mode strips redundant `cd $SESSION_CWD &&` from execute chrome.**
|
||||
/// User `!` command with a leading cd into the session cwd should show the
|
||||
/// short command in the Run header, not the long cd prefix. Start a real session
|
||||
/// first so session_cwd is set and bash-mode runs as an execute tool (Run chrome),
|
||||
/// not only as a welcome-history line (`#1 ! …` keeps the typed command).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
#[cfg(unix)]
|
||||
async fn bash_mode_strips_redundant_session_cd_from_chrome() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!(
|
||||
"{MOCK_RESPONSE_SENTINEL} session ready for bash strip."
|
||||
));
|
||||
|
||||
let project = tempfile::tempdir().expect("create project dir");
|
||||
std::fs::create_dir_all(project.path().join(".git")).expect("create .git");
|
||||
let cwd = dunce::canonicalize(project.path()).expect("canonicalize project");
|
||||
let cwd_str = cwd.to_string_lossy();
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness = PtyHarness::spawn_with_content_in_dir(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&content,
|
||||
&[],
|
||||
Some(cwd.as_path()),
|
||||
)
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
|
||||
// Establish session so tracker has session_cwd and `!` goes through execute chrome.
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("start session");
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("session ready");
|
||||
|
||||
let cmd = format!("! cd {cwd_str} && printf 'STRIP_CD_OK\\n'\r");
|
||||
harness
|
||||
.inject_keys(cmd.as_bytes())
|
||||
.expect("submit bash-mode");
|
||||
|
||||
harness
|
||||
.wait_for_text("STRIP_CD_OK", Duration::from_secs(30))
|
||||
.expect("command output");
|
||||
// Completed bash-mode shows "Run (user)" in execute chrome (not history `#N ! …`).
|
||||
harness
|
||||
.wait_for_text("Run (user)", Duration::from_secs(15))
|
||||
.expect("Run (user) chrome on screen");
|
||||
|
||||
let screen = harness.screen_contents();
|
||||
assert!(
|
||||
!screen.contains("panicked"),
|
||||
"pager panicked\nscreen:\n{screen}"
|
||||
);
|
||||
assert!(
|
||||
screen.contains("STRIP_CD_OK"),
|
||||
"expected command output on screen:\n{screen}"
|
||||
);
|
||||
// Line-oriented asserts avoid byte-slicing box-drawing UI (char boundary panics).
|
||||
// History `#N ! cd …` keeps the typed command; only Run (user) chrome is peeled.
|
||||
let run_lines: Vec<&str> = screen
|
||||
.lines()
|
||||
.filter(|line| line.contains("Run (user)"))
|
||||
.collect();
|
||||
assert!(
|
||||
!run_lines.is_empty(),
|
||||
"Run (user) chrome missing on screen:\n{screen}"
|
||||
);
|
||||
assert!(
|
||||
run_lines.iter().any(|line| line.contains("printf")),
|
||||
"expected short command (printf) on Run (user) line(s) {run_lines:?}\nfull:\n{screen}"
|
||||
);
|
||||
let noisy_prefix = format!("cd {cwd_str} &&");
|
||||
assert!(
|
||||
run_lines
|
||||
.iter()
|
||||
.all(|line| !line.contains(noisy_prefix.as_str())),
|
||||
"Run chrome still shows redundant cd prefix in {run_lines:?}\nfull:\n{screen}"
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// Two seeded files sharing the typed prefix (equal length, so ranking ties
|
||||
/// break on name and `AAA` is deterministically the top row) — both reach
|
||||
/// the screen only through the Tab-opened dropdown.
|
||||
const FILE_AAA: &str = "SUGGESTAAA.txt";
|
||||
const FILE_BBB: &str = "SUGGESTBBB.txt";
|
||||
/// A seeded shell-history line sharing the same typed prefix. Tab fetches
|
||||
/// are token-only: this row must NEVER surface on a Tab (it would make the
|
||||
/// set mixed and kill terminal-Tab semantics for the file rows).
|
||||
const HISTORY_LINE: &str = "cat SUGGESTHISTROW-42";
|
||||
const HISTORY_SENTINEL: &str = "SUGGESTHISTROW";
|
||||
/// What the mocked user types: `!` flips into bash mode (consumed by the
|
||||
/// prompt), then the shared prefix of both files — exactly their LCP, so
|
||||
/// the first Tab opens the dropdown instead of prefix-filling.
|
||||
const TYPED_PREFIX: &str = "!cat SUGGEST";
|
||||
|
||||
/// Env: NO suggestion flag — Tab completion in bash mode is always on, and
|
||||
/// this test is the acceptance proof. `KIGI_SUGGESTIONS=0` pins the
|
||||
/// as-you-type pipeline OFF hermetically (the PTY child inherits the parent
|
||||
/// env, so a dev shell exporting the flag must not turn it on here); the
|
||||
/// shell-history tier is pinned to the seeded file.
|
||||
fn suggestions_env(content: &ContentController, histfile: &Path) -> Vec<(String, String)> {
|
||||
let mut env = content.env_for_pager();
|
||||
env.push(("SHELL".into(), "/bin/bash".into()));
|
||||
env.push(("KIGI_SUGGESTIONS".into(), "0".into()));
|
||||
env.push(("HISTFILE".into(), histfile.to_string_lossy().into_owned()));
|
||||
env
|
||||
}
|
||||
|
||||
fn seed_history(content: &ContentController) -> std::path::PathBuf {
|
||||
let histfile = content.home().join(".bash_history");
|
||||
std::fs::write(&histfile, format!("{HISTORY_LINE}\n")).expect("seed shell history");
|
||||
histfile
|
||||
}
|
||||
|
||||
/// **Bash-mode completion acceptance with NO env flag: Tab fetches
|
||||
/// token-only candidates and opens the dropdown, Tab accepts the selected
|
||||
/// item in place.** In a real session with a seeded cwd and history, typing
|
||||
/// `!cat SUGGEST`:
|
||||
/// - nothing completes before Tab (no as-you-type pipeline without the
|
||||
/// env flag);
|
||||
/// - Tab fires the deterministic fetch; when its candidates land, the
|
||||
/// dropdown opens with BOTH file rows (their LCP equals the typed token,
|
||||
/// so no fill) — and the prefix-matching history line stays out (Tab
|
||||
/// fetches run only the token providers);
|
||||
/// - Down + Tab accepts the second item into the prompt without clobbering
|
||||
/// it: a trailing typed char composes with the spliced token, and the
|
||||
/// other candidate vanishes with the closed dropdown.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
#[cfg(unix)]
|
||||
async fn bash_mode_tab_accepts_dropdown_item_in_place() {
|
||||
let project = tempfile::tempdir().expect("create project dir");
|
||||
std::fs::create_dir_all(project.path().join(".git")).expect("create .git");
|
||||
std::fs::write(project.path().join(FILE_AAA), "").expect("seed AAA file");
|
||||
std::fs::write(project.path().join(FILE_BBB), "").expect("seed BBB file");
|
||||
let cwd = dunce::canonicalize(project.path()).expect("canonicalize project");
|
||||
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} session up."));
|
||||
let histfile = seed_history(&content);
|
||||
|
||||
let env = suggestions_env(&content, &histfile);
|
||||
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness = PtyHarness::new_in_dir(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&[],
|
||||
&env_refs,
|
||||
Some(&cwd),
|
||||
)
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
|
||||
// Establish a session: bash mode lives on the agent-view prompt (the
|
||||
// welcome prompt never completes).
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("start session");
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("session ready");
|
||||
|
||||
harness
|
||||
.inject_keys(TYPED_PREFIX.as_bytes())
|
||||
.expect("type bash prefix");
|
||||
// No as-you-type pipeline: neither candidate may render before Tab.
|
||||
let screen = harness.screen_contents();
|
||||
assert!(
|
||||
!screen.contains(FILE_AAA) && !screen.contains(FILE_BBB),
|
||||
"nothing may complete before Tab without the env flag:\n{screen}"
|
||||
);
|
||||
|
||||
harness.inject_keys(b"\t").expect("press Tab");
|
||||
// The Tab-armed fetch lands and opens the dropdown with both files.
|
||||
harness
|
||||
.wait_for_text(FILE_AAA, Duration::from_secs(15))
|
||||
.expect("dropdown lists the top file candidate");
|
||||
harness
|
||||
.wait_for_text(FILE_BBB, Duration::from_secs(5))
|
||||
.expect("dropdown lists the second file candidate");
|
||||
let screen = harness.screen_contents();
|
||||
assert!(
|
||||
!screen.contains(HISTORY_SENTINEL),
|
||||
"token-only Tab fetch must not surface history rows:\n{screen}"
|
||||
);
|
||||
|
||||
harness.inject_keys(b"\x1b[B").expect("press Down");
|
||||
harness.inject_keys(b"\t").expect("press Tab to accept");
|
||||
// A trailing char proves the accepted token is real prompt text (the
|
||||
// dropdown never contained this composition).
|
||||
harness.inject_keys(b"Y").expect("type trailing char");
|
||||
harness
|
||||
.wait_for_text(&format!("cat {FILE_BBB}Y"), Duration::from_secs(10))
|
||||
.expect("selected item accepted into the prompt in place");
|
||||
|
||||
let screen = harness.screen_contents();
|
||||
assert!(
|
||||
!screen.contains("panicked"),
|
||||
"pager panicked\nscreen:\n{screen}"
|
||||
);
|
||||
assert!(
|
||||
!screen.contains(FILE_AAA),
|
||||
"the other candidate must vanish with the closed dropdown:\n{screen}"
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// A `!` row queued mid-turn stays real bash under empty-Enter send-now:
|
||||
/// the row is promoted to run NOW as its own bash turn (silent cancel of the
|
||||
/// running turn — no "Turn cancelled by user" marker), never leaking to the
|
||||
/// model as prompt/interjection text and never rendering a "❯ !…" block.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
#[cfg(unix)]
|
||||
async fn bash_queued_mid_turn_drains_as_bash() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_chunk_delay(Some(Duration::from_millis(150)));
|
||||
let step_one = {
|
||||
let mut s = String::from("STEPONE");
|
||||
for i in 0..150 {
|
||||
s.push_str(&format!(" streaming{i}"));
|
||||
}
|
||||
s
|
||||
};
|
||||
content.set_turns([step_one]);
|
||||
|
||||
let project = tempfile::tempdir().expect("create project dir");
|
||||
std::fs::create_dir_all(project.path().join(".git")).expect("create .git");
|
||||
let cwd = dunce::canonicalize(project.path()).expect("canonicalize project");
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness = PtyHarness::spawn_with_content_in_dir(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&content,
|
||||
&[],
|
||||
Some(cwd.as_path()),
|
||||
)
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text("STEPONE", Duration::from_secs(30))
|
||||
.expect("turn 1 streaming");
|
||||
|
||||
// `QBASH_%s_OK` keeps the output sentinel out of the queue-row text.
|
||||
harness
|
||||
.inject_keys(b"!printf 'QBASH_%s_OK\\n' MIDTURN\r")
|
||||
.expect("submit bash-mode command mid-turn");
|
||||
harness
|
||||
.wait_for_text("QBASH_%s_OK", Duration::from_secs(10))
|
||||
.expect("bash command visible as a queued row");
|
||||
|
||||
// Empty Enter is send-now (cancel-and-send): the shell silently cancels
|
||||
// turn 1 and runs the bash row as its own next turn immediately.
|
||||
harness.inject_keys(b"\r").expect("empty Enter send-now");
|
||||
harness
|
||||
.wait_for_text("QBASH_MIDTURN_OK", Duration::from_secs(30))
|
||||
.expect("queued bash command executed now (send-now)");
|
||||
harness
|
||||
.wait_for_text("Run (user)", Duration::from_secs(15))
|
||||
.expect("Run (user) chrome for the promoted bash turn");
|
||||
|
||||
// Bash rows never render a user-prompt block (the execute block IS the
|
||||
// visual entry) — a "❯ !printf…" block would mean the row went to the
|
||||
// model as text instead of executing.
|
||||
assert!(
|
||||
!harness.contains_text("\u{276F} !printf"),
|
||||
"bash row must not render a user-prompt block\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
// The send-now cancel of turn 1 is silent.
|
||||
assert!(
|
||||
!harness.contains_text("Turn cancelled by user"),
|
||||
"send-now cancel must not render a cancelled marker\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
let users = all_user_message_blobs(&content);
|
||||
assert!(
|
||||
!users.iter().any(|u| u.contains("QBASH")),
|
||||
"bash command leaked to the model as a prompt/interjection: {users:#?}"
|
||||
);
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// Regression, Linux-hermetic: under Otty (`TERM_PROGRAM=otty` — the
|
||||
/// only terminal known to deliver macOS IME commits as bracketed paste),
|
||||
/// bracketed text that did not come from the system clipboard must not attach
|
||||
/// the unrelated clipboard image. Any other terminal keeps the historical
|
||||
/// probe behavior, asserted here with a second, TERM_PROGRAM-less spawn. A
|
||||
/// fake `wl-paste`/`wl-copy` pair on `PATH` plays the clipboard; the sibling
|
||||
/// `bracketed_ime_paste_skips_clipboard_image_macos` covers the reported
|
||||
/// agent-prompt surface on a real pasteboard.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn bracketed_ime_paste_skips_clipboard_image_linux() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
const IME_PAYLOAD: &str = "中文";
|
||||
const CAPTION: &str = "CLIPCAPTION42";
|
||||
|
||||
let tmp = tempfile::tempdir().expect("tempdir for fake clipboard tools");
|
||||
let bin_dir = tmp.path().join("bin");
|
||||
std::fs::create_dir_all(&bin_dir).expect("mkdir fake bin");
|
||||
let text_file = tmp.path().join("clipboard_text");
|
||||
let png_file = kigi_pager_pty_harness::host_clipboard::write_fixture_png(tmp.path())
|
||||
.expect("write clipboard png fixture");
|
||||
|
||||
std::fs::write(&text_file, b"").expect("empty clipboard text");
|
||||
|
||||
let wl_paste = format!(
|
||||
"#!/bin/sh\ncase \"$*\" in\n *image/png*) cat '{png}' ;;\n *text*) cat '{txt}' ;;\n *) exit 0 ;;\nesac\n",
|
||||
png = png_file.display(),
|
||||
txt = text_file.display(),
|
||||
);
|
||||
let wl_copy = "#!/bin/sh\nexit 0\n";
|
||||
for (name, body) in [("wl-paste", wl_paste.as_str()), ("wl-copy", wl_copy)] {
|
||||
let path = bin_dir.join(name);
|
||||
std::fs::write(&path, body).expect("write fake tool");
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
|
||||
.expect("chmod fake tool");
|
||||
}
|
||||
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} ime paste turn."));
|
||||
|
||||
let path_env = format!(
|
||||
"{}:{}",
|
||||
bin_dir.display(),
|
||||
std::env::var("PATH").unwrap_or_default()
|
||||
);
|
||||
let base_env: Vec<(String, String)> = {
|
||||
let mut env = content.env_for_pager();
|
||||
env.push(("PATH".into(), path_env));
|
||||
env.push(("WAYLAND_DISPLAY".into(), "wayland-fake".into()));
|
||||
env.push(("DISPLAY".into(), String::new()));
|
||||
env
|
||||
};
|
||||
|
||||
/// Spawn the pager with `extra_env` and drive it to the dashboard, where
|
||||
/// bracketed paste routes to the dispatch input.
|
||||
fn spawn_on_dashboard(base_env: &[(String, String)], extra_env: &[(&str, &str)]) -> PtyHarness {
|
||||
let mut env_refs: Vec<(&str, &str)> = base_env
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.as_str()))
|
||||
.collect();
|
||||
env_refs.extend_from_slice(extra_env);
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness =
|
||||
PtyHarness::new_in_dir(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs, None)
|
||||
.expect("spawn pager");
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("turn rendered (idle session)");
|
||||
harness
|
||||
.inject_keys(b"\x1b[92;5u")
|
||||
.expect("ctrl+\\ open dashboard");
|
||||
harness
|
||||
.wait_for_text("+ New Agent", Duration::from_secs(10))
|
||||
.expect("dashboard opens");
|
||||
harness
|
||||
}
|
||||
|
||||
// ── Otty: IME-style bracketed paste, image-only clipboard → no image ──
|
||||
let mut harness = spawn_on_dashboard(&base_env, &[("TERM_PROGRAM", "otty")]);
|
||||
harness
|
||||
.inject_keys(format!("\x1b[200~{IME_PAYLOAD}\x1b[201~").as_bytes())
|
||||
.expect("bracketed IME payload");
|
||||
harness
|
||||
.wait_for_text(IME_PAYLOAD, Duration::from_secs(10))
|
||||
.expect("IME text reaches the dispatch input");
|
||||
harness.update(Duration::from_millis(500));
|
||||
assert!(
|
||||
!harness.contains_text("[Image #"),
|
||||
"under Otty, IME-committed text must NOT attach the clipboard image\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
// ── Otty (positive control): payload == clipboard caption → image ──
|
||||
std::fs::write(&text_file, CAPTION.as_bytes()).expect("genuine-paste clipboard text");
|
||||
harness
|
||||
.inject_keys(format!("\x1b[200~{CAPTION}\x1b[201~").as_bytes())
|
||||
.expect("bracketed genuine clipboard payload");
|
||||
harness
|
||||
.wait_for_text("[Image #1", Duration::from_secs(10))
|
||||
.expect(
|
||||
"a genuine clipboard paste (payload matches clipboard text) must still \
|
||||
attach the clipboard image — the probe path must stay live",
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
harness.quit().expect("clean quit");
|
||||
|
||||
// ── No TERM_PROGRAM (any other terminal): historical behavior intact —
|
||||
// the same mismatched bracketed payload still attaches the image ──
|
||||
std::fs::write(&text_file, b"").expect("reset clipboard text");
|
||||
let mut harness = spawn_on_dashboard(&base_env, &[]);
|
||||
harness
|
||||
.inject_keys(format!("\x1b[200~{IME_PAYLOAD}\x1b[201~").as_bytes())
|
||||
.expect("bracketed payload without otty");
|
||||
harness
|
||||
.wait_for_text("[Image #1", Duration::from_secs(10))
|
||||
.expect(
|
||||
"outside Otty the payload-origin gate must not run — the historical \
|
||||
bracketed-paste image probe attaches the clipboard image unchanged",
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// Regression, macOS-only, REAL host pasteboard — the exact reported
|
||||
/// surface: under Otty (`TERM_PROGRAM=otty`, the only terminal known to
|
||||
/// deliver macOS IME commits as bracketed paste), with an image on the
|
||||
/// clipboard, an IME commit must not attach that image to the agent prompt.
|
||||
///
|
||||
/// Skips (loudly) when the session has no usable clipboard — a CI runner
|
||||
/// without a pasteboard shouldn't fail on environment.
|
||||
///
|
||||
/// WARNING: this test OVERWRITES the machine-global clipboard with an image.
|
||||
/// A prior TEXT clipboard is restored on exit; a prior IMAGE cannot be.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
#[serial_test::serial(host_clipboard)]
|
||||
async fn bracketed_ime_paste_skips_clipboard_image_macos() {
|
||||
use kigi_pager_pty_harness::host_clipboard::{
|
||||
HostClipboardTextGuard, clipboard_roundtrip_works, set_clipboard_png, write_fixture_png,
|
||||
};
|
||||
|
||||
const IME_PAYLOAD: &str = "中文";
|
||||
|
||||
// Guard FIRST: the roundtrip check overwrites the clipboard, and a guard
|
||||
// taken after it would restore the nonce instead of the user's clipboard.
|
||||
let _restore = HostClipboardTextGuard::save();
|
||||
if !clipboard_roundtrip_works() {
|
||||
eprintln!(
|
||||
"SKIP bracketed_ime_paste_skips_clipboard_image_macos: host clipboard \
|
||||
roundtrip failed (no usable clipboard in this session)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let tmp = tempfile::tempdir().expect("tempdir for the clipboard PNG");
|
||||
let png = write_fixture_png(tmp.path()).expect("write clipboard png fixture");
|
||||
set_clipboard_png(&png).expect("put the fixture PNG on the host pasteboard");
|
||||
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
// The payload-origin gate only runs under Otty (TERM_PROGRAM=otty).
|
||||
let mut env = content.env_for_pager();
|
||||
env.push(("TERM_PROGRAM".into(), "otty".into()));
|
||||
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
let mut harness =
|
||||
PtyHarness::new_in_dir(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs, None)
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
|
||||
harness
|
||||
.inject_keys(format!("\x1b[200~{IME_PAYLOAD}\x1b[201~").as_bytes())
|
||||
.expect("bracketed IME payload");
|
||||
|
||||
harness
|
||||
.wait_for_text(IME_PAYLOAD, Duration::from_secs(10))
|
||||
.expect("IME text echoes in the agent prompt");
|
||||
|
||||
// Settle long enough for the deferred clipboard probe to resolve.
|
||||
harness.update(Duration::from_secs(3));
|
||||
assert!(
|
||||
!harness.contains_text("[Image #"),
|
||||
"IME-committed text must NOT attach the clipboard image\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// **Campaign soft default-model nudge — overrides the config default for new
|
||||
/// sessions, and an explicit `/model` pick dismisses it (the user wins).**
|
||||
///
|
||||
/// - boot with a `[models].default` in config.toml plus a campaign (via the
|
||||
/// override env, the remote-equivalent injection) nudging a *different* model
|
||||
/// → the welcome screen shows the **campaign** model;
|
||||
/// - start a session and pick the config model via `/model` → the campaign id is
|
||||
/// recorded dismissed in `campaigns_state.json`;
|
||||
/// - reboot with the *same* campaign env → the welcome shows the **config**
|
||||
/// model, proving the dismissal persisted (an explicit pick beats the nudge).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn campaign_nudges_default_until_dismissed_by_model_pick() {
|
||||
const CONFIG_MODEL: &str = "config-model";
|
||||
const CAMPAIGN_MODEL: &str = "campaign-model";
|
||||
const CAMPAIGN_ID: &str = "e2e-nudge";
|
||||
|
||||
let content = ContentController::start_with_models(vec![
|
||||
MockModel::new(CONFIG_MODEL),
|
||||
MockModel::new(CAMPAIGN_MODEL),
|
||||
])
|
||||
.await
|
||||
.expect("start content with two models");
|
||||
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} ok."));
|
||||
|
||||
// Seed config.toml with the user's own default model.
|
||||
let kigi_home = content.home().join(".kigi");
|
||||
std::fs::create_dir_all(&kigi_home).expect("create KIGI_SHARE_DIR");
|
||||
std::fs::write(
|
||||
kigi_home.join("config.toml"),
|
||||
format!("[models]\ndefault = \"{CONFIG_MODEL}\"\n"),
|
||||
)
|
||||
.expect("write config.toml");
|
||||
|
||||
// The campaign (remote-equivalent) nudges new sessions to CAMPAIGN_MODEL.
|
||||
let campaign_env = (
|
||||
"KIGI_CAMPAIGNS_OVERRIDE".to_string(),
|
||||
format!(r#"[{{"id":"{CAMPAIGN_ID}","models":{{"default":"{CAMPAIGN_MODEL}"}}}}]"#),
|
||||
);
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
|
||||
let spawn = |extra: &(String, String)| -> PtyHarness {
|
||||
let mut env = content.env_for_pager();
|
||||
env.push(extra.clone());
|
||||
let env_refs: Vec<(&str, &str)> =
|
||||
env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs).expect("spawn pager")
|
||||
};
|
||||
|
||||
// ── Phase 1: a fresh boot shows the campaign model, not the config one. ──
|
||||
{
|
||||
let mut h = spawn(&campaign_env);
|
||||
h.wait_for_text(CAMPAIGN_MODEL, WELCOME_TIMEOUT)
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"welcome should show the campaign-nudged model\nscreen:\n{}",
|
||||
h.screen_contents()
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
!h.contains_text("panicked"),
|
||||
"pager panicked\n{}",
|
||||
h.screen_contents()
|
||||
);
|
||||
h.quit().expect("clean quit");
|
||||
}
|
||||
|
||||
// ── Phase 2: a session + explicit `/model` pick dismisses the campaign. ──
|
||||
{
|
||||
let mut h = spawn(&campaign_env);
|
||||
h.wait_for_text(CAMPAIGN_MODEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome shows campaign model");
|
||||
h.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
h.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("response rendered");
|
||||
// Explicit pick of the config model -> persists default + dismisses campaign.
|
||||
h.inject_keys(format!("/model {CONFIG_MODEL}\r").as_bytes())
|
||||
.expect("pick model");
|
||||
|
||||
// Deterministically wait for the dismiss to land on disk.
|
||||
let state_path = kigi_home.join("campaigns_state.json");
|
||||
let deadline = Instant::now() + Duration::from_secs(15);
|
||||
loop {
|
||||
h.update(Duration::from_millis(200));
|
||||
let dismissed = std::fs::read_to_string(&state_path)
|
||||
.map(|s| s.contains(CAMPAIGN_ID))
|
||||
.unwrap_or(false);
|
||||
if dismissed {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"campaign id should be recorded dismissed in {state_path:?}\nscreen:\n{}",
|
||||
h.screen_contents()
|
||||
);
|
||||
}
|
||||
h.quit().expect("clean quit");
|
||||
}
|
||||
|
||||
// ── Phase 3: reboot with the SAME campaign env -> the config model wins. ──
|
||||
{
|
||||
let mut h = spawn(&campaign_env);
|
||||
h.wait_for_text(CONFIG_MODEL, WELCOME_TIMEOUT)
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"after dismissal the welcome must show the config model\nscreen:\n{}",
|
||||
h.screen_contents()
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
!h.contains_text(CAMPAIGN_MODEL),
|
||||
"a dismissed campaign must not re-nudge the model\nscreen:\n{}",
|
||||
h.screen_contents()
|
||||
);
|
||||
h.quit().expect("clean quit");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// **Campaign nudge via the real remote path** — the production source
|
||||
/// (`GET /v1/settings` → `RemoteSettings.campaigns` → process cache seed →
|
||||
/// apply/dismiss), unlike the sibling test which injects the campaign through
|
||||
/// `KIGI_CAMPAIGNS_OVERRIDE`.
|
||||
///
|
||||
/// - boot with a `[models].default` in config.toml plus a **server-served**
|
||||
/// campaign nudging a *different* model → a (possibly not first — see
|
||||
/// [`wait_for_model_via_new_sessions`]) new session opens on the
|
||||
/// **campaign** model;
|
||||
/// - pick the config model via `/model` → the remote campaign id is recorded
|
||||
/// dismissed in `campaigns_state.json`;
|
||||
/// - reboot against the *same* server settings → the **config** model wins
|
||||
/// and stays winning across `/new`.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
|
||||
async fn campaign_remote_settings_nudge_and_dismiss() {
|
||||
const CONFIG_MODEL: &str = "config-model";
|
||||
const CAMPAIGN_MODEL: &str = "campaign-model";
|
||||
const CAMPAIGN_ID: &str = "e2e-remote-nudge";
|
||||
|
||||
let content = ContentController::start_with_models(vec![
|
||||
MockModel::new(CONFIG_MODEL),
|
||||
MockModel::new(CAMPAIGN_MODEL),
|
||||
])
|
||||
.await
|
||||
.expect("start content with two models");
|
||||
|
||||
// Serve the campaign from the settings endpoint (replaces the preset, so
|
||||
// `allow_access` must be restated or the pager parks on the upsell screen).
|
||||
content.server().set_settings(json!({
|
||||
"allow_access": true,
|
||||
"campaigns": [
|
||||
{ "id": CAMPAIGN_ID, "models": { "default": CAMPAIGN_MODEL } }
|
||||
]
|
||||
}));
|
||||
|
||||
// Seed config.toml with the user's own default model.
|
||||
let kigi_home = content.home().join(".kigi");
|
||||
std::fs::create_dir_all(&kigi_home).expect("create KIGI_SHARE_DIR");
|
||||
std::fs::write(
|
||||
kigi_home.join("config.toml"),
|
||||
format!("[models]\ndefault = \"{CONFIG_MODEL}\"\n"),
|
||||
)
|
||||
.expect("write config.toml");
|
||||
|
||||
// Session (OAuth) auth, not the harness's default XAI_API_KEY: the
|
||||
// settings fetch requires `auth_manager.auth()` — in ApiKey/BYOK mode the
|
||||
// pager never requests `/v1/settings`, so a remote campaign would be
|
||||
// structurally unreachable (see `spawn_polling_session`'s doc).
|
||||
seed_fake_oauth(&content, "pty-campaign-remote");
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let env = oauth_env_for_pager(&content);
|
||||
let spawn = || -> PtyHarness {
|
||||
let env_refs: Vec<(&str, &str)> =
|
||||
env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs).expect("spawn pager")
|
||||
};
|
||||
|
||||
// ── Phase 1+2: the campaign applies to a new session; a pick dismisses. ──
|
||||
{
|
||||
let mut h = spawn();
|
||||
h.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome renders");
|
||||
assert!(
|
||||
wait_for_model_via_new_sessions(&mut h, CAMPAIGN_MODEL, Duration::from_secs(60)),
|
||||
"a new session should open on the remote campaign model\nscreen:\n{}",
|
||||
h.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
!h.contains_text("panicked"),
|
||||
"pager panicked\n{}",
|
||||
h.screen_contents()
|
||||
);
|
||||
|
||||
// Explicit pick of the config model → persists default + dismisses.
|
||||
h.inject_keys(format!("/model {CONFIG_MODEL}\r").as_bytes())
|
||||
.expect("pick model");
|
||||
|
||||
// Deterministically wait for the dismiss to land on disk.
|
||||
let state_path = kigi_home.join("campaigns_state.json");
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
loop {
|
||||
h.update(Duration::from_millis(200));
|
||||
let dismissed = std::fs::read_to_string(&state_path)
|
||||
.map(|s| s.contains(CAMPAIGN_ID))
|
||||
.unwrap_or(false);
|
||||
if dismissed {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"remote campaign id should be recorded dismissed in {state_path:?}\nscreen:\n{}",
|
||||
h.screen_contents()
|
||||
);
|
||||
}
|
||||
h.quit().expect("clean quit");
|
||||
}
|
||||
|
||||
// ── Phase 3: reboot against the SAME settings → the config model wins. ──
|
||||
{
|
||||
let mut h = spawn();
|
||||
h.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome renders after reboot");
|
||||
h.wait_for_text(CONFIG_MODEL, Duration::from_secs(20))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"after dismissal the config model must show\nscreen:\n{}",
|
||||
h.screen_contents()
|
||||
)
|
||||
});
|
||||
// Give the settings fetch time to land, then prove a fresh session
|
||||
// still resolves to the user's model (dismissed campaigns never
|
||||
// re-apply, even once the remote campaign is in the cache).
|
||||
let _ = h.inject_keys(b"/new\r");
|
||||
h.update(Duration::from_millis(4000));
|
||||
h.wait_for_text(CONFIG_MODEL, Duration::from_secs(10))
|
||||
.expect("config model after post-fetch /new");
|
||||
assert!(
|
||||
!h.contains_text(CAMPAIGN_MODEL),
|
||||
"a dismissed remote campaign must not re-nudge the model\nscreen:\n{}",
|
||||
h.screen_contents()
|
||||
);
|
||||
h.quit().expect("clean quit");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// 22. **Send-now delivery vs. explicit cancel.** (Historical name: the chord
|
||||
/// used to buffer a same-turn interjection that a cancel could discard.)
|
||||
/// The chord is now cancel-and-send: text + Ctrl+Enter mid-stream silently
|
||||
/// cancels the running turn and delivers the text as its OWN next turn (no
|
||||
/// interjection preamble on the wire). A later explicit Ctrl+C still renders
|
||||
/// its "Turn cancelled by user" marker — the consumed send-now expectation
|
||||
/// must never suppress a real user cancel.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn cancel_discards_buffered_interjection() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_chunk_delay(Some(Duration::from_millis(150)));
|
||||
content.set_turns([
|
||||
slow_turn_text("CANCELTURN"),
|
||||
slow_turn_text("STEERTURN"),
|
||||
"FRESHTURN after cancel.".to_owned(),
|
||||
]);
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness =
|
||||
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text("CANCELTURN", Duration::from_secs(30))
|
||||
.expect("turn streaming");
|
||||
|
||||
harness
|
||||
.inject_keys(b"deliver this steer now")
|
||||
.expect("type steering");
|
||||
harness.inject_keys(CTRL_ENTER).expect("send-now chord");
|
||||
// Cancel-and-send: the steer commits as a standard "❯ " prompt block via
|
||||
// the turn-start adoption and runs as its own turn.
|
||||
harness
|
||||
.wait_for_text("\u{276F} deliver this steer now", Duration::from_secs(15))
|
||||
.expect("send-now prompt block");
|
||||
harness
|
||||
.wait_for_text("STEERTURN", Duration::from_secs(30))
|
||||
.expect("steer runs as its own turn");
|
||||
// The send-now half cancelled turn 1 silently.
|
||||
assert!(
|
||||
!harness.contains_text("Turn cancelled by user"),
|
||||
"send-now cancel must not render a cancelled marker\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
// Explicit Ctrl+C on the steer turn (still streaming): a REAL cancel,
|
||||
// whose marker must render — the earlier send-now expectation was
|
||||
// consumed and must not silence it.
|
||||
harness.inject_keys(keys::CTRL_C).expect("cancel turn");
|
||||
harness
|
||||
.wait_for_text("Turn cancelled by user", Duration::from_secs(10))
|
||||
.expect("explicit cancel marker");
|
||||
|
||||
harness
|
||||
.inject_keys(b"fresh prompt\r")
|
||||
.expect("submit fresh prompt");
|
||||
harness
|
||||
.wait_for_text("FRESHTURN", Duration::from_secs(30))
|
||||
.expect("fresh turn response");
|
||||
|
||||
let users = all_user_message_blobs(&content);
|
||||
let steers: Vec<_> = users
|
||||
.iter()
|
||||
.filter(|u| u.contains("deliver this steer now"))
|
||||
.collect();
|
||||
assert!(
|
||||
!steers.is_empty(),
|
||||
"send-now steer never reached the wire: {users:#?}"
|
||||
);
|
||||
for s in &steers {
|
||||
assert!(
|
||||
!s.contains(INTERJECTION_WIRE_PREFIX),
|
||||
"send-now must not use the interjection preamble: {s}"
|
||||
);
|
||||
assert!(
|
||||
s.contains("<user_query>"),
|
||||
"send-now must arrive as a standard user_query prompt: {s}"
|
||||
);
|
||||
}
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// The `cancel_then_send` confounder: submit, Ctrl+C (pristine rewind), then
|
||||
/// Enter to resend the restored text. The prompt must appear EXACTLY ONCE in
|
||||
/// scrollback and in each wire request — the rewound turn's copy must not
|
||||
/// survive in session history and pair with the resend as 2x.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn cancel_then_resend_prompt_appears_once() {
|
||||
const RESEND_PROMPT: &str = "resend me exactly once";
|
||||
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
// Turn 1 is rewound pre-first-token (the 30s pacing guarantees the
|
||||
// pristine window); turn 2 is the resend's reply, streamed after the
|
||||
// pacing is dropped below.
|
||||
content.set_turns([
|
||||
"GONE never streams.".to_owned(),
|
||||
"RESENT_REPLY to the restored prompt.".to_owned(),
|
||||
]);
|
||||
content.set_chunk_delay(Some(Duration::from_secs(30)));
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness =
|
||||
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
harness
|
||||
.inject_keys(format!("{RESEND_PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_until(
|
||||
"prompt block committed and composer cleared",
|
||||
Duration::from_secs(30),
|
||||
|h| block_lines_containing(h, RESEND_PROMPT) == 1 && !composer_holds(h, RESEND_PROMPT),
|
||||
)
|
||||
.expect("prompt block committed");
|
||||
harness
|
||||
.wait_for_text("Waiting for response", Duration::from_secs(25))
|
||||
.expect("turn running pre-first-token");
|
||||
|
||||
harness.inject_keys(keys::CTRL_C).expect("Ctrl+C rewind");
|
||||
// Require the rewound state to *settle*, not just flash true: the pager
|
||||
// clears the scrollback block optimistically, but the shell's trim of the
|
||||
// rewound copy from session history is a separate round-trip. On a slow,
|
||||
// contended runner, resending during that gap pairs the stale copy with
|
||||
// the resend in one wire request (the 2x this test guards). Holding the
|
||||
// rewound state continuously lets the trim land first.
|
||||
harness
|
||||
.wait_until_stable(
|
||||
"rewound prompt restored after session history trim",
|
||||
Duration::from_secs(30),
|
||||
Duration::from_millis(1500),
|
||||
|h| composer_holds(h, RESEND_PROMPT) && block_lines_containing(h, RESEND_PROMPT) == 0,
|
||||
)
|
||||
.expect("rewound prompt restored after history trim");
|
||||
|
||||
// Resend the restored text as a fresh turn.
|
||||
content.set_chunk_delay(None);
|
||||
harness.inject_keys(b"\r").expect("Enter resends");
|
||||
harness
|
||||
.wait_for_text("RESENT_REPLY", Duration::from_secs(90))
|
||||
.expect("resent turn reply");
|
||||
|
||||
// Exactly once in scrollback (block back, composer empty again).
|
||||
harness
|
||||
.wait_until(
|
||||
"resent prompt rendered exactly once",
|
||||
Duration::from_secs(30),
|
||||
|h| block_lines_containing(h, RESEND_PROMPT) == 1 && !composer_holds(h, RESEND_PROMPT),
|
||||
)
|
||||
.expect("resent prompt rendered exactly once");
|
||||
|
||||
// Exactly once per wire request: the rewound copy must have been cut from
|
||||
// session history, so no request pairs a stale copy with the resend.
|
||||
for body in content.request_bodies() {
|
||||
// Chat Completions carries `messages`; the Responses shape `input`.
|
||||
let items = body["messages"]
|
||||
.as_array()
|
||||
.or_else(|| body["input"].as_array());
|
||||
let users: Vec<&serde_json::Value> = items
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|m| {
|
||||
m["role"] == "user"
|
||||
&& m["content"]
|
||||
.as_str()
|
||||
.is_some_and(|c| c.contains(RESEND_PROMPT))
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
users.len() <= 1,
|
||||
"prompt duplicated in one request (stale rewound copy + resend): {body}"
|
||||
);
|
||||
}
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// 16. **`--continue` resumes the latest session.**
|
||||
/// History must render exactly once (duplicate replay and empty pane both
|
||||
/// fail) and the resumed session must accept a follow-up turn.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn continue_resumes_session_with_history() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!("{} first session payload.", turn_sentinel(1)));
|
||||
|
||||
// Sessions are keyed by cwd: both runs must share a stable project dir.
|
||||
let project = tempfile::tempdir().expect("create project dir");
|
||||
std::fs::create_dir_all(project.path().join(".git")).expect("create .git");
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut first = PtyHarness::spawn_with_content_in_dir(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&content,
|
||||
&[],
|
||||
Some(project.path()),
|
||||
)
|
||||
.expect("spawn first pager");
|
||||
|
||||
first
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
first
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit turn 1");
|
||||
first
|
||||
.wait_for_text(&turn_sentinel(1), Duration::from_secs(30))
|
||||
.expect("turn 1 rendered");
|
||||
// Quit via Ctrl+Q double-press: focus is in the prompt, so 'q' would just type.
|
||||
first.update(Duration::from_millis(500));
|
||||
first.inject_keys(b"\x11").expect("ctrl-q once");
|
||||
first.update(Duration::from_millis(200));
|
||||
first.inject_keys(b"\x11").expect("ctrl-q confirm");
|
||||
first.quit().expect("reap first pager");
|
||||
|
||||
let mut resumed = PtyHarness::spawn_with_content_in_dir(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&content,
|
||||
&["--continue"],
|
||||
Some(project.path()),
|
||||
)
|
||||
.expect("spawn resumed pager");
|
||||
|
||||
resumed
|
||||
.wait_for_text(&turn_sentinel(1), WELCOME_TIMEOUT)
|
||||
.expect("history replayed after --continue");
|
||||
let screen = resumed.screen_contents();
|
||||
assert_eq!(
|
||||
screen.matches(&turn_sentinel(1)).count(),
|
||||
1,
|
||||
"turn 1 must appear exactly once after resume\nscreen:\n{screen}"
|
||||
);
|
||||
|
||||
content.set_response(format!("{} resumed session payload.", turn_sentinel(2)));
|
||||
resumed
|
||||
.inject_keys(b"again\r")
|
||||
.expect("submit turn 2 after resume");
|
||||
resumed
|
||||
.wait_for_text(&turn_sentinel(2), Duration::from_secs(30))
|
||||
.expect("turn 2 rendered in resumed session");
|
||||
|
||||
assert!(
|
||||
!resumed.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
resumed.screen_contents()
|
||||
);
|
||||
|
||||
resumed.quit().expect("quit resumed pager");
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// 17. **Ctrl+C-cancel mid-stream recovers cleanly.**
|
||||
///
|
||||
/// Cancelling a streaming turn must (a) surface the "Turn cancelled" marker
|
||||
/// exactly once — the turn end now arrives via BOTH the PromptResponse RPC
|
||||
/// and the `prompt_complete` broadcast (which arms the lost-response
|
||||
/// reconcile), and a double-finish would render two markers — and (b) leave
|
||||
/// the pane usable: no `TurnCancelling` latch, the next typed prompt runs.
|
||||
/// Cancel is via Ctrl+C (Esc no longer cancels mid-turn).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn ctrl_c_cancel_during_stream_recovers_cleanly() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
// A long, paced response keeps the turn visibly streaming while Ctrl+C is
|
||||
// pressed (the mock otherwise dumps all SSE events instantly and the
|
||||
// turn completes before the cancel lands).
|
||||
let long_response = format!(
|
||||
"{MOCK_RESPONSE_SENTINEL} {}",
|
||||
"streaming filler words for the cancellation window. ".repeat(120)
|
||||
);
|
||||
content.set_response(long_response);
|
||||
content.set_chunk_delay(Some(Duration::from_millis(50)));
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness =
|
||||
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("stream started");
|
||||
|
||||
// Ctrl+C on an empty prompt cancels while streaming.
|
||||
harness.inject_keys(keys::CTRL_C).expect("press ctrl+c");
|
||||
harness.update(Duration::from_millis(200));
|
||||
|
||||
harness
|
||||
.wait_for_text("Turn cancelled by user", Duration::from_secs(15))
|
||||
.expect("turn cancelled marker");
|
||||
|
||||
// Settle, then assert the marker rendered exactly once — a double marker
|
||||
// means the broadcast reconcile double-finished the turn alongside the
|
||||
// PromptResponse path.
|
||||
harness.update(Duration::from_millis(1000));
|
||||
let screen = harness.screen_contents();
|
||||
assert_eq!(
|
||||
screen.matches("Turn cancelled by user").count(),
|
||||
1,
|
||||
"'Turn cancelled' must appear exactly once\nscreen:\n{screen}"
|
||||
);
|
||||
|
||||
// Recovery: the pane must accept and run a new prompt (no
|
||||
// TurnCancelling latch, which previously required a restart here).
|
||||
content.set_chunk_delay(None);
|
||||
content.set_response("RECOVERYSENTINEL post-cancel turn ran.");
|
||||
harness
|
||||
.inject_keys(b"run again\r")
|
||||
.expect("submit post-cancel prompt");
|
||||
harness
|
||||
.wait_for_text("RECOVERYSENTINEL", Duration::from_secs(30))
|
||||
.expect("post-cancel turn streamed");
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
|
||||
// The new cancel-path observability must have recorded the cancel
|
||||
// end-to-end in the (isolated) unified log: the agent received the
|
||||
// `session/cancel` notification and processed it against the running
|
||||
// prompt. Absence of these markers was exactly what made the original
|
||||
// incident undiagnosable.
|
||||
let unified_log = content
|
||||
.home()
|
||||
.join(".kigi")
|
||||
.join("logs")
|
||||
.join("unified.jsonl");
|
||||
let unified = std::fs::read_to_string(&unified_log).unwrap_or_default();
|
||||
assert!(
|
||||
unified.contains("\"msg\":\"shell.cancel.received\""),
|
||||
"unified log must record the agent receiving the cancel\nlog path: {}",
|
||||
unified_log.display()
|
||||
);
|
||||
assert!(
|
||||
unified.contains("\"msg\":\"shell.cancel.processing\""),
|
||||
"unified log must record the cancel being processed\nlog path: {}",
|
||||
unified_log.display()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// The no-rewind boundary: once the server has streamed ANY activity, Ctrl+C
|
||||
/// is a standard cancel — the prompt stays a committed "❯ " block (exactly
|
||||
/// once, NOT restored to the composer) and the "Turn cancelled by user"
|
||||
/// marker renders.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn ctrlc_after_activity_no_rewind_prompt_once() {
|
||||
const CANCEL_PROMPT: &str = "cancel after activity";
|
||||
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_chunk_delay(Some(Duration::from_millis(150)));
|
||||
content.set_turns([slow_turn_text("CANCELME")]);
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness =
|
||||
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
harness
|
||||
.inject_keys(format!("{CANCEL_PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
// Server activity on screen — the rewind window is provably closed.
|
||||
harness
|
||||
.wait_for_text("CANCELME", Duration::from_secs(30))
|
||||
.expect("turn streaming");
|
||||
|
||||
harness.inject_keys(keys::CTRL_C).expect("Ctrl+C cancel");
|
||||
harness
|
||||
.wait_for_text("Turn cancelled by user", Duration::from_secs(10))
|
||||
.expect("standard cancel marker");
|
||||
|
||||
// No rewind: the composer stays empty and the committed block stays put,
|
||||
// exactly once.
|
||||
assert!(
|
||||
!composer_holds(&harness, CANCEL_PROMPT),
|
||||
"post-activity cancel must not restore the prompt to the composer\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
assert_eq!(
|
||||
block_lines_containing(&harness, CANCEL_PROMPT),
|
||||
1,
|
||||
"prompt must stay committed exactly once\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// Rewind is SKIPPED when a queued prompt exists: Ctrl+C on the running turn
|
||||
/// is a standard cancel (visible marker, A never mixed into the composer),
|
||||
/// the queued B promotes as the next turn, and each of A/B renders exactly
|
||||
/// once.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn ctrlc_with_queued_prompt_no_dup() {
|
||||
const PROMPT_A: &str = "alpha primary task";
|
||||
const PROMPT_B: &str = "bravo queued follow";
|
||||
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
// Gate turn A's terminal event so the queue + Ctrl+C provably land
|
||||
// mid-turn (the cancel abort beats the held completion).
|
||||
content.hold_agent_completions();
|
||||
content.set_turns([
|
||||
slow_turn_text("ALPHARESP"),
|
||||
"BRAVORESP promoted after cancel.".to_owned(),
|
||||
]);
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness =
|
||||
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT_A}\r").as_bytes())
|
||||
.expect("submit A");
|
||||
harness
|
||||
.wait_for_text("ALPHARESP", Duration::from_secs(45))
|
||||
.expect("A streaming");
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT_B}\r").as_bytes())
|
||||
.expect("queue B mid-turn");
|
||||
harness
|
||||
.wait_for_text(PROMPT_B, Duration::from_secs(20))
|
||||
.expect("B visible as a queued row");
|
||||
|
||||
harness.inject_keys(keys::CTRL_C).expect("Ctrl+C cancel A");
|
||||
content.release_agent_completions();
|
||||
|
||||
// Standard cancel (queued prompts skip the rewind): A is cancelled and B
|
||||
// promotes as the next turn. The "Turn cancelled by user" marker and the
|
||||
// "❯ B" promotion scroll above the viewport once B's start-adoption pins
|
||||
// its block to the head, so gate on B's reply (stable at the head) and
|
||||
// prove correctness via the composer state + the recorded wire below.
|
||||
harness
|
||||
.wait_for_text("BRAVORESP", Duration::from_secs(90))
|
||||
.expect("B promoted and replied");
|
||||
|
||||
// A stayed a committed block — never mixed into the composer.
|
||||
assert!(
|
||||
!composer_holds(&harness, PROMPT_A),
|
||||
"cancel with a queued prompt must not restore A to the composer\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
// No duplication: A (cancelled) and B (promoted) each reach the wire
|
||||
// exactly once in the final request — A is not interrupt-dup'd and B is not
|
||||
// double-sent. A visible-line count is unreliable here (A scrolls above the
|
||||
// viewport on B's adoption), so assert against the wire.
|
||||
let bodies = content.request_bodies();
|
||||
let last = bodies.last().expect("final request recorded");
|
||||
let user_queries: Vec<String> = last["messages"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|m| m["role"] == "user")
|
||||
.filter_map(|m| m["content"].as_str())
|
||||
.filter(|c| c.contains("<user_query>"))
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
let wire_count = |needle: &str| user_queries.iter().filter(|c| c.contains(needle)).count();
|
||||
assert_eq!(
|
||||
wire_count(PROMPT_A),
|
||||
1,
|
||||
"A must reach the wire exactly once (no interrupt dup): {user_queries:#?}"
|
||||
);
|
||||
assert_eq!(
|
||||
wire_count(PROMPT_B),
|
||||
1,
|
||||
"B must reach the wire exactly once: {user_queries:#?}"
|
||||
);
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// Ctrl+\ (OpenDashboard). crossterm maps the raw 0x1c byte to Ctrl+4, so the
|
||||
/// universal dashboard chord must be sent as the kitty CSI-u form: code 92
|
||||
/// (`\`), modifier 5 (Ctrl). Mirrors `CTRL_ENTER` / `CTRL_SEMICOLON` in common.
|
||||
const CTRL_BACKSLASH: &[u8] = b"\x1b[92;5u";
|
||||
|
||||
/// Attach the (only) agent row as a session overlay from the dashboard list.
|
||||
/// Down clamps at the last focusable, so three Downs land on the row regardless
|
||||
/// of the current cursor (fresh open = New Agent button; after a back-out =
|
||||
/// previously-selected row); Enter then attaches the peeked row. Waits until the
|
||||
/// overlay is up: the dashboard list ("+ New Agent") is gone and the agent's
|
||||
/// transcript (MOCKRESPONSE) is shown.
|
||||
fn attach_overlay(h: &mut PtyHarness) {
|
||||
for _ in 0..3 {
|
||||
h.inject_keys(keys::DOWN).expect("down to row");
|
||||
h.update(Duration::from_millis(200));
|
||||
}
|
||||
h.inject_keys(keys::ENTER).expect("attach row");
|
||||
wait_for_labels_absent(h, &["+ New Agent"], Duration::from_secs(10));
|
||||
h.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(10))
|
||||
.expect("attached the original agent as an overlay");
|
||||
assert!(
|
||||
!h.contains_text("+ New Agent"),
|
||||
"attach must leave the dashboard list for the agent overlay\nscreen:\n{}",
|
||||
h.screen_contents()
|
||||
);
|
||||
}
|
||||
|
||||
/// Dashboard-overlay back-out. Attaching a
|
||||
/// session lands on the default Prompt focus, so every keyboard back-out path
|
||||
/// must work and the user must never be trapped:
|
||||
/// - **Ctrl+\** opens the dashboard from a session (and from inside the overlay);
|
||||
/// - **empty-prompt Esc** backs out;
|
||||
/// - **Left on an empty prompt** backs out;
|
||||
/// - **a drafted-prompt Esc** does NOT back out — it arms "press again to clear";
|
||||
/// - **Tab then a neutral scrollback Esc** backs out.
|
||||
/// Each back-out is re-verified against a freshly re-attached overlay.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn dashboard_overlay_tab_esc_backout_and_ctrl_backslash() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} dashboard overlay turn."));
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness =
|
||||
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("turn rendered (idle session)");
|
||||
|
||||
// Ctrl+\ opens the dashboard from inside a session (universal back-out).
|
||||
harness
|
||||
.inject_keys(CTRL_BACKSLASH)
|
||||
.expect("ctrl+\\ open dashboard");
|
||||
harness
|
||||
.wait_for_text("+ New Agent", Duration::from_secs(10))
|
||||
.expect("Ctrl+\\ opens the dashboard");
|
||||
|
||||
// ── (c) Drafted-prompt Esc must NOT back out (arms "press again to clear")
|
||||
// and (d) empty-prompt Esc backs out. Overlay lands on Prompt.
|
||||
attach_overlay(&mut harness);
|
||||
let draft = "OVLDRAFT";
|
||||
harness.inject_keys(draft.as_bytes()).expect("type draft");
|
||||
harness
|
||||
.wait_for_text(draft, Duration::from_secs(10))
|
||||
.expect("draft renders in the overlay prompt");
|
||||
harness.inject_keys(keys::ESC).expect("esc with draft");
|
||||
harness.update(Duration::from_millis(300));
|
||||
assert!(
|
||||
harness.contains_text("press again to clear"),
|
||||
"a drafted overlay prompt Esc must arm clear, not back out\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("+ New Agent"),
|
||||
"a drafted overlay prompt Esc must NOT return to the dashboard\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
// Wipe the draft (Ctrl+U clears the armed pending then kills the line).
|
||||
harness.inject_keys(b"\x15").expect("ctrl+u clear draft");
|
||||
wait_for_labels_absent(&mut harness, &[draft], Duration::from_secs(5));
|
||||
// (d) Empty-prompt Esc now backs out to the dashboard.
|
||||
harness.inject_keys(keys::ESC).expect("empty-prompt esc");
|
||||
harness
|
||||
.wait_for_text("+ New Agent", Duration::from_secs(10))
|
||||
.expect("empty-prompt overlay Esc must back out to the dashboard");
|
||||
|
||||
// ── (a) Left on an empty prompt backs out. (Left arrow = CSI D.)
|
||||
attach_overlay(&mut harness);
|
||||
harness
|
||||
.inject_keys(b"\x1b[D")
|
||||
.expect("left on empty prompt");
|
||||
harness
|
||||
.wait_for_text("+ New Agent", Duration::from_secs(10))
|
||||
.expect("Left on an empty overlay prompt must back out to the dashboard");
|
||||
|
||||
// ── (b) Ctrl+\ from inside the overlay backs out.
|
||||
attach_overlay(&mut harness);
|
||||
harness
|
||||
.inject_keys(CTRL_BACKSLASH)
|
||||
.expect("ctrl+\\ from overlay");
|
||||
harness
|
||||
.wait_for_text("+ New Agent", Duration::from_secs(10))
|
||||
.expect("Ctrl+\\ from inside the overlay must back out to the dashboard");
|
||||
|
||||
// ── Tab then a neutral scrollback Esc backs out (bare-scrollback path).
|
||||
attach_overlay(&mut harness);
|
||||
harness.inject_keys(b"\t").expect("tab to scrollback");
|
||||
harness.update(Duration::from_millis(400));
|
||||
harness
|
||||
.inject_keys(keys::ESC)
|
||||
.expect("neutral esc back-out");
|
||||
harness
|
||||
.wait_for_text("+ New Agent", Duration::from_secs(10))
|
||||
.expect("Tab then neutral Esc returns to the dashboard list");
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Doubled lines — regression guard via a simulated
|
||||
//! out-of-band screen reflow.
|
||||
//!
|
||||
//! The stack is `tmux -> nvim :terminal -> grok`: nvim/tmux repaint grok's
|
||||
//! pane out-of-band (no grok PTY resize), and grok's diff renderer only
|
||||
//! re-clears on a real size change — so the rows it doesn't own survive and
|
||||
//! you get doubled lines at the top/bottom until restart.
|
||||
//!
|
||||
//! The harness is a single faithful emulator and can't nest a real tmux/nvim,
|
||||
//! so we SIMULATE the out-of-band reflow with `feed_screen` (writes straight
|
||||
//! into the virtual terminal, bypassing grok) and then check whether grok
|
||||
//! heals it. `NVIM` is set in grok's env so it sees the embedded-editor
|
||||
//! context the fix keys off (mirroring a real nvim `:terminal`).
|
||||
//!
|
||||
//! The fix: grok forces a full clear+repaint on `FocusGained` in
|
||||
//! editor/multiplexer contexts, so the injected row is gone after refocus.
|
||||
//! The final assertion guards that heal.
|
||||
|
||||
use super::common::*;
|
||||
|
||||
/// Unique sentinel that grok would never render on its own.
|
||||
const STALE_MARKER: &str = "STALE_OUT_OF_BAND_ROW_ZZZ";
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore] // opt-in PTY e2e (run with `-- --ignored`)
|
||||
async fn out_of_band_stale_row_heals_on_focus_gained() {
|
||||
let content = ContentController::start()
|
||||
.await
|
||||
.expect("start mock content");
|
||||
|
||||
// Mock-auth env + pretend we're inside a neovim `:terminal` (sets the
|
||||
// embedded-editor context the doubled-line fix gates on).
|
||||
let mut env = content.env_for_pager();
|
||||
env.push(("NVIM".into(), "/tmp/grok-pty-harness-fake-nvim.sock".into()));
|
||||
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut h =
|
||||
PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs).expect("spawn pager");
|
||||
|
||||
h.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome screen");
|
||||
// Let the initial draws land before injecting. The Welcome logo shimmers, so
|
||||
// the screen never goes fully frame-idle, but the col-1 marker below is safe.
|
||||
h.update(Duration::from_millis(800));
|
||||
assert!(
|
||||
!h.contains_text(STALE_MARKER),
|
||||
"marker must be absent before injection"
|
||||
);
|
||||
|
||||
// Simulate the out-of-band reflow: write a stale row straight into the
|
||||
// virtual screen at col 1 — the static left margin grok's diff renderer
|
||||
// doesn't repaint during the logo shimmer. The heal is a full clear, so
|
||||
// removal is reliable. `\x1b[<row>;<col>H` is 1-based cursor positioning.
|
||||
h.feed_screen(format!("\x1b[6;1H{STALE_MARKER}").as_bytes());
|
||||
assert!(
|
||||
h.contains_text(STALE_MARKER),
|
||||
"marker should be on the virtual screen right after injection"
|
||||
);
|
||||
|
||||
// grok must not self-heal out-of-band content via ordinary diff redraws:
|
||||
// it only rewrites cells whose own model changed, so this row is stranded.
|
||||
h.update(Duration::from_millis(300));
|
||||
assert!(
|
||||
h.contains_text(STALE_MARKER),
|
||||
"stale row should survive a normal redraw (grok's diff renderer doesn't own it)\nscreen:\n{}",
|
||||
h.screen_contents()
|
||||
);
|
||||
|
||||
// A FocusGained (CSI I) forces a full clear+repaint that re-asserts grok's
|
||||
// whole screen and removes the out-of-band row.
|
||||
h.inject_keys(b"\x1b[I").expect("inject FocusGained");
|
||||
// Poll for the heal instead of a fixed settle so host load can't flake it.
|
||||
wait_for_labels_absent(&mut h, &[STALE_MARKER], Duration::from_secs(5));
|
||||
assert!(
|
||||
!h.contains_text(STALE_MARKER),
|
||||
"FocusGained should force a full repaint and clear the out-of-band row \
|
||||
(regression guard for the doubled-line bug)\nscreen:\n{}",
|
||||
h.screen_contents()
|
||||
);
|
||||
|
||||
h.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
#[allow(unused_imports)]
|
||||
use super::scroll::*;
|
||||
|
||||
/// PTY: drag-autoscroll must not bounce. Over tall content scrolled off the
|
||||
/// bottom, a drag held past the pane's bottom edge autoscrolls down; the
|
||||
/// topmost visible marker, sampled every ~100ms, must be monotonically
|
||||
/// non-decreasing (never a regression to an earlier marker = no direction
|
||||
/// flip, no offset jitter) and must settle back at the bottom clamp.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
|
||||
async fn drag_autoscroll_no_bounce_pty() {
|
||||
let (mut harness, _content, baseline_topmost) = spawn_bottom_pinned_marker_scrollback(60).await;
|
||||
|
||||
// Wheel up (back-to-back reports classify as wheel) so the autoscroll
|
||||
// has room to move back down to the clamp.
|
||||
let mut wheel = String::new();
|
||||
for _ in 0..12 {
|
||||
wheel.push_str(&sgr_mouse(SGR_SCROLL_UP, WHEEL_ROW, WHEEL_COL, 'M'));
|
||||
}
|
||||
harness.inject_keys(wheel.as_bytes()).expect("wheel up");
|
||||
harness.update(Duration::from_millis(600));
|
||||
let scrolled_topmost = topmost_visible_marker(&harness).expect("markers visible");
|
||||
assert!(
|
||||
scrolled_topmost + 3 <= baseline_topmost,
|
||||
"setup: wheel-up must scroll up (topmost {scrolled_topmost} vs baseline {baseline_topmost})\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
// Press on a visible marker line (text anchor), then drag to the strip
|
||||
// row above the prompt box — past the pane's bottom edge — and HOLD.
|
||||
let screen = harness.screen_contents();
|
||||
let marker_text = format!("MARKER-{:04}", scrolled_topmost + 3);
|
||||
let (press_row, press_col) = locate_screen_text(&screen, &marker_text)
|
||||
.unwrap_or_else(|| panic!("could not locate {marker_text:?}; screen:\n{screen}"));
|
||||
let (placeholder_row, _) = locate_screen_text(&screen, "Build anything")
|
||||
.unwrap_or_else(|| panic!("could not locate the prompt placeholder; screen:\n{screen}"));
|
||||
let hold_row = placeholder_row - 2;
|
||||
assert!(hold_row > press_row, "setup: hold point below the press");
|
||||
|
||||
// Two motion samples, as any real drag emits: the first promotes the
|
||||
// pending drag, the second (at the held position) arms the autoscroll —
|
||||
// promotion itself deliberately arms nothing.
|
||||
let mut drag = String::new();
|
||||
drag.push_str(&sgr_mouse(0, press_row, press_col, 'M'));
|
||||
drag.push_str(&sgr_mouse(32, press_row + 1, press_col, 'M'));
|
||||
drag.push_str(&sgr_mouse(32, hold_row, press_col, 'M'));
|
||||
harness
|
||||
.inject_keys(drag.as_bytes())
|
||||
.expect("press a marker, drag past the bottom edge");
|
||||
|
||||
// Sample the viewport every ~100ms with the pointer held: the topmost
|
||||
// marker must never regress (bounce) and must settle at the clamp.
|
||||
let mut samples = Vec::new();
|
||||
for _ in 0..25 {
|
||||
harness.update(Duration::from_millis(100));
|
||||
samples.push(topmost_visible_marker(&harness).unwrap_or_else(|| {
|
||||
panic!(
|
||||
"markers must stay visible mid-autoscroll\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
}));
|
||||
}
|
||||
for pair in samples.windows(2) {
|
||||
assert!(
|
||||
pair[1] >= pair[0],
|
||||
"viewport bounced: topmost regressed {} -> {}; samples={samples:?}",
|
||||
pair[0],
|
||||
pair[1]
|
||||
);
|
||||
}
|
||||
let last = *samples.last().unwrap();
|
||||
assert!(
|
||||
last > samples[0] || samples[0] == baseline_topmost,
|
||||
"autoscroll made no progress; samples={samples:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
last, baseline_topmost,
|
||||
"must settle back at the bottom clamp; samples={samples:?}"
|
||||
);
|
||||
assert!(
|
||||
samples[samples.len() - 5..].iter().all(|&m| m == last),
|
||||
"must hold flat once clamped (no jitter); samples={samples:?}"
|
||||
);
|
||||
|
||||
harness
|
||||
.inject_keys(sgr_mouse(0, hold_row, press_col, 'm').as_bytes())
|
||||
.expect("release");
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// Single-line message; the drag enters it at the tail word.
|
||||
const GAPDEEP_LINE: &str = "GAPDEEP alpha beta gamma delta epsilon";
|
||||
|
||||
const ENTRY_WORD: &str = "epsilon";
|
||||
|
||||
/// PTY: a mouse-down on the blank gap below the conversation (between the
|
||||
/// turn marker and the prompt box) arms an anchor-less drag — dead space
|
||||
/// is a valid drag start — and the anchor materializes at the first drag
|
||||
/// position that lands on selectable text: here a word inside the last
|
||||
/// message. The payload is the entry-to-release slice of that single line
|
||||
/// — not a snap to the press-nearest text, not a block copy.
|
||||
///
|
||||
/// `SSH_CONNECTION` forces the OSC 52 clipboard route for readback.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
|
||||
async fn drag_enters_content_from_gap_pty() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(GAPDEEP_LINE.to_string());
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut env = content.env_for_pager();
|
||||
env.push((
|
||||
"SSH_CONNECTION".into(),
|
||||
"scripted-test 1 127.0.0.1 2".into(),
|
||||
));
|
||||
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
let mut harness = PtyHarness::new_in_dir(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&[],
|
||||
&env_refs,
|
||||
Some(content.home()),
|
||||
)
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome");
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(ENTRY_WORD, Duration::from_secs(45))
|
||||
.expect("message rendered");
|
||||
harness
|
||||
.wait_for_text("Worked for", Duration::from_secs(20))
|
||||
.expect("turn marker rendered");
|
||||
|
||||
harness.inject_keys(b"\t").expect("focus scrollback");
|
||||
let _ = harness.wait_for_text("Space:prompt", Duration::from_secs(10));
|
||||
harness.update(Duration::from_millis(1500));
|
||||
|
||||
let screen = harness.screen_contents();
|
||||
let (msg_row, _) = locate_screen_text(&screen, "GAPDEEP")
|
||||
.unwrap_or_else(|| panic!("could not locate GAPDEEP; screen:\n{screen}"));
|
||||
let (entry_row, entry_col) = locate_screen_text(&screen, ENTRY_WORD)
|
||||
.unwrap_or_else(|| panic!("could not locate {ENTRY_WORD:?}; screen:\n{screen}"));
|
||||
assert_eq!(entry_row, msg_row, "setup: single unwrapped message line");
|
||||
let (marker_row, _) = locate_screen_text(&screen, "Worked for")
|
||||
.unwrap_or_else(|| panic!("could not locate the turn marker; screen:\n{screen}"));
|
||||
assert!(marker_row > msg_row, "setup: marker below the message");
|
||||
|
||||
// The gap row below the marker must be fully blank (above the prompt box).
|
||||
let gap_row = marker_row + 1;
|
||||
let gap_line = screen.lines().nth(gap_row as usize).unwrap_or("");
|
||||
assert!(
|
||||
gap_line.trim().is_empty(),
|
||||
"setup: the press row must be a blank gap; line: {gap_line:?}"
|
||||
);
|
||||
|
||||
// PRESS in the gap, then drag up into the message. The motion samples
|
||||
// jump the marker row deliberately (terminals coalesce motion): the
|
||||
// same-row column clamp makes the marker's line hittable at any column
|
||||
// of its row, so a sample there would anchor the drag on the marker —
|
||||
// the correct first-text-entered answer for that path, but not this
|
||||
// test's subject. First sample on the message = anchor at the word's
|
||||
// first column; then extend to its last column and release.
|
||||
let head_col = entry_col + ENTRY_WORD.len() as u16 - 1;
|
||||
let mut drag = String::new();
|
||||
drag.push_str(&sgr_mouse(0, gap_row, entry_col, 'M'));
|
||||
drag.push_str(&sgr_mouse(32, entry_row, entry_col, 'M'));
|
||||
drag.push_str(&sgr_mouse(32, entry_row, head_col, 'M'));
|
||||
drag.push_str(&sgr_mouse(0, entry_row, head_col, 'm'));
|
||||
harness
|
||||
.inject_keys(drag.as_bytes())
|
||||
.expect("press the gap, drag up into the message");
|
||||
|
||||
let payloads = wait_for_osc52_payloads(&mut harness, Duration::from_secs(10));
|
||||
assert!(
|
||||
!payloads.is_empty(),
|
||||
"expected an OSC 52 clipboard write after release; screen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
let joined = payloads.join("\n");
|
||||
assert_eq!(
|
||||
joined, ENTRY_WORD,
|
||||
"payload must be the entry-to-release slice of the entered line \
|
||||
(anchor at text entry, not at the press or a snap); payloads={payloads:?}"
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// Single-line message; the drag enters it at the tail word.
|
||||
const STRIPDEEP_LINE: &str = "STRIPDEEP alpha beta gamma delta epsilon";
|
||||
|
||||
const ENTRY_WORD: &str = "epsilon";
|
||||
|
||||
/// PTY: a mouse-down on the strip directly above the prompt box — OUTSIDE
|
||||
/// the scrollback pane — arms the anchor-less latch, and dragging up into
|
||||
/// the last message anchors where the pointer enters text: the payload is
|
||||
/// the entry-to-release slice. The prompt-gap row is the deterministic
|
||||
/// representative of the band (turn status and banner need live turn or
|
||||
/// watcher state the harness can't stage while idle; all band rows share
|
||||
/// the same arming path).
|
||||
///
|
||||
/// `SSH_CONNECTION` forces the OSC 52 clipboard route for readback.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
|
||||
async fn drag_from_above_prompt_strip_pty() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(STRIPDEEP_LINE.to_string());
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut env = content.env_for_pager();
|
||||
env.push((
|
||||
"SSH_CONNECTION".into(),
|
||||
"scripted-test 1 127.0.0.1 2".into(),
|
||||
));
|
||||
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
let mut harness = PtyHarness::new_in_dir(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&[],
|
||||
&env_refs,
|
||||
Some(content.home()),
|
||||
)
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome");
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(ENTRY_WORD, Duration::from_secs(45))
|
||||
.expect("message rendered");
|
||||
harness
|
||||
.wait_for_text("Worked for", Duration::from_secs(20))
|
||||
.expect("turn marker rendered");
|
||||
|
||||
harness.inject_keys(b"\t").expect("focus scrollback");
|
||||
let _ = harness.wait_for_text("Space:prompt", Duration::from_secs(10));
|
||||
harness.update(Duration::from_millis(1500));
|
||||
|
||||
let screen = harness.screen_contents();
|
||||
let (entry_row, entry_col) = locate_screen_text(&screen, ENTRY_WORD)
|
||||
.unwrap_or_else(|| panic!("could not locate {ENTRY_WORD:?}; screen:\n{screen}"));
|
||||
// The strip row sits two rows above the prompt placeholder: placeholder,
|
||||
// then the box's top border, then the gap row between the scrollback
|
||||
// pane and the prompt box.
|
||||
let (placeholder_row, _) = locate_screen_text(&screen, "Build anything")
|
||||
.unwrap_or_else(|| panic!("could not locate the prompt placeholder; screen:\n{screen}"));
|
||||
let border_row = placeholder_row - 1;
|
||||
let border_line = screen.lines().nth(border_row as usize).unwrap_or("");
|
||||
assert!(
|
||||
border_line.contains('\u{256d}'),
|
||||
"setup: prompt top border above the placeholder; line: {border_line:?}"
|
||||
);
|
||||
let strip_row = border_row - 1;
|
||||
let strip_line = screen.lines().nth(strip_row as usize).unwrap_or("");
|
||||
assert!(
|
||||
strip_line.trim().is_empty(),
|
||||
"setup: the press row must be the blank above-prompt strip; line: {strip_line:?}"
|
||||
);
|
||||
assert!(
|
||||
strip_row > entry_row,
|
||||
"setup: strip below the message\nscreen:\n{screen}"
|
||||
);
|
||||
|
||||
// PRESS on the strip, then drag up into the message. The single motion
|
||||
// sample jumps the turn-marker row deliberately (terminals coalesce
|
||||
// motion): the same-row column clamp makes the marker's line hittable
|
||||
// at any column of its row, and this test pins the strip-to-message
|
||||
// path, not marker anchoring. First sample on the message = anchor at
|
||||
// the word's first column; then extend to its last column and release.
|
||||
let head_col = entry_col + ENTRY_WORD.len() as u16 - 1;
|
||||
let mut drag = String::new();
|
||||
drag.push_str(&sgr_mouse(0, strip_row, entry_col, 'M'));
|
||||
drag.push_str(&sgr_mouse(32, entry_row, entry_col, 'M'));
|
||||
drag.push_str(&sgr_mouse(32, entry_row, head_col, 'M'));
|
||||
drag.push_str(&sgr_mouse(0, entry_row, head_col, 'm'));
|
||||
harness
|
||||
.inject_keys(drag.as_bytes())
|
||||
.expect("press the above-prompt strip, drag up into the message");
|
||||
|
||||
let payloads = wait_for_osc52_payloads(&mut harness, Duration::from_secs(10));
|
||||
assert!(
|
||||
!payloads.is_empty(),
|
||||
"expected an OSC 52 clipboard write after release; screen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
let joined = payloads.join("\n");
|
||||
assert_eq!(
|
||||
joined, ENTRY_WORD,
|
||||
"payload must be the entry-to-release slice of the entered line; \
|
||||
payloads={payloads:?}"
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// Paragraph tokens above and below the blank row inside one message.
|
||||
const CHROMEHOLD_TOP: &str = "CHROMEHOLD_TOP";
|
||||
|
||||
const CHROMEHOLD_BOTTOM: &str = "CHROMEHOLD_BOTTOM";
|
||||
|
||||
/// PTY: a mouse-down on a blank row INSIDE a block's area whose drag never
|
||||
/// touches selectable text stays a whole-block drag: release copies the
|
||||
/// whole message (both paragraphs), exactly as before the deferred-anchor
|
||||
/// latch existed. Covers the never-enter branch the anchor-on-entry
|
||||
/// conversion must not disturb.
|
||||
///
|
||||
/// `SSH_CONNECTION` forces the OSC 52 clipboard route for readback.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
|
||||
async fn drag_from_chrome_stays_block_pty() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!(
|
||||
"{CHROMEHOLD_TOP} first paragraph line\n\n{CHROMEHOLD_BOTTOM} second paragraph line"
|
||||
));
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut env = content.env_for_pager();
|
||||
env.push((
|
||||
"SSH_CONNECTION".into(),
|
||||
"scripted-test 1 127.0.0.1 2".into(),
|
||||
));
|
||||
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
let mut harness = PtyHarness::new_in_dir(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&[],
|
||||
&env_refs,
|
||||
Some(content.home()),
|
||||
)
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome");
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(CHROMEHOLD_BOTTOM, Duration::from_secs(45))
|
||||
.expect("both paragraphs rendered");
|
||||
|
||||
harness.inject_keys(b"\t").expect("focus scrollback");
|
||||
let _ = harness.wait_for_text("Space:prompt", Duration::from_secs(10));
|
||||
harness.update(Duration::from_millis(1500));
|
||||
|
||||
let screen = harness.screen_contents();
|
||||
let (row_top, col_top) = locate_screen_text(&screen, CHROMEHOLD_TOP)
|
||||
.unwrap_or_else(|| panic!("could not locate {CHROMEHOLD_TOP:?}; screen:\n{screen}"));
|
||||
let (row_bottom, _) = locate_screen_text(&screen, CHROMEHOLD_BOTTOM)
|
||||
.unwrap_or_else(|| panic!("could not locate {CHROMEHOLD_BOTTOM:?}; screen:\n{screen}"));
|
||||
assert_eq!(
|
||||
row_bottom,
|
||||
row_top + 2,
|
||||
"setup: exactly one blank row between the paragraphs\nscreen:\n{screen}"
|
||||
);
|
||||
let chrome_row = row_top + 1;
|
||||
let chrome_line = screen.lines().nth(chrome_row as usize).unwrap_or("");
|
||||
assert!(
|
||||
chrome_line
|
||||
.chars()
|
||||
.skip(col_top as usize)
|
||||
.take(16)
|
||||
.all(char::is_whitespace),
|
||||
"setup: the in-block row must be blank at the drag columns; line: {chrome_line:?}"
|
||||
);
|
||||
|
||||
// PRESS on the blank in-block row and drag sideways along it — the
|
||||
// pointer never enters selectable text, so the gesture stays a block
|
||||
// drag — then release.
|
||||
let mut drag = String::new();
|
||||
drag.push_str(&sgr_mouse(0, chrome_row, col_top, 'M'));
|
||||
drag.push_str(&sgr_mouse(32, chrome_row, col_top + 3, 'M'));
|
||||
drag.push_str(&sgr_mouse(0, chrome_row, col_top + 3, 'm'));
|
||||
harness
|
||||
.inject_keys(drag.as_bytes())
|
||||
.expect("press the in-block blank row, drag along it");
|
||||
|
||||
let payloads = wait_for_osc52_payloads(&mut harness, Duration::from_secs(10));
|
||||
assert!(
|
||||
!payloads.is_empty(),
|
||||
"expected an OSC 52 clipboard write after release; screen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
let joined = payloads.join("\n");
|
||||
assert!(
|
||||
joined.starts_with(&format!("{CHROMEHOLD_TOP} first paragraph line")),
|
||||
"whole-block copy starts at the block's first line; payloads={payloads:?}"
|
||||
);
|
||||
assert!(
|
||||
joined
|
||||
.trim_end()
|
||||
.ends_with(&format!("{CHROMEHOLD_BOTTOM} second paragraph line")),
|
||||
"whole-block copy includes the full second paragraph; payloads={payloads:?}"
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// Paragraph tokens above and below the blank row inside one message.
|
||||
const GAP_TOP: &str = "GAPROW_ALPHA";
|
||||
|
||||
const GAP_BOTTOM: &str = "GAPROW_OMEGA";
|
||||
|
||||
/// PTY: a drag whose LAST motion lands on the dead blank row between two
|
||||
/// paragraphs of one message must not freeze the head — the copy spans the
|
||||
/// gap into the second paragraph.
|
||||
///
|
||||
/// Both paragraphs share the message body's selection range; the blank row
|
||||
/// between them has no selectable columns, so before dead-zone snapping the
|
||||
/// head collapsed to the anchor there (a one-character copy).
|
||||
/// `SSH_CONNECTION` forces the OSC 52 clipboard route for readback.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
|
||||
async fn drag_over_gap_rows_does_not_freeze_head_pty() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!(
|
||||
"{GAP_TOP} first paragraph line\n\n{GAP_BOTTOM} second paragraph line"
|
||||
));
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut env = content.env_for_pager();
|
||||
env.push((
|
||||
"SSH_CONNECTION".into(),
|
||||
"scripted-test 1 127.0.0.1 2".into(),
|
||||
));
|
||||
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
let mut harness = PtyHarness::new_in_dir(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&[],
|
||||
&env_refs,
|
||||
Some(content.home()),
|
||||
)
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome");
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(GAP_BOTTOM, Duration::from_secs(45))
|
||||
.expect("both paragraphs rendered");
|
||||
|
||||
harness.inject_keys(b"\t").expect("focus scrollback");
|
||||
let _ = harness.wait_for_text("Space:prompt", Duration::from_secs(10));
|
||||
// Settle: the turn-end relayout can shift rows; locate afterwards.
|
||||
harness.update(Duration::from_millis(1500));
|
||||
|
||||
let screen = harness.screen_contents();
|
||||
let (row_top, col_top) = locate_screen_text(&screen, GAP_TOP)
|
||||
.unwrap_or_else(|| panic!("could not locate {GAP_TOP:?}; screen:\n{screen}"));
|
||||
let (row_bottom, _) = locate_screen_text(&screen, GAP_BOTTOM)
|
||||
.unwrap_or_else(|| panic!("could not locate {GAP_BOTTOM:?}; screen:\n{screen}"));
|
||||
assert_eq!(
|
||||
row_bottom,
|
||||
row_top + 2,
|
||||
"setup: exactly one blank row between the paragraphs\nscreen:\n{screen}"
|
||||
);
|
||||
let gap_row = row_top + 1;
|
||||
let drag_col = col_top + 12;
|
||||
let gap_line = screen.lines().nth(gap_row as usize).unwrap_or("");
|
||||
assert!(
|
||||
gap_line
|
||||
.chars()
|
||||
.skip(col_top as usize)
|
||||
.take(13)
|
||||
.all(char::is_whitespace),
|
||||
"setup: the gap row must be blank at the drag columns; line: {gap_line:?}"
|
||||
);
|
||||
|
||||
// Press on the first paragraph, drag ONCE onto the dead row, release there.
|
||||
let mut drag = String::new();
|
||||
drag.push_str(&sgr_mouse(0, row_top, col_top, 'M'));
|
||||
drag.push_str(&sgr_mouse(32, gap_row, drag_col, 'M'));
|
||||
drag.push_str(&sgr_mouse(0, gap_row, drag_col, 'm'));
|
||||
harness
|
||||
.inject_keys(drag.as_bytes())
|
||||
.expect("drag onto the gap row and release");
|
||||
|
||||
let payloads = wait_for_osc52_payloads(&mut harness, Duration::from_secs(10));
|
||||
assert!(
|
||||
!payloads.is_empty(),
|
||||
"expected an OSC 52 clipboard write after release; screen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
let joined = payloads.join("\n");
|
||||
assert!(
|
||||
joined.contains(GAP_TOP),
|
||||
"clipboard must contain the anchor paragraph; payloads={payloads:?}"
|
||||
);
|
||||
assert!(
|
||||
joined.contains(GAP_BOTTOM),
|
||||
"clipboard span must cross the dead row into the second paragraph; \
|
||||
payloads={payloads:?}"
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
#[allow(unused_imports)]
|
||||
use super::scroll::*;
|
||||
|
||||
/// First and last line tokens of the anchor block (turn 1's message).
|
||||
const ANCHOR_FIRST: &str = "SCROLLOUT_ALPHA";
|
||||
|
||||
const ANCHOR_LAST: &str = "SCROLLOUT_OMEGA";
|
||||
|
||||
/// Turn-2 filler rows: comfortably taller than the 50-row PTY so drag
|
||||
/// autoscroll can push the anchor block fully out of the viewport.
|
||||
const FILLER_ROWS: usize = 120;
|
||||
|
||||
/// PTY: drag-hold at the bottom edge autoscrolls until the anchor block has
|
||||
/// scrolled FULLY out of the viewport, then release — the copy must still
|
||||
/// contain the anchor line and the block's late lines.
|
||||
///
|
||||
/// Exercises the drag-start `content_width` snapshot (mouse-up-time
|
||||
/// `visible_blocks` no longer has the anchor block) plus the per-frame head
|
||||
/// reclamp keeping the head on the range while it scrolls out.
|
||||
/// `SSH_CONNECTION` forces the OSC 52 clipboard route for readback.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
|
||||
async fn drag_select_autoscroll_full_scrollout_copy_pty() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
// Turn 1: a three-row anchor message (markdown hard breaks keep one row
|
||||
// per source line). Turn 2: filler tall enough to scroll it fully out.
|
||||
content.set_turns([
|
||||
format!("{ANCHOR_FIRST} anchor first line \nmiddle filler line \n{ANCHOR_LAST} anchor last line"),
|
||||
marker_response(MOCK_RESPONSE_SENTINEL, FILLER_ROWS),
|
||||
]);
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut env = content.env_for_pager();
|
||||
env.push((
|
||||
"SSH_CONNECTION".into(),
|
||||
"scripted-test 1 127.0.0.1 2".into(),
|
||||
));
|
||||
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
let mut harness = PtyHarness::new_in_dir(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&[],
|
||||
&env_refs,
|
||||
Some(content.home()),
|
||||
)
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome");
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit turn 1");
|
||||
harness
|
||||
.wait_for_text(ANCHOR_LAST, Duration::from_secs(45))
|
||||
.expect("anchor message rendered");
|
||||
harness.update(Duration::from_millis(500));
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit turn 2");
|
||||
harness
|
||||
.wait_for_text(&marker_line(FILLER_ROWS - 1), Duration::from_secs(60))
|
||||
.expect("filler streamed");
|
||||
harness.update(Duration::from_millis(500));
|
||||
|
||||
harness.inject_keys(b"\t").expect("focus scrollback");
|
||||
let _ = harness.wait_for_text("Space:prompt", Duration::from_secs(10));
|
||||
harness.update(Duration::from_millis(500));
|
||||
|
||||
// Wheel up until the anchor block's first line is back on screen, then a
|
||||
// little further so the press row sits clear of the top autoscroll zone.
|
||||
let scroll_deadline = Instant::now() + Duration::from_secs(30);
|
||||
while !harness.contains_text(ANCHOR_FIRST) {
|
||||
assert!(
|
||||
Instant::now() < scroll_deadline,
|
||||
"never scrolled the anchor line back into view\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
send_wheel_burst(
|
||||
&mut harness,
|
||||
SGR_SCROLL_UP,
|
||||
10,
|
||||
WHEEL_ROW,
|
||||
WHEEL_COL,
|
||||
Duration::ZERO,
|
||||
);
|
||||
harness.update(Duration::from_millis(200));
|
||||
}
|
||||
send_wheel_burst(
|
||||
&mut harness,
|
||||
SGR_SCROLL_UP,
|
||||
3,
|
||||
WHEEL_ROW,
|
||||
WHEEL_COL,
|
||||
Duration::ZERO,
|
||||
);
|
||||
harness.update(Duration::from_millis(400));
|
||||
|
||||
let screen = harness.screen_contents();
|
||||
let (row, col) = locate_screen_text(&screen, ANCHOR_FIRST)
|
||||
.unwrap_or_else(|| panic!("could not locate {ANCHOR_FIRST:?}; screen:\n{screen}"));
|
||||
|
||||
// Press on the anchor line, drag to the bottom edge, and HOLD: the edge
|
||||
// position arms autoscroll and the ticks do the rest.
|
||||
let bottom_row = DEFAULT_ROWS - 3;
|
||||
let mut drag = String::new();
|
||||
drag.push_str(&sgr_mouse(0, row, col, 'M'));
|
||||
drag.push_str(&sgr_mouse(32, row + 1, col + 8, 'M'));
|
||||
drag.push_str(&sgr_mouse(32, bottom_row, col + 20, 'M'));
|
||||
harness
|
||||
.inject_keys(drag.as_bytes())
|
||||
.expect("press and drag to bottom edge");
|
||||
|
||||
// Hold until the anchor block is PROVABLY out of `visible_blocks`: the
|
||||
// numbered filler sits below the anchor block, its padding, and the
|
||||
// second prompt, so once the topmost visible filler marker is >= 4 every
|
||||
// row above it — the whole anchor block included — is beyond the
|
||||
// viewport top and the mouse-up-time width lookup cannot rescue the copy.
|
||||
let out_deadline = Instant::now() + Duration::from_secs(30);
|
||||
while topmost_visible_marker(&harness).is_none_or(|m| m < 4) {
|
||||
assert!(
|
||||
Instant::now() < out_deadline,
|
||||
"autoscroll never pushed the anchor block off-screen\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
harness.update(Duration::from_millis(200));
|
||||
}
|
||||
assert!(
|
||||
!harness.contains_text(ANCHOR_FIRST) && !harness.contains_text(ANCHOR_LAST),
|
||||
"anchor lines still on screen past marker 4\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness
|
||||
.inject_keys(sgr_mouse(0, bottom_row, col + 20, 'm').as_bytes())
|
||||
.expect("release");
|
||||
|
||||
let payloads = wait_for_osc52_payloads(&mut harness, Duration::from_secs(10));
|
||||
assert!(
|
||||
!payloads.is_empty(),
|
||||
"expected an OSC 52 clipboard write after release; screen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
let joined = payloads.join("\n");
|
||||
assert!(
|
||||
joined.contains(ANCHOR_FIRST),
|
||||
"clipboard must contain the (scrolled-out) anchor line; payloads={payloads:?}"
|
||||
);
|
||||
assert!(
|
||||
joined.contains(ANCHOR_LAST),
|
||||
"clipboard must contain the anchor block's late lines; payloads={payloads:?}"
|
||||
);
|
||||
// The selection stays pinned to the anchor block's range: no filler lines.
|
||||
assert!(
|
||||
!joined.contains("MARKER-"),
|
||||
"clipboard must not contain lines from the filler block; payloads={payloads:?}"
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
#[allow(unused_imports)]
|
||||
use super::scroll::*;
|
||||
|
||||
/// Tall enough that a mid-drag wheel burst has ~150 rows of headroom above
|
||||
/// the bottom-pinned viewport.
|
||||
const MARKER_COUNT: usize = 240;
|
||||
|
||||
/// Wheel-up reports sent mid-drag in one write (batched with the follow-up
|
||||
/// one-cell motion so no redraw separates them).
|
||||
const WHEEL_NOTCHES: usize = 16;
|
||||
|
||||
/// The extension witness: a marker this many lines above the pre-wheel head.
|
||||
/// 16 notches scroll ≥16 rows under any stream classification, so the
|
||||
/// post-wheel head is at least this far up; the pre-wheel span was 2 rows.
|
||||
const MIN_EXTEND_LINES: usize = 12;
|
||||
|
||||
/// PTY: wheel-scrolling mid-drag extends the selection — the next draw
|
||||
/// rebuilds the model and re-snaps the head to the held pointer, so lines
|
||||
/// revealed by the wheel land in the copy.
|
||||
///
|
||||
/// The wheel reports and the one-cell motion ride ONE write so the motion
|
||||
/// resolves against the pre-wheel model; only the post-render reclamp can
|
||||
/// extend the head to the revealed rows. `SSH_CONNECTION` forces the OSC 52
|
||||
/// clipboard route for readback.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
|
||||
async fn drag_select_wheel_scroll_extends_pty() {
|
||||
let (mut harness, _content, top_before) = spawn_bottom_pinned_marker_scrollback_with_env(
|
||||
MARKER_COUNT,
|
||||
&[("SSH_CONNECTION", "scripted-test 1 127.0.0.1 2")],
|
||||
)
|
||||
.await;
|
||||
|
||||
// Anchor ~25 rows below the topmost visible marker: mid-screen, clear of
|
||||
// both autoscroll edge zones.
|
||||
let anchor_idx = top_before + 25;
|
||||
let anchor_marker = marker_line(anchor_idx);
|
||||
let screen = harness.screen_contents();
|
||||
let (row_a, col_a) = locate_screen_text(&screen, &anchor_marker)
|
||||
.unwrap_or_else(|| panic!("could not locate {anchor_marker:?}; screen:\n{screen}"));
|
||||
|
||||
// Press at the END of the anchor marker (the anchor line is the span's
|
||||
// bottom endpoint; its slice runs 0..=press col, keeping the token whole),
|
||||
// then drag two rows UP ending at the marker's start column.
|
||||
let press_col = col_a + anchor_marker.len() as u16 - 1;
|
||||
let mut drag = String::new();
|
||||
drag.push_str(&sgr_mouse(0, row_a, press_col, 'M'));
|
||||
drag.push_str(&sgr_mouse(32, row_a - 1, col_a + 5, 'M'));
|
||||
drag.push_str(&sgr_mouse(32, row_a - 2, col_a, 'M'));
|
||||
harness
|
||||
.inject_keys(drag.as_bytes())
|
||||
.expect("press and drag up");
|
||||
harness.update(Duration::from_millis(400));
|
||||
|
||||
// Wheel up mid-drag, then move one cell left — batched into one write.
|
||||
let mut wheel = String::new();
|
||||
for _ in 0..WHEEL_NOTCHES {
|
||||
wheel.push_str(&sgr_mouse(SGR_SCROLL_UP, row_a - 2, col_a, 'M'));
|
||||
}
|
||||
wheel.push_str(&sgr_mouse(32, row_a - 2, col_a.saturating_sub(1), 'M'));
|
||||
harness
|
||||
.inject_keys(wheel.as_bytes())
|
||||
.expect("wheel burst mid-drag");
|
||||
harness.update(Duration::from_millis(800));
|
||||
|
||||
harness
|
||||
.inject_keys(sgr_mouse(0, row_a - 2, col_a.saturating_sub(1), 'm').as_bytes())
|
||||
.expect("release");
|
||||
|
||||
let payloads = wait_for_osc52_payloads(&mut harness, Duration::from_secs(10));
|
||||
assert!(
|
||||
!payloads.is_empty(),
|
||||
"expected an OSC 52 clipboard write after release; screen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
let joined = payloads.join("\n");
|
||||
assert!(
|
||||
joined.contains(&anchor_marker),
|
||||
"clipboard must contain the anchor line; payloads={payloads:?}"
|
||||
);
|
||||
let revealed_marker = marker_line(anchor_idx - MIN_EXTEND_LINES);
|
||||
assert!(
|
||||
joined.contains(&revealed_marker),
|
||||
"clipboard must span rows revealed by the mid-drag wheel scroll \
|
||||
(expected {revealed_marker}); payloads={payloads:?}"
|
||||
);
|
||||
assert!(
|
||||
!joined.contains(&marker_line(anchor_idx + 1)),
|
||||
"an upward drag must not copy below the anchor line; payloads={payloads:?}"
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
const DONE_SENTINEL: &str = "EDIT_COLLAPSED_DONE";
|
||||
|
||||
/// Comment planted in the replacement text: visible only when the diff body
|
||||
/// renders, so it separates the collapsed one-liner from the expanded view.
|
||||
const BODY_MARKER: &str = "EXPANDED_BODY_MARKER";
|
||||
|
||||
/// PTY: with the `collapsed_edit_blocks` flag enabled (config tier here;
|
||||
/// remote settings/managed in production), an Edit tool call lands as a collapsed
|
||||
/// one-liner — header with the colored `+N/-M` diffstat, no diff body — and a
|
||||
/// double-click on the header expands it to the full diff. (The keyboard fold
|
||||
/// keys operate on the scrollback selection, which this test does not
|
||||
/// exercise.) The flag-off/legacy default is covered end-to-end by
|
||||
/// `edit_hl_inplace_refresh_pty`, which relies on expanded-on-arrival diffs.
|
||||
/// Doubles as the demo-cast generator via `KIGI_PTY_CAST_DIR`.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
|
||||
async fn edit_collapsed_oneliner_pty() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
seed_ui_config(&content, "collapsed_edit_blocks = true");
|
||||
|
||||
// Fixture under the isolated HOME so the scripted search_replace succeeds.
|
||||
let target = content.home().join("greet_fix.py");
|
||||
std::fs::write(
|
||||
&target,
|
||||
"# collapsed one-liner demo fixture\ndef greet():\n return \"hi\"\n",
|
||||
)
|
||||
.expect("write fixture");
|
||||
let abs = dunce::canonicalize(&target).unwrap_or(target.clone());
|
||||
|
||||
// One deleted line, two inserted lines => a `+2/-1` diffstat.
|
||||
enqueue_tool_turn(
|
||||
&content,
|
||||
"call_collapsed",
|
||||
"search_replace",
|
||||
json!({
|
||||
"file_path": abs.to_string_lossy(),
|
||||
"old_string": " return \"hi\"",
|
||||
"new_string": format!(" name = \"grok\" # {BODY_MARKER}\n return f\"hi {{name}}\""),
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
content.set_response(DONE_SENTINEL);
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness = PtyHarness::spawn_with_content_in_dir(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&content,
|
||||
&["--yolo", "--trust"],
|
||||
Some(content.home()),
|
||||
)
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome");
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
|
||||
// The follow-up completion means the tool turn settled.
|
||||
harness
|
||||
.wait_for_text(DONE_SENTINEL, Duration::from_secs(90))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"expected settled transcript; got:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
|
||||
// Collapsed one-liner: basename + colored diffstat right after it.
|
||||
harness
|
||||
.wait_for_text("Edit greet_fix.py +2/-1", Duration::from_secs(20))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"expected the collapsed `Edit <basename> +N/-M` one-liner; got:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
!harness.contains_text(BODY_MARKER),
|
||||
"diff body must stay hidden while collapsed\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
// Double-click the header to expand the block in place.
|
||||
let screen = harness.screen_contents();
|
||||
let (row, col) = locate_screen_text(&screen, "Edit greet_fix.py").unwrap_or_else(|| {
|
||||
panic!("could not locate the Edit header; screen:\n{screen}");
|
||||
});
|
||||
let dbl = format!(
|
||||
"{}{}{}{}",
|
||||
sgr_mouse(0, row, col, 'M'),
|
||||
sgr_mouse(0, row, col, 'm'),
|
||||
sgr_mouse(0, row, col, 'M'),
|
||||
sgr_mouse(0, row, col, 'm'),
|
||||
);
|
||||
harness
|
||||
.inject_keys(dbl.as_bytes())
|
||||
.expect("double-click header");
|
||||
harness
|
||||
.wait_for_text(BODY_MARKER, Duration::from_secs(10))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"double-click must expand the Edit block to the diff body; got:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
#[cfg(unix)]
|
||||
write_cast_if_requested(&harness, "edit_collapsed_oneliner.cast");
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
//
|
||||
// Regression pin for the in-place edit-HL upgrade: the target line's styling
|
||||
// must CHANGE on screen after the first (hunk-only) paint, proving the
|
||||
// file-scoped repaint landed. Doubles as the demo-artifact generator — the
|
||||
// asciicast/HTML dumps under /tmp/edit_hl_video are kept for demo videos.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
use kigi_pager_pty_harness::StyledLine;
|
||||
|
||||
const DONE_SENTINEL: &str = "EDIT_HL_DONE";
|
||||
const ARTIFACT_DIR: &str = "/tmp/edit_hl_video";
|
||||
|
||||
/// Marker unique to the edited (Insert) line; its styling flips on upgrade.
|
||||
const TARGET_MARKER: &str = "min_length=2";
|
||||
/// Tail of the same line; requiring both ends rejects partially-painted rows.
|
||||
const TARGET_TAIL: &str = "upgrade target";
|
||||
|
||||
/// Style-run snapshot of every fully-painted screen row containing the target
|
||||
/// line, row-position independent (scrolling must not read as a styling
|
||||
/// change). `None` until the whole line is on screen.
|
||||
fn target_line_style_snapshot(rows: &[StyledLine]) -> Option<String> {
|
||||
let mut snaps = Vec::new();
|
||||
for row in rows {
|
||||
let text: String = row.runs.iter().map(|r| r.text.as_str()).collect();
|
||||
if text.contains(TARGET_MARKER) && text.contains(TARGET_TAIL) {
|
||||
snaps.push(serde_json::to_string(&row.runs).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
if snaps.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(snaps.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Python body: mid-file closing `"""` then fields.
|
||||
/// Prefix padding slows full-file HL so the in-place upgrade is visible.
|
||||
fn fixture_python(pad_lines: usize) -> String {
|
||||
let mut s = String::with_capacity(pad_lines * 48 + 512);
|
||||
s.push_str("# queue_item.py — edit HL demo fixture (cold-start mismatch shape)\n");
|
||||
for i in 0..pad_lines {
|
||||
s.push_str(&format!(
|
||||
"# pad line {i:04} keep full-file HL non-instant\n"
|
||||
));
|
||||
}
|
||||
s.push_str(
|
||||
r#"
|
||||
class ProcessQueueItem(BaseModel):
|
||||
"""Request body for processing a single queue item.
|
||||
|
||||
The item id is in the path; keep notes in the body.
|
||||
"""
|
||||
|
||||
notes: str = Field(..., min_length=1)
|
||||
category_id: CategoryId = Field(default=DEFAULT_CATEGORY_ID)
|
||||
"#,
|
||||
);
|
||||
s
|
||||
}
|
||||
|
||||
fn write_asciicast(path: &Path, cols: u16, rows: u16, events: &[(f64, String)]) {
|
||||
let mut f = fs::File::create(path).expect("create cast");
|
||||
let header = serde_json::json!({
|
||||
"version": 2,
|
||||
"width": cols,
|
||||
"height": rows,
|
||||
"timestamp": std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0),
|
||||
"env": {"TERM": "xterm-256color", "SHELL": "/bin/zsh"},
|
||||
});
|
||||
writeln!(f, "{header}").expect("header");
|
||||
for (t, out) in events {
|
||||
// asciicast v2: [time, "o", data]
|
||||
let line = serde_json::json!([t, "o", out]);
|
||||
writeln!(f, "{line}").expect("event");
|
||||
}
|
||||
}
|
||||
|
||||
/// PTY: search_replace on a mid-file `"""` fixture; assert the Edit block's
|
||||
/// target line restyles in place (hunk-only → file-scoped upgrade) and dump
|
||||
/// asciicast + HTML demo artifacts.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
|
||||
async fn edit_hl_inplace_refresh_pty() {
|
||||
fs::create_dir_all(ARTIFACT_DIR).expect("artifact dir");
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
// No config seeds: the collapsed_edit_blocks flag ships OFF, so Edit
|
||||
// diffs arrive expanded — this test asserts the on-screen restyle of the
|
||||
// diff BODY and doubles as the flag-off/legacy-default e2e.
|
||||
|
||||
// ~2.5k pad lines: full-file HL takes hundreds of ms (visible upgrade).
|
||||
let pad = 2500usize;
|
||||
let body = fixture_python(pad);
|
||||
let target = content.home().join("queue_item.py");
|
||||
fs::write(&target, &body).expect("write fixture");
|
||||
let abs = dunce::canonicalize(&target).unwrap_or(target.clone());
|
||||
|
||||
// Small unique edit on the field line after the closing """ (the spill zone).
|
||||
let old = " notes: str = Field(..., min_length=1)";
|
||||
let new = " notes: str = Field(..., min_length=2) # HL upgrade target";
|
||||
enqueue_tool_turn(
|
||||
&content,
|
||||
"call_edit_hl",
|
||||
"search_replace",
|
||||
json!({
|
||||
"file_path": abs.to_string_lossy(),
|
||||
"old_string": old,
|
||||
"new_string": new,
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
content.set_response(DONE_SENTINEL);
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let rows = DEFAULT_ROWS;
|
||||
let cols = DEFAULT_COLS;
|
||||
let mut harness = PtyHarness::spawn_with_content_in_dir(
|
||||
&binary,
|
||||
rows,
|
||||
cols,
|
||||
&content,
|
||||
&["--yolo", "--trust"],
|
||||
Some(content.home()),
|
||||
)
|
||||
.expect("spawn pager");
|
||||
|
||||
// Timed raw-output samples for asciicast.
|
||||
let t0 = Instant::now();
|
||||
let mut events: Vec<(f64, String)> = Vec::new();
|
||||
let mut raw_cursor = 0usize;
|
||||
let mut sample = |harness: &mut PtyHarness| {
|
||||
harness.update(Duration::from_millis(50));
|
||||
let raw = harness.raw_output();
|
||||
if raw.len() > raw_cursor {
|
||||
let chunk = &raw[raw_cursor..];
|
||||
raw_cursor = raw.len();
|
||||
if let Ok(s) = std::str::from_utf8(chunk) {
|
||||
if !s.is_empty() {
|
||||
events.push((t0.elapsed().as_secs_f64(), s.to_owned()));
|
||||
}
|
||||
} else {
|
||||
// Lossy for binary OSC/etc. still useful for video.
|
||||
let s = String::from_utf8_lossy(chunk).into_owned();
|
||||
if !s.is_empty() {
|
||||
events.push((t0.elapsed().as_secs_f64(), s));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Welcome
|
||||
let welcome_deadline = Instant::now() + WELCOME_TIMEOUT;
|
||||
loop {
|
||||
sample(&mut harness);
|
||||
if harness.contains_text(WELCOME_SCREEN_SENTINEL) {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < welcome_deadline,
|
||||
"welcome timeout; screen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
}
|
||||
// Hold welcome briefly for the video.
|
||||
for _ in 0..20 {
|
||||
sample(&mut harness);
|
||||
}
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
|
||||
// Phase 1: hunk-only first paint — capture the target line's styling at
|
||||
// first sighting. The 2.5k pad keeps the full-file HL slow enough that
|
||||
// this frame reliably precedes the upgrade.
|
||||
// Phase 2: in-place upgrade — poll until the SAME line's styling changes.
|
||||
let edit_deadline = Instant::now() + Duration::from_secs(90);
|
||||
let mut saw_edit = false;
|
||||
let mut first_styles: Option<String> = None;
|
||||
let mut upgraded_styles: Option<String> = None;
|
||||
let mut post_upgrade_html: Option<String> = None;
|
||||
loop {
|
||||
sample(&mut harness);
|
||||
let screen = harness.screen_contents();
|
||||
if !saw_edit && (screen.contains("Edit ") || screen.contains("queue_item.py")) {
|
||||
saw_edit = true;
|
||||
}
|
||||
if let Some(snap) = target_line_style_snapshot(&harness.screen_styled()) {
|
||||
match &first_styles {
|
||||
None => {
|
||||
first_styles = Some(snap);
|
||||
let _ = fs::write(
|
||||
PathBuf::from(ARTIFACT_DIR).join("frame_edit_first.html"),
|
||||
harness.screen_html(),
|
||||
);
|
||||
let _ = fs::write(
|
||||
PathBuf::from(ARTIFACT_DIR).join("frame_edit_first.txt"),
|
||||
&screen,
|
||||
);
|
||||
}
|
||||
Some(first) if upgraded_styles.is_none() && *first != snap => {
|
||||
upgraded_styles = Some(snap);
|
||||
let html = harness.screen_html();
|
||||
post_upgrade_html = Some(html.clone());
|
||||
let _ = fs::write(
|
||||
PathBuf::from(ARTIFACT_DIR).join("frame_edit_upgraded.html"),
|
||||
&html,
|
||||
);
|
||||
let _ = fs::write(
|
||||
PathBuf::from(ARTIFACT_DIR).join("frame_edit_upgraded.txt"),
|
||||
&screen,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if saw_edit && upgraded_styles.is_some() && screen.contains(DONE_SENTINEL) {
|
||||
break;
|
||||
}
|
||||
if Instant::now() > edit_deadline {
|
||||
panic!(
|
||||
"timeout waiting for edit HL flow (saw_edit={saw_edit} \
|
||||
first_styles={} upgraded={}); screen:\n{screen}",
|
||||
first_styles.is_some(),
|
||||
upgraded_styles.is_some(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Hold the upgraded view for the video (~3s).
|
||||
for _ in 0..60 {
|
||||
sample(&mut harness);
|
||||
}
|
||||
|
||||
// The in-place upgrade proof: the marker line's style runs changed after
|
||||
// the hunk-only first paint while its text stayed put.
|
||||
let first = first_styles.expect("target line styled snapshot at first paint");
|
||||
let upgraded = upgraded_styles.expect("target line styling must change (file-scoped upgrade)");
|
||||
assert_ne!(
|
||||
first, upgraded,
|
||||
"upgrade must restyle the target line in place"
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
// Final full dumps.
|
||||
let final_screen = harness.screen_contents();
|
||||
let final_html = harness.screen_html();
|
||||
fs::write(PathBuf::from(ARTIFACT_DIR).join("final.txt"), &final_screen).ok();
|
||||
fs::write(PathBuf::from(ARTIFACT_DIR).join("final.html"), &final_html).ok();
|
||||
fs::write(
|
||||
PathBuf::from(ARTIFACT_DIR).join("raw.ansi"),
|
||||
harness.raw_output(),
|
||||
)
|
||||
.ok();
|
||||
|
||||
let cast_path = PathBuf::from(ARTIFACT_DIR).join("edit-hl-demo.cast");
|
||||
write_asciicast(&cast_path, cols, rows, &events);
|
||||
eprintln!(
|
||||
"edit HL PTY artifacts → {ARTIFACT_DIR} (cast events={}, duration≈{:.1}s)",
|
||||
events.len(),
|
||||
events.last().map(|(t, _)| *t).unwrap_or(0.0)
|
||||
);
|
||||
|
||||
// Soft color proof: HTML after upgrade should include span styling (not plain text only).
|
||||
let html = post_upgrade_html.unwrap_or(final_html);
|
||||
assert!(
|
||||
html.contains("style=") || html.contains("<span"),
|
||||
"expected styled HTML for syntax colors; html len={}",
|
||||
html.len()
|
||||
);
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// **Edit-interject of the lone queued row must not brick the TUI.**
|
||||
///
|
||||
/// Regression e2e for the orphaned invisible `EditConfirm`: interjecting
|
||||
/// (Ctrl+Enter) a DIRTY edit of a LOCAL queue row that is also the LAST
|
||||
/// visible row empties the queue mid-flow — the pane auto-hide used to run
|
||||
/// its pane switch while still in `EditingQueued`, arming a confirm modal
|
||||
/// that never renders but consumes every subsequent key. The interjection
|
||||
/// itself still went out (toast + drain both looked healthy), so only an
|
||||
/// after-the-fact liveness probe catches the brick: on a broken binary the
|
||||
/// probe text never echoes and step 7 times out.
|
||||
///
|
||||
/// The queued message carries a pasted image (as in the original report) —
|
||||
/// that is also what forces it onto the LOCAL queue: mid-turn plain text
|
||||
/// takes the server-authoritative immediate-send path instead
|
||||
/// (`immediate_server_send_eligible`), which never hits the fixed code.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn edit_interject_lone_queued_row_keeps_tui_alive() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_chunk_delay(Some(Duration::from_millis(150)));
|
||||
// Turn 1 must stay open long enough for the ENTIRE mid-turn setup — type
|
||||
// the queued text, paste the image path, queue it, focus the queue pane,
|
||||
// enter edit, dirty it, Ctrl+Enter — to land WHILE it is still streaming.
|
||||
// Only then does the edit-interject drain into turn 1 (→ STEPTWO). Under
|
||||
// the ~60-way-parallel suite a short stream can collapse before the
|
||||
// interject lands (same flake class the lifecycle test documents), so
|
||||
// stream ~150 tokens (~22s) while STEPTWO still drains well inside its
|
||||
// 40s wait below.
|
||||
let step_one = {
|
||||
let mut s = String::from("STEPONE");
|
||||
for i in 0..150 {
|
||||
s.push_str(&format!(" streaming{i}"));
|
||||
}
|
||||
s
|
||||
};
|
||||
content.set_turns([
|
||||
step_one,
|
||||
"STEPTWO interjection acknowledged.".to_owned(),
|
||||
"STEPTHREE liveness prompt handled.".to_owned(),
|
||||
]);
|
||||
|
||||
// Image fixture under the isolated HOME; the pasted absolute path becomes
|
||||
// an `[Image #1]` composer chip (path-paste detection reads + decodes it).
|
||||
let png_path = content.home().join("queue-edit-fixture.png");
|
||||
std::fs::write(&png_path, PNG_32X32_GRAY).expect("write png fixture");
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness =
|
||||
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text("STEPONE", Duration::from_secs(30))
|
||||
.expect("step 1: turn streaming");
|
||||
|
||||
// Queue ONE image-bearing message → the lone LOCAL queue row. The prose
|
||||
// and the path must reach the pager as SEPARATE events: back-to-back
|
||||
// injects can land in one EventStream batch and get paste-coalesced,
|
||||
// merging the prose into the paste payload — and the paste pipeline is
|
||||
// whole-paste-or-nothing (`try_read_dropped_paths`), so a mixed
|
||||
// prose+path payload falls back to plain text instead of a chip. Gate
|
||||
// on the typed prose rendering before pasting the bare path alone.
|
||||
harness
|
||||
.inject_keys(b"brick repro payload ")
|
||||
.expect("type queued text");
|
||||
harness
|
||||
.wait_for_text("brick repro payload", Duration::from_secs(10))
|
||||
.expect("step 2a: typed prose echoed before the paste");
|
||||
harness
|
||||
.inject_keys(format!("\x1b[200~{}\x1b[201~", png_path.display()).as_bytes())
|
||||
.expect("paste png path");
|
||||
harness.update(Duration::from_millis(500));
|
||||
harness
|
||||
.wait_for_text("[Image #", Duration::from_secs(10))
|
||||
.expect("step 2b: image chip attached");
|
||||
harness.inject_keys(b"\r").expect("queue the message");
|
||||
// The `#1 ` row prefix is queue-pane-only chrome — the composer echo
|
||||
// (on screen since step 2a) can never match it, so this proves the
|
||||
// Enter actually queued the row.
|
||||
harness
|
||||
.wait_for_text("#1 brick repro payload", Duration::from_secs(10))
|
||||
.expect("step 3: message queued as row #1 in the queue pane");
|
||||
|
||||
// Edit the row (prompt info row flips to "editing queued #1"), dirty it,
|
||||
// then force-interject the edit.
|
||||
harness
|
||||
.inject_keys(CTRL_SEMICOLON)
|
||||
.expect("focus queue pane");
|
||||
harness.update(Duration::from_millis(300));
|
||||
harness.inject_keys(b"e").expect("edit queued row");
|
||||
harness
|
||||
.wait_for_text("editing queued #1", Duration::from_secs(10))
|
||||
.expect("step 4: edit mode entered");
|
||||
harness.inject_keys(b"EDITED ").expect("dirty the edit");
|
||||
harness.inject_keys(CTRL_ENTER).expect("interject the edit");
|
||||
|
||||
harness
|
||||
.wait_for_text("Interjection sent", Duration::from_secs(10))
|
||||
.expect("step 5: interjection toast");
|
||||
harness
|
||||
.wait_for_text("STEPTWO", Duration::from_secs(40))
|
||||
.expect("step 6: interjection drained into turn 1");
|
||||
|
||||
// THE regression assertion — liveness probe. Both prior steps pass on a
|
||||
// broken binary too (the interjection dispatch is not gated by the agent
|
||||
// modal); what breaks is everything AFTER: the orphaned EditConfirm eats
|
||||
// all input. Space first: the queue-pane auto-hide left focus on the
|
||||
// scrollback, where Space focuses the prompt (on a broken binary the
|
||||
// modal eats the Space as well, so the probe below never echoes).
|
||||
harness
|
||||
.inject_keys(b" ")
|
||||
.expect("focus prompt from scrollback");
|
||||
harness.update(Duration::from_millis(200));
|
||||
harness
|
||||
.inject_keys(b"liveness-probe-xyz")
|
||||
.expect("type liveness probe");
|
||||
harness
|
||||
.wait_for_text("liveness-probe-xyz", Duration::from_secs(10))
|
||||
.expect("step 7: typed input echoes — an orphaned EditConfirm would eat it");
|
||||
harness.inject_keys(b"\r").expect("submit liveness probe");
|
||||
harness
|
||||
.wait_for_text("STEPTHREE", Duration::from_secs(40))
|
||||
.expect("step 8: liveness prompt round-trips input, dispatch, and wire");
|
||||
|
||||
// Wire checks: final request's user_query sequence is exactly
|
||||
// [prompt, edited interjection (with wire prefix), liveness probe].
|
||||
let bodies = content.request_bodies();
|
||||
let last = bodies.last().expect("final request recorded");
|
||||
// User-role context preambles (user_info, skill reminders) don't carry
|
||||
// <user_query>; real prompts and interjections do. Content is a plain
|
||||
// string OR a parts array (the image-bearing interjection), so extract
|
||||
// text parts-aware instead of `as_str`.
|
||||
let finals: Vec<String> = last["messages"]
|
||||
.as_array()
|
||||
.expect("messages array")
|
||||
.iter()
|
||||
.filter(|m| m["role"] == "user")
|
||||
.map(message_text)
|
||||
.filter(|c| c.contains("<user_query>"))
|
||||
.collect();
|
||||
assert_eq!(3, finals.len(), "expected 3 user messages: {finals:#?}");
|
||||
assert!(finals[0].contains(PROMPT), "first: {finals:#?}");
|
||||
assert!(
|
||||
finals[1].contains("EDITED")
|
||||
&& finals[1].contains("brick repro payload")
|
||||
&& finals[1].contains(INTERJECTION_WIRE_PREFIX),
|
||||
"second must be the EDITED interjection: {finals:#?}"
|
||||
);
|
||||
assert!(
|
||||
finals[2].contains("liveness-probe-xyz"),
|
||||
"third: {finals:#?}"
|
||||
);
|
||||
// The queued row's stored image must ride the interjection to the wire.
|
||||
let interjection_bodies: Vec<&serde_json::Value> = bodies
|
||||
.iter()
|
||||
.filter(|b| b.to_string().contains("EDITED"))
|
||||
.collect();
|
||||
assert!(
|
||||
!interjection_bodies.is_empty(),
|
||||
"no request carried the edited interjection"
|
||||
);
|
||||
assert!(
|
||||
interjection_bodies.iter().any(|b| contains_image_part(b)),
|
||||
"queued row's image never reached the wire: {interjection_bodies:#?}"
|
||||
);
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
|
||||
/// A user message's text: plain-string content verbatim, parts-array content
|
||||
/// (image-bearing messages) as the joined `text` parts.
|
||||
fn message_text(m: &serde_json::Value) -> String {
|
||||
match &m["content"] {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
serde_json::Value::Array(parts) => parts
|
||||
.iter()
|
||||
.filter_map(|p| p["text"].as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any nested object is an image content part — same shape the
|
||||
/// harness's scenario assertions accept: a `type` containing "image"
|
||||
/// (`image_url`, `input_image`, …) or inline `mime_type` + `data`.
|
||||
fn contains_image_part(value: &serde_json::Value) -> bool {
|
||||
match value {
|
||||
serde_json::Value::Object(map) => {
|
||||
let is_image = map
|
||||
.get("type")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|ty| ty.contains("image"))
|
||||
|| (map.contains_key("mime_type") && map.contains_key("data"));
|
||||
is_image || map.values().any(contains_image_part)
|
||||
}
|
||||
serde_json::Value::Array(values) => values.iter().any(contains_image_part),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Valid 32×32 8-bit grayscale PNG (signature + IHDR + IDAT + IEND, CRCs
|
||||
/// correct, IDAT zlib round-trips). Hardcoded rather than encoded via the
|
||||
/// `image` dep so the fixture is byte-stable and encoder-independent.
|
||||
/// 32×32 = 1024 total pixels clears the API/client 512-total-pixel floor
|
||||
/// (`MIN_VISION_TOTAL_PX`) so the image rides the interjection to the wire
|
||||
/// instead of being replaced by an `image_dropped_notice`.
|
||||
const PNG_32X32_GRAY: &[u8] = &[
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x20, 0x08, 0x00, 0x00, 0x00, 0x00, 0x56, 0x11, 0x25,
|
||||
0x28, 0x00, 0x00, 0x00, 0x16, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0x68, 0x20, 0x00, 0x18,
|
||||
0x46, 0x15, 0x8c, 0x2a, 0x18, 0x55, 0x30, 0x52, 0x15, 0x00, 0x00, 0x42, 0x00, 0x00, 0x1f, 0x37,
|
||||
0x97, 0x36, 0x00, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
];
|
||||
@@ -0,0 +1,116 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
const DONE_SENTINEL: &str = "EDIT_MERGE_PAR_DONE";
|
||||
|
||||
const FIXTURE: &str = "parallel_fix.py";
|
||||
|
||||
/// PTY: with `collapsed_edit_blocks` enabled, TWO search_replace calls to the
|
||||
/// same file issued in ONE model turn (parallel tool calls) coalesce into a
|
||||
/// single Edit row with the summed diffstat, regardless of the order their
|
||||
/// completions land in.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "PTY e2e; run with cargo test -p kigi-tui --test pty_e2e -- --ignored"]
|
||||
async fn edit_merge_parallel_pty() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
seed_ui_config(&content, "collapsed_edit_blocks = true");
|
||||
|
||||
let target = content.home().join(FIXTURE);
|
||||
std::fs::write(
|
||||
&target,
|
||||
"# parallel merge fixture\n\
|
||||
first = \"one\"\n\
|
||||
second = \"two\"\n\
|
||||
third = \"three\"\n\
|
||||
fourth = \"four\"\n\
|
||||
fifth = \"five\"\n\
|
||||
sixth = \"six\"\n\
|
||||
seventh = \"seven\"\n\
|
||||
eighth = \"eight\"\n\
|
||||
ninth = \"nine\"\n\
|
||||
tenth = \"ten\"\n",
|
||||
)
|
||||
.expect("write fixture");
|
||||
let abs = dunce::canonicalize(&target).unwrap_or(target.clone());
|
||||
|
||||
// Non-overlapping 1:1 replacements so both calls succeed against the
|
||||
// same starting file whatever order the shell runs them in.
|
||||
let args_a = json!({
|
||||
"file_path": abs.to_string_lossy(),
|
||||
"old_string": "second = \"two\"",
|
||||
"new_string": "second = \"two\" # PAR_EDIT_A",
|
||||
})
|
||||
.to_string();
|
||||
let args_b = json!({
|
||||
"file_path": abs.to_string_lossy(),
|
||||
"old_string": "ninth = \"nine\"",
|
||||
"new_string": "ninth = \"nine\" # PAR_EDIT_B",
|
||||
})
|
||||
.to_string();
|
||||
enqueue_parallel_tool_turn(
|
||||
&content,
|
||||
&[
|
||||
("call_par_1", "search_replace", args_a),
|
||||
("call_par_2", "search_replace", args_b),
|
||||
],
|
||||
);
|
||||
content.set_response(DONE_SENTINEL);
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness = PtyHarness::spawn_with_content_in_dir(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&content,
|
||||
&["--yolo", "--trust"],
|
||||
Some(content.home()),
|
||||
)
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome");
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(DONE_SENTINEL, Duration::from_secs(90))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"expected settled transcript; got:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
|
||||
// One merged row summing both calls' diffstats.
|
||||
harness
|
||||
.wait_for_text(&format!("Edit {FIXTURE} +2/-2"), Duration::from_secs(20))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"expected one merged `Edit {FIXTURE} +2/-2` row; got:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
let screen = harness.screen_contents();
|
||||
let edit_rows = screen
|
||||
.lines()
|
||||
.filter(|l| l.contains(&format!("Edit {FIXTURE}")))
|
||||
.count();
|
||||
assert_eq!(
|
||||
edit_rows, 1,
|
||||
"parallel same-file edits must render as ONE Edit row\nscreen:\n{screen}"
|
||||
);
|
||||
assert!(
|
||||
!screen.contains("+1/-1"),
|
||||
"per-call diffstats must not survive the merge\nscreen:\n{screen}"
|
||||
);
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
#[allow(unused_imports)]
|
||||
use super::scroll::*;
|
||||
|
||||
const DONE_SENTINEL: &str = "EDIT_MERGE_SEQ_DONE";
|
||||
|
||||
/// Agent text between the merged run and the break-case edit.
|
||||
const BREAK_TEXT_SENTINEL: &str = "MERGE_BREAK_TEXT";
|
||||
|
||||
/// Markers planted by the scripted edits: visible only in the diff body.
|
||||
const EDIT_ONE_MARK: &str = "EDIT_ONE_MARK";
|
||||
const EDIT_THREE_MARK: &str = "EDIT_THREE_MARK";
|
||||
|
||||
const FIXTURE: &str = "merge_fix.py";
|
||||
|
||||
/// One line of the fixture per word, so each edit is a 1:1 line replacement
|
||||
/// (`+1/-1`) and line numbers stay stable across the whole scripted turn.
|
||||
fn fixture_text() -> String {
|
||||
let words = [
|
||||
"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india",
|
||||
"juliett", "kilo", "lima", "mike", "november", "oscar", "papa", "quebec", "romeo",
|
||||
"sierra", "tango", "uniform", "victor", "whiskey", "xray", "yankee", "zulu", "omega",
|
||||
"finale",
|
||||
];
|
||||
let mut text = String::from("# merge fixture\n");
|
||||
for (i, word) in words.iter().enumerate() {
|
||||
text.push_str(&format!("v{:02} = \"{word}\"\n", i + 1));
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn edit_args(abs: &Path, var: &str, word: &str, mark: &str) -> String {
|
||||
json!({
|
||||
"file_path": abs.to_string_lossy(),
|
||||
"old_string": format!("{var} = \"{word}\""),
|
||||
"new_string": format!("{var} = \"{word}\" # {mark}"),
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Count screen rows carrying an Edit header for the fixture.
|
||||
fn edit_header_rows(screen: &str) -> usize {
|
||||
screen
|
||||
.lines()
|
||||
.filter(|l| l.contains(&format!("Edit {FIXTURE}")))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// PTY: with `collapsed_edit_blocks` enabled, three sequential same-file
|
||||
/// edits coalesce into ONE Edit row whose header sums the diffstat (`+3/-3`);
|
||||
/// expanding it shows every hunk with `… N unchanged lines` gap markers
|
||||
/// between them. A fourth edit arriving after intervening agent text stays a
|
||||
/// separate second Edit row — counted after wheeling back above the second
|
||||
/// submit's page-flip, which pins the new prompt to the pane top and scrolls
|
||||
/// turn 1 out of view by design. (Flag off, coalescing is disabled entirely —
|
||||
/// pinned by the tracker unit test.)
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "PTY e2e; run with cargo test -p kigi-tui --test pty_e2e -- --ignored"]
|
||||
async fn edit_merge_sequential_pty() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
seed_ui_config(&content, "collapsed_edit_blocks = true");
|
||||
|
||||
let target = content.home().join(FIXTURE);
|
||||
std::fs::write(&target, fixture_text()).expect("write fixture");
|
||||
let abs = dunce::canonicalize(&target).unwrap_or(target.clone());
|
||||
|
||||
// Three 1:1 replacements at widely separated, increasing lines so every
|
||||
// merged-hunk gap is computable (edits sit ~11 lines apart, context ±3).
|
||||
enqueue_tool_turn(
|
||||
&content,
|
||||
"call_sr_1",
|
||||
"search_replace",
|
||||
edit_args(&abs, "v03", "charlie", EDIT_ONE_MARK),
|
||||
);
|
||||
enqueue_tool_turn(
|
||||
&content,
|
||||
"call_sr_2",
|
||||
"search_replace",
|
||||
edit_args(&abs, "v14", "november", "EDIT_TWO_MARK"),
|
||||
);
|
||||
enqueue_tool_turn(
|
||||
&content,
|
||||
"call_sr_3",
|
||||
"search_replace",
|
||||
edit_args(&abs, "v25", "yankee", EDIT_THREE_MARK),
|
||||
);
|
||||
// The first prompt's turn ends on agent text — the break for the run.
|
||||
let break_text = format!("{BREAK_TEXT_SENTINEL} first batch settled.");
|
||||
content.enqueue_response(
|
||||
"/v1/responses",
|
||||
ScriptedResponse::sse(responses_api_message_events(&break_text)),
|
||||
);
|
||||
content.enqueue_response(
|
||||
"/v1/chat/completions",
|
||||
ScriptedResponse::sse(chat_completions_message_events(&break_text)),
|
||||
);
|
||||
content.set_response(DONE_SENTINEL);
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness = PtyHarness::spawn_with_content_in_dir(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&content,
|
||||
&["--yolo", "--trust"],
|
||||
Some(content.home()),
|
||||
)
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome");
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(BREAK_TEXT_SENTINEL, Duration::from_secs(90))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"expected settled first batch; got:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
|
||||
// One merged row with the summed diffstat; the per-call counts are gone.
|
||||
harness
|
||||
.wait_for_text(&format!("Edit {FIXTURE} +3/-3"), Duration::from_secs(20))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"expected one merged `Edit {FIXTURE} +3/-3` row; got:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
let screen = harness.screen_contents();
|
||||
assert_eq!(
|
||||
edit_header_rows(&screen),
|
||||
1,
|
||||
"three adjacent edits must render as ONE Edit row\nscreen:\n{screen}"
|
||||
);
|
||||
assert!(
|
||||
!screen.contains("+1/-1") && !screen.contains("+2/-2"),
|
||||
"per-call diffstats must not survive the merge\nscreen:\n{screen}"
|
||||
);
|
||||
|
||||
// Expand the merged block: all hunks with gap markers between them.
|
||||
let (row, col) = locate_screen_text(&screen, &format!("Edit {FIXTURE}")).unwrap_or_else(|| {
|
||||
panic!("could not locate the merged Edit header; screen:\n{screen}");
|
||||
});
|
||||
let dbl = format!(
|
||||
"{}{}{}{}",
|
||||
sgr_mouse(0, row, col, 'M'),
|
||||
sgr_mouse(0, row, col, 'm'),
|
||||
sgr_mouse(0, row, col, 'M'),
|
||||
sgr_mouse(0, row, col, 'm'),
|
||||
);
|
||||
harness
|
||||
.inject_keys(dbl.as_bytes())
|
||||
.expect("double-click header");
|
||||
harness
|
||||
.wait_for_text(" unchanged lines", Duration::from_secs(10))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"expanded merged block must show gap markers; got:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
harness.contains_text(EDIT_ONE_MARK) && harness.contains_text(EDIT_THREE_MARK),
|
||||
"expanded body must include hunks from the first AND last merged edit\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
// Fold the block back so the final screen keeps every row visible.
|
||||
let screen = harness.screen_contents();
|
||||
let (row, col) = locate_screen_text(&screen, &format!("Edit {FIXTURE}")).unwrap_or_else(|| {
|
||||
panic!("could not re-locate the merged Edit header; screen:\n{screen}");
|
||||
});
|
||||
let dbl = format!(
|
||||
"{}{}{}{}",
|
||||
sgr_mouse(0, row, col, 'M'),
|
||||
sgr_mouse(0, row, col, 'm'),
|
||||
sgr_mouse(0, row, col, 'M'),
|
||||
sgr_mouse(0, row, col, 'm'),
|
||||
);
|
||||
harness
|
||||
.inject_keys(dbl.as_bytes())
|
||||
.expect("double-click to fold");
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while harness.contains_text(EDIT_ONE_MARK) {
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"merged block must fold back\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
harness.update(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
// BREAK case: a fourth same-file edit after the agent-text turn stays a
|
||||
// separate row instead of merging across the visible text entry.
|
||||
harness
|
||||
.wait_for_turn_idle(Duration::from_secs(15))
|
||||
.expect("turn idle after break-case edit");
|
||||
enqueue_tool_turn(
|
||||
&content,
|
||||
"call_sr_4",
|
||||
"search_replace",
|
||||
edit_args(&abs, "v10", "juliett", "EDIT_FOUR_MARK"),
|
||||
);
|
||||
harness
|
||||
.inject_keys(b"go again\r")
|
||||
.expect("submit second prompt");
|
||||
harness
|
||||
.wait_for_text(DONE_SENTINEL, Duration::from_secs(90))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"expected settled second turn; got:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
harness
|
||||
.wait_for_text(&format!("Edit {FIXTURE} +1/-1"), Duration::from_secs(20))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"the post-break edit must keep its own `+1/-1` row; got:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
// The second submit page-flipped the viewport (the new prompt pins to the
|
||||
// pane top — dispatch/queue.rs `scroll_to_entry_top` +
|
||||
// `enable_follow_with_preserve`), so turn 1 legitimately sits above the
|
||||
// fold. Wheel back to the transcript top — the whole thing fits one
|
||||
// screen — before counting rows across both turns.
|
||||
send_wheel_burst(
|
||||
&mut harness,
|
||||
SGR_SCROLL_UP,
|
||||
30,
|
||||
WHEEL_ROW,
|
||||
WHEEL_COL,
|
||||
Duration::ZERO,
|
||||
);
|
||||
harness
|
||||
.wait_for_text(&format!("Edit {FIXTURE} +3/-3"), Duration::from_secs(10))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"the merged row must scroll back into view with its summed \
|
||||
diffstat intact; got:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
let screen = harness.screen_contents();
|
||||
assert_eq!(
|
||||
edit_header_rows(&screen),
|
||||
2,
|
||||
"expected the merged row plus one separate post-break row\nscreen:\n{screen}"
|
||||
);
|
||||
assert!(
|
||||
screen.contains("+1/-1"),
|
||||
"the post-break row keeps its own diffstat\nscreen:\n{screen}"
|
||||
);
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
#[cfg(unix)]
|
||||
write_cast_if_requested(&harness, "edit_merge_sequential.cast");
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// Mid-turn: queue a follow-up with Enter, then bare Enter on the empty
|
||||
/// composer sends that top row now — cancel-and-send: the running turn is
|
||||
/// cancelled silently and the row runs as its own next turn, arriving on the
|
||||
/// wire as a standard `<user_query>` prompt with no interjection preamble.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn empty_enter_force_sends_top_queued() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
// Gate turn 1's terminal event so the queue + empty-Enter provably land
|
||||
// mid-turn regardless of suite load.
|
||||
content.hold_agent_completions();
|
||||
content.set_turns([
|
||||
slow_turn_text("TURNONE"),
|
||||
"TURNTWO reply to the promoted follow-up.".to_owned(),
|
||||
]);
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness =
|
||||
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text("TURNONE", Duration::from_secs(30))
|
||||
.expect("turn 1 streaming");
|
||||
|
||||
harness
|
||||
.inject_keys(b"please also check the logs\r")
|
||||
.expect("queue follow-up via Enter");
|
||||
harness
|
||||
.wait_for_text("please also check the logs", Duration::from_secs(10))
|
||||
.expect("queued text visible");
|
||||
|
||||
// Composer is empty after queue; bare Enter sends the top row now. The
|
||||
// shell cancels turn 1 (the abort beats the held completion) and promotes
|
||||
// the row to run as turn 2.
|
||||
harness.inject_keys(b"\r").expect("empty Enter send-now");
|
||||
content.release_agent_completions();
|
||||
// The promoted row renders as a standard "❯ " prompt block via the
|
||||
// turn-start adoption (the arrow prefix distinguishes the committed block
|
||||
// from the prefix-less queue row).
|
||||
harness
|
||||
.wait_for_text(
|
||||
"\u{276F} please also check the logs",
|
||||
Duration::from_secs(15),
|
||||
)
|
||||
.expect("promoted prompt scrollback chrome");
|
||||
|
||||
harness
|
||||
.wait_for_text("TURNTWO", Duration::from_secs(40))
|
||||
.expect("promoted turn reply");
|
||||
|
||||
// The send-now cancel is silent: no cancelled marker between the partial
|
||||
// turn-1 output and the promoted prompt.
|
||||
assert!(
|
||||
!harness.contains_text("Turn cancelled by user"),
|
||||
"send-now cancel must not render a cancelled marker\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
let users = all_user_message_blobs(&content);
|
||||
let promoted = users
|
||||
.iter()
|
||||
.find(|u| u.contains("please also check the logs"))
|
||||
.unwrap_or_else(|| panic!("queued follow-up never reached the wire: {users:#?}"));
|
||||
assert!(
|
||||
!promoted.contains(INTERJECTION_WIRE_PREFIX),
|
||||
"send-now must not use the interjection preamble: {promoted}"
|
||||
);
|
||||
assert!(
|
||||
promoted.contains("<user_query>"),
|
||||
"send-now must arrive as a standard user_query prompt: {promoted}"
|
||||
);
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// With two mid-turn queued rows, empty Enter sends the **top** (first) row
|
||||
/// now — not the most recently typed one. Cancel-and-send: the running turn
|
||||
/// is cancelled silently, alpha runs as its own next turn (no interjection
|
||||
/// preamble), and bravo stays queued to promote afterwards.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn empty_enter_sends_top_not_last_of_two() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
// Gate turn 1's terminal event so both queues + the empty Enter provably
|
||||
// land while turn 1 is still the running turn.
|
||||
content.hold_agent_completions();
|
||||
content.set_turns([
|
||||
slow_turn_text("TURNONE"),
|
||||
"TURNTWO top-row send-now acknowledged.".to_owned(),
|
||||
"TURNTHREE remaining queue promoted.".to_owned(),
|
||||
]);
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness =
|
||||
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text("TURNONE", Duration::from_secs(45))
|
||||
.expect("turn 1 streaming");
|
||||
|
||||
harness
|
||||
.inject_keys(b"queue-alpha-top\r")
|
||||
.expect("queue alpha");
|
||||
harness
|
||||
.wait_for_text("queue-alpha-top", Duration::from_secs(20))
|
||||
.expect("alpha visible");
|
||||
harness
|
||||
.inject_keys(b"queue-bravo-later\r")
|
||||
.expect("queue bravo");
|
||||
harness
|
||||
.wait_for_text("queue-bravo-later", Duration::from_secs(20))
|
||||
.expect("bravo visible");
|
||||
|
||||
harness
|
||||
.inject_keys(b"\r")
|
||||
.expect("empty Enter send-now top");
|
||||
content.release_agent_completions();
|
||||
// Alpha (the promoted TOP row) then bravo drain back-to-back. Each
|
||||
// promoted "❯ …" block and the intermediate TURNTWO reply is scrolled
|
||||
// above the viewport by the next turn's start-adoption before a 100ms poll
|
||||
// can observe it, so gating on those transient markers is inherently racy.
|
||||
// Gate only on the FINAL reply (stable at the viewport head) and prove the
|
||||
// top-row order + send-now silence via the recorded wire below, which is
|
||||
// not subject to scrolling.
|
||||
harness
|
||||
.wait_for_text("TURNTHREE", Duration::from_secs(90))
|
||||
.expect("all queued turns drained through to the final reply");
|
||||
|
||||
// The send-now cancel of turn 1 is silent.
|
||||
assert!(
|
||||
!harness.contains_text("Turn cancelled by user"),
|
||||
"send-now cancel must not render a cancelled marker\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
let users = all_user_message_blobs(&content);
|
||||
let alpha = users
|
||||
.iter()
|
||||
.find(|u| u.contains("queue-alpha-top"))
|
||||
.unwrap_or_else(|| panic!("top row never on wire: {users:#?}"));
|
||||
assert!(
|
||||
!alpha.contains(INTERJECTION_WIRE_PREFIX),
|
||||
"send-now must not use the interjection preamble: {alpha}"
|
||||
);
|
||||
assert!(
|
||||
alpha.contains("<user_query>"),
|
||||
"send-now must arrive as a standard user_query prompt: {alpha}"
|
||||
);
|
||||
|
||||
// The final request's user sequence proves the order: prompt, then the
|
||||
// TOP row (alpha), then bravo — never bravo before alpha.
|
||||
let bodies = content.request_bodies();
|
||||
let last = bodies.last().expect("final request recorded");
|
||||
let finals: Vec<String> = last["messages"]
|
||||
.as_array()
|
||||
.expect("messages array")
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
m["role"] == "user"
|
||||
&& m["content"]
|
||||
.as_str()
|
||||
.is_some_and(|c| c.contains("<user_query>"))
|
||||
})
|
||||
.map(|m| m["content"].as_str().unwrap_or_default().to_owned())
|
||||
.collect();
|
||||
assert_eq!(3, finals.len(), "expected 3 user messages: {finals:#?}");
|
||||
assert!(finals[0].contains(PROMPT), "first: {finals:#?}");
|
||||
assert!(
|
||||
finals[1].contains("queue-alpha-top"),
|
||||
"second must be the TOP row: {finals:#?}"
|
||||
);
|
||||
assert!(
|
||||
finals[2].contains("queue-bravo-later"),
|
||||
"third must be bravo: {finals:#?}"
|
||||
);
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
//! PTY: a parked wait produces two static markers — the park pushes "Turn
|
||||
//! completed in X. 1 command still running…" and the turn that follows ends
|
||||
//! with its own marker below. A prompt typed mid-park is cancel-and-send:
|
||||
//! the shell silently cancels the parked turn (no "Turn cancelled by user"
|
||||
//! marker) and runs the message as its OWN next turn, whose completion pushes
|
||||
//! the second marker; the park line is never edited, so the transcript holds
|
||||
//! BOTH markers with the park text intact, in order.
|
||||
//!
|
||||
//! Wire journey, fully flag-file driven — no timing windows: the model
|
||||
//! backgrounds a flag-gated command, then runs a flag-gated foreground hold
|
||||
//! while the test extracts the runtime task id from the request bodies
|
||||
//! (`<task-id>` envelope; a UUID minted by the terminal actor, so it cannot
|
||||
//! be scripted statically) and enqueues the blocking
|
||||
//! `get_command_or_subagent_output(timeout)` on the real id — the pager
|
||||
//! parks. Typing mid-park cancels-and-sends and the fixed-text reply ends
|
||||
//! the new turn with the second, final marker.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
|
||||
async fn endline_park_two_static_markers() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
// Gates the background command the markers count (released at the end).
|
||||
let park_flag = content.home().join("endline_park_flag");
|
||||
// Gates the id-extraction hold: created once the wait script is enqueued.
|
||||
let id_ready_flag = content.home().join("endline_id_ready_flag");
|
||||
|
||||
let gated_loop = |flag: &std::path::Path| {
|
||||
format!("while [ ! -e {} ]; do /bin/sleep 0.2; done", flag.display())
|
||||
};
|
||||
|
||||
// Tool call 1: a flag-gated background command — the work both markers
|
||||
// snapshot ("1 command still running…").
|
||||
let bg_args = json!({
|
||||
"command": gated_loop(&park_flag),
|
||||
"description": "flag-gated command",
|
||||
"is_background": true
|
||||
})
|
||||
.to_string();
|
||||
content.enqueue_response(
|
||||
"/v1/responses",
|
||||
ScriptedResponse::sse(responses_api_tool_call_events(
|
||||
"call_endline_bg",
|
||||
"run_terminal_command",
|
||||
&bg_args,
|
||||
)),
|
||||
);
|
||||
content.enqueue_response(
|
||||
"/v1/chat/completions",
|
||||
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
|
||||
"call_endline_bg",
|
||||
"run_terminal_command",
|
||||
&bg_args,
|
||||
)),
|
||||
);
|
||||
|
||||
// Tool call 2: the flag-gated foreground hold — the turn idles here (no
|
||||
// deadline) until the test has extracted the task id and enqueued the
|
||||
// wait script.
|
||||
let id_hold_args = json!({
|
||||
"command": gated_loop(&id_ready_flag),
|
||||
"description": "hold for id extraction"
|
||||
})
|
||||
.to_string();
|
||||
content.enqueue_response(
|
||||
"/v1/responses",
|
||||
ScriptedResponse::sse(responses_api_tool_call_events(
|
||||
"call_endline_id_hold",
|
||||
"run_terminal_command",
|
||||
&id_hold_args,
|
||||
)),
|
||||
);
|
||||
content.enqueue_response(
|
||||
"/v1/chat/completions",
|
||||
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
|
||||
"call_endline_id_hold",
|
||||
"run_terminal_command",
|
||||
&id_hold_args,
|
||||
)),
|
||||
);
|
||||
|
||||
// Fallback for the cancel-and-sent prompt's turn: plain text ends it.
|
||||
content.set_response("ENDLINE_FINAL_ANSWER");
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
// --yolo skips the bash permission prompt; --trust skips the folder-trust gate.
|
||||
let mut harness = PtyHarness::spawn_with_content_in_dir(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&content,
|
||||
&["--yolo", "--trust"],
|
||||
Some(content.home()),
|
||||
)
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome");
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
|
||||
// The runtime task id rides in the tool result of the follow-up request
|
||||
// (the same request the id-hold script answers), inside a
|
||||
// <task-id>…</task-id> envelope.
|
||||
let task_id = poll_for(Duration::from_secs(60), || {
|
||||
content
|
||||
.request_bodies()
|
||||
.iter()
|
||||
.find_map(|b| extract_task_id(&b.to_string()))
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"no <task-id> in any request body\n--- non-system messages ---\n{}\n--- screen ---\n{}",
|
||||
dump_non_system_messages(&content.request_bodies()),
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
|
||||
// Tool call 3: block on the REAL task — the interruptible wait the pager
|
||||
// parks on (600s survives the wait cap; the send-now cancel aborts it).
|
||||
let wait_args = json!({
|
||||
"task_ids": [task_id],
|
||||
"timeout_ms": 600_000
|
||||
})
|
||||
.to_string();
|
||||
content.enqueue_response(
|
||||
"/v1/responses",
|
||||
ScriptedResponse::sse(responses_api_tool_call_events(
|
||||
"call_endline_wait",
|
||||
"get_command_or_subagent_output",
|
||||
&wait_args,
|
||||
)),
|
||||
);
|
||||
content.enqueue_response(
|
||||
"/v1/chat/completions",
|
||||
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
|
||||
"call_endline_wait",
|
||||
"get_command_or_subagent_output",
|
||||
&wait_args,
|
||||
)),
|
||||
);
|
||||
|
||||
// Everything downstream is scripted — let the id-extraction hold finish.
|
||||
std::fs::write(&id_ready_flag, b"ready").expect("release id-extraction hold");
|
||||
|
||||
// Park: the first static marker reads as a completion with the count.
|
||||
harness
|
||||
.wait_for_text("1 command still running", Duration::from_secs(90))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"parked marker never appeared; screen:\n{}\n--- non-system messages ---\n{}",
|
||||
harness.screen_contents(),
|
||||
dump_non_system_messages(&content.request_bodies())
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
harness.screen_contents().contains("Worked for"),
|
||||
"the parked marker keeps the completion prefix; screen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
// Type mid-park: Enter is cancel-and-send (the wait makes it a sendable
|
||||
// parked turn) — the parked turn is cancelled silently and the message
|
||||
// runs as its own next turn.
|
||||
harness
|
||||
.inject_keys(b"hurry up please")
|
||||
.expect("type mid-park");
|
||||
harness.update(Duration::from_millis(300));
|
||||
harness.inject_keys(b"\r").expect("submit mid-park prompt");
|
||||
|
||||
harness
|
||||
.wait_for_text("ENDLINE_FINAL_ANSWER", Duration::from_secs(90))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"cancel-and-sent turn never settled; screen:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
|
||||
// The turn-start adoption scrolls the promoted "❯ hurry up please" block
|
||||
// to the viewport top, pushing the park marker above the screen. Scroll
|
||||
// the transcript back to its head so both markers are inspectable.
|
||||
harness.inject_keys(b"\t").expect("focus scrollback (tab)");
|
||||
harness.update(Duration::from_millis(300));
|
||||
harness.inject_keys(b"g").expect("goto transcript top");
|
||||
|
||||
// Two static markers: the park line unchanged above the promoted prompt
|
||||
// and the new turn's final marker (also counting the still-gated command)
|
||||
// below it — with NO cancelled marker anywhere (silent send-now cancel).
|
||||
let two_markers = wait_until(Duration::from_secs(90), || {
|
||||
harness.update(Duration::from_millis(100));
|
||||
let screen = harness.screen_contents();
|
||||
// Positional: park marker ABOVE the promoted prompt ABOVE the final
|
||||
// marker (screen text is row-major), both markers intact.
|
||||
screen.matches("Worked for").count() == 2
|
||||
&& screen.matches("1 command still running").count() == 2
|
||||
&& !screen.contains("Turn cancelled by user")
|
||||
&& matches!(
|
||||
(
|
||||
screen.find("Worked for"),
|
||||
screen.find("hurry up please"),
|
||||
screen.rfind("Worked for"),
|
||||
),
|
||||
(Some(park), Some(prompt), Some(fin)) if park < prompt && prompt < fin
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
two_markers,
|
||||
"expected park marker, promoted prompt, then the fresh final marker in order \
|
||||
(and no cancelled marker); screen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
write_cast_if_requested(&harness, "endline_park_two_static_markers.cast");
|
||||
|
||||
// Release the flag-gated command so nothing outlives the harness teardown.
|
||||
std::fs::write(&park_flag, b"done").expect("release flag");
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//! PTY: every wake turn ends with a real marker — a turn ends with three
|
||||
//! flag-gated background commands running ("3 commands still running…"), and
|
||||
//! each released flag lands a completion chip, the auto-wake response, then a
|
||||
//! FRESH wake-end marker snapshotting the remaining counts ("2 …", "1 …"),
|
||||
//! while every earlier line stays unchanged above (nothing mutates). The last
|
||||
//! wake's marker is the plain form ("Worked for X." — zero left), and
|
||||
//! no after-chip work-only status lines appear anywhere: the shell stamps
|
||||
//! `will_wake` on each completion, so the wake markers carry the counts.
|
||||
//!
|
||||
//! Positional chain asserted at the end: marker(3) < chip < wake reply <
|
||||
//! marker(2) < chip < reply < marker(1) < chip < reply < plain final marker.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// One flag-gated background command per released flag file.
|
||||
#[cfg(unix)]
|
||||
const TASKS: usize = 3;
|
||||
|
||||
/// Taller than [`DEFAULT_ROWS`]: the full chain (initial turn + three wake
|
||||
/// turns) must stay on screen at once for the positional asserts.
|
||||
#[cfg(unix)]
|
||||
const ROWS: u16 = 70;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
|
||||
async fn endline_wake_markers_close_each_wakeup() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
let flags: Vec<std::path::PathBuf> = (0..TASKS)
|
||||
.map(|i| content.home().join(format!("endline_status_flag_{i}")))
|
||||
.collect();
|
||||
|
||||
// The turn backgrounds one flag-gated command per tool call…
|
||||
for (i, flag) in flags.iter().enumerate() {
|
||||
let args = json!({
|
||||
"command": format!(
|
||||
"while [ ! -e {} ]; do /bin/sleep 0.2; done",
|
||||
flag.display()
|
||||
),
|
||||
"description": format!("flag-gated command {i}"),
|
||||
"is_background": true
|
||||
})
|
||||
.to_string();
|
||||
let call_id = format!("call_endline_status_{i}");
|
||||
content.enqueue_response(
|
||||
"/v1/responses",
|
||||
ScriptedResponse::sse(responses_api_tool_call_events(
|
||||
&call_id,
|
||||
"run_terminal_command",
|
||||
&args,
|
||||
)),
|
||||
);
|
||||
content.enqueue_response(
|
||||
"/v1/chat/completions",
|
||||
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
|
||||
&call_id,
|
||||
"run_terminal_command",
|
||||
&args,
|
||||
)),
|
||||
);
|
||||
}
|
||||
// …then a text response ends it with all three still running, and each
|
||||
// auto-wake turn consumes one distinct scripted reply (FIFO per path; the
|
||||
// stage gating below keeps the consumption order deterministic).
|
||||
for text in [
|
||||
"STATUS_TURN_SETTLED",
|
||||
"WAKE_REPLY_ONE",
|
||||
"WAKE_REPLY_TWO",
|
||||
"WAKE_REPLY_THREE",
|
||||
] {
|
||||
content.enqueue_response(
|
||||
"/v1/responses",
|
||||
ScriptedResponse::sse(responses_api_message_events(text)),
|
||||
);
|
||||
content.enqueue_response(
|
||||
"/v1/chat/completions",
|
||||
ScriptedResponse::sse(chat_completions_message_events(text)),
|
||||
);
|
||||
}
|
||||
content.set_response("STATUS_FALLBACK");
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
// --yolo skips the bash permission prompt; --trust skips the folder-trust gate.
|
||||
let mut harness = PtyHarness::spawn_with_content_in_dir(
|
||||
&binary,
|
||||
ROWS,
|
||||
DEFAULT_COLS,
|
||||
&content,
|
||||
&["--yolo", "--trust"],
|
||||
Some(content.home()),
|
||||
)
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome");
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
|
||||
// The turn ends with all three commands running: the final marker
|
||||
// carries the snapshot count.
|
||||
harness
|
||||
.wait_for_text("STATUS_TURN_SETTLED", Duration::from_secs(60))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"turn never settled; screen:\n{}\n--- non-system messages ---\n{}",
|
||||
harness.screen_contents(),
|
||||
dump_non_system_messages(&content.request_bodies())
|
||||
)
|
||||
});
|
||||
harness
|
||||
.wait_for_text("3 commands still running", Duration::from_secs(30))
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"marker never showed the snapshot count; screen:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
harness.screen_contents().contains("Worked for"),
|
||||
"the marker keeps the completion prefix; screen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
// Release flag 0: chip → wake reply → a fresh "2 commands" wake-end
|
||||
// marker below, with the original "3 commands" marker intact above
|
||||
// (screen text is row-major, so find offsets order the lines).
|
||||
std::fs::write(&flags[0], b"done").expect("release flag 0");
|
||||
let wake_one = wait_until(Duration::from_secs(45), || {
|
||||
harness.update(Duration::from_millis(100));
|
||||
let screen = harness.screen_contents();
|
||||
matches!(
|
||||
(
|
||||
screen.find("3 commands still running"),
|
||||
screen.find("WAKE_REPLY_ONE"),
|
||||
screen.find("2 commands still running"),
|
||||
),
|
||||
(Some(three), Some(reply), Some(two)) if three < reply && reply < two
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
wake_one,
|
||||
"expected chip → wake reply → fresh '2 commands' marker below the intact '3 commands' one; screen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
// Release flag 1: the "1 command" wake marker joins below its reply.
|
||||
std::fs::write(&flags[1], b"done").expect("release flag 1");
|
||||
let wake_two = wait_until(Duration::from_secs(45), || {
|
||||
harness.update(Duration::from_millis(100));
|
||||
let screen = harness.screen_contents();
|
||||
matches!(
|
||||
(
|
||||
screen.find("2 commands still running"),
|
||||
screen.find("WAKE_REPLY_TWO"),
|
||||
screen.find("1 command still running"),
|
||||
),
|
||||
(Some(two), Some(reply), Some(one)) if two < reply && reply < one
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
wake_two,
|
||||
"expected the second wake chain below the earlier lines; screen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
// Release flag 2: zero left — the last wake ends with the PLAIN marker
|
||||
// (fourth "Worked for", no new "still running" suffix).
|
||||
std::fs::write(&flags[2], b"done").expect("release flag 2");
|
||||
let wake_three = wait_until(Duration::from_secs(45), || {
|
||||
harness.update(Duration::from_millis(100));
|
||||
let screen = harness.screen_contents();
|
||||
screen.contains("WAKE_REPLY_THREE") && screen.matches("Worked for").count() == 4
|
||||
});
|
||||
assert!(
|
||||
wake_three,
|
||||
"the final plain wake marker never landed; screen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
// Full chain, positional: marker(3) < chip < reply < marker(2) < chip <
|
||||
// reply < marker(1) < chip < reply < plain final marker — and exactly
|
||||
// three "still running" lines total (the markers'), i.e. the stamped
|
||||
// `will_wake` suppressed every after-chip work-only status line.
|
||||
let screen = harness.screen_contents();
|
||||
let chips: Vec<usize> = screen
|
||||
.match_indices("Task completed")
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
chips.len(),
|
||||
TASKS,
|
||||
"one completion chip per task; screen:\n{screen}"
|
||||
);
|
||||
let m3 = screen.find("3 commands still running").expect("marker 3");
|
||||
let w1 = screen.find("WAKE_REPLY_ONE").expect("wake reply 1");
|
||||
let m2 = screen.find("2 commands still running").expect("marker 2");
|
||||
let w2 = screen.find("WAKE_REPLY_TWO").expect("wake reply 2");
|
||||
let m1 = screen.find("1 command still running").expect("marker 1");
|
||||
let w3 = screen.find("WAKE_REPLY_THREE").expect("wake reply 3");
|
||||
let final_marker = screen
|
||||
.match_indices("Worked for")
|
||||
.map(|(i, _)| i)
|
||||
.last()
|
||||
.expect("final marker");
|
||||
assert!(
|
||||
m3 < chips[0]
|
||||
&& chips[0] < w1
|
||||
&& w1 < m2
|
||||
&& m2 < chips[1]
|
||||
&& chips[1] < w2
|
||||
&& w2 < m1
|
||||
&& m1 < chips[2]
|
||||
&& chips[2] < w3
|
||||
&& w3 < final_marker,
|
||||
"chain out of order; screen:\n{screen}"
|
||||
);
|
||||
assert_eq!(
|
||||
screen.matches("still running").count(),
|
||||
3,
|
||||
"wake-bound completions must not add work-only status lines; screen:\n{screen}"
|
||||
);
|
||||
|
||||
write_cast_if_requested(&harness, "endline_wake_markers_close_each_wakeup.cast");
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// Esc double-press policy (idle, non-empty prompt): the **first Esc shows
|
||||
/// "press again to clear"** and the **second Esc clears the prompt**, recording
|
||||
/// the cleared text into prompt history (recallable via the Up-arrow history
|
||||
/// panel). Proves `try_handle_esc_policy`'s idle clear arm +
|
||||
/// `dispatch_clear_prompt` end-to-end on the real binary.
|
||||
///
|
||||
/// Uses [`spawn_esc_double_press_pager`] so a slow inter-press round-trip
|
||||
/// can't expire the arm.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn esc_esc_clears_idle_prompt_and_records_history() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} done."));
|
||||
|
||||
let mut harness = spawn_esc_double_press_pager(&content);
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
|
||||
// Establish a real idle session first (so Esc runs the agent policy, not
|
||||
// welcome-screen handling), then type a fresh draft to clear.
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("first turn rendered");
|
||||
harness
|
||||
.wait_for_turn_idle(Duration::from_secs(15))
|
||||
.expect("turn idle");
|
||||
|
||||
let draft = "ZZCLEARDRAFT";
|
||||
harness.inject_keys(draft.as_bytes()).expect("type draft");
|
||||
harness
|
||||
.wait_for_text(draft, Duration::from_secs(10))
|
||||
.expect("draft renders in the composer");
|
||||
|
||||
// Wait for the confirm hint between the presses: it proves the arm landed,
|
||||
// and a single `ESC ESC` byte pair collapses to one `Esc` in crossterm.
|
||||
harness.inject_keys(keys::ESC).expect("first esc");
|
||||
harness
|
||||
.wait_for_text("press again to clear", Duration::from_secs(15))
|
||||
.expect("first idle Esc must show the clear confirm hint");
|
||||
|
||||
// Second Esc fires the clear.
|
||||
harness.inject_keys(keys::ESC).expect("second esc");
|
||||
wait_for_labels_absent(&mut harness, &[draft], Duration::from_secs(5));
|
||||
assert!(
|
||||
!harness.contains_text(draft),
|
||||
"second Esc must clear the draft\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
// The confirm hint must be gone once the pending fired.
|
||||
assert!(
|
||||
!harness.contains_text("press again to clear"),
|
||||
"clear-confirm hint must clear after the second Esc fires\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
// The cleared text was recorded into prompt history: Up on the now-empty
|
||||
// prompt opens the history panel, whose list surfaces the cleared draft.
|
||||
harness.inject_keys(keys::UP).expect("open history panel");
|
||||
harness
|
||||
.wait_for_text(draft, Duration::from_secs(10))
|
||||
.expect("cleared draft recorded in prompt history");
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// Esc double-press policy (idle, empty prompt, conversation has a user turn):
|
||||
/// **Esc Esc opens the rewind picker**, and the **first Esc is silent** — no
|
||||
/// "press again" hint, no toast (unlike the idle clear arm). Proves the
|
||||
/// rewind arm of `try_handle_esc_policy` (gated on `scrollback.turn_count() > 0`)
|
||||
/// with a `label: None` silent pending, end-to-end.
|
||||
///
|
||||
/// Phase 2 repeats the gesture with the SCROLLBACK pane focused (Tab, footer
|
||||
/// shows "Space:prompt"): the rewind arm is either-pane, so double-Esc must
|
||||
/// open the picker from there too — through the scrollback key routing, where
|
||||
/// the prompt-only regression would hide.
|
||||
///
|
||||
/// Uses [`spawn_esc_double_press_pager`] so a slow inter-press round-trip
|
||||
/// can't expire the arm.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn esc_esc_opens_rewind_picker_silent_first_press() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} done."));
|
||||
|
||||
let mut harness = spawn_esc_double_press_pager(&content);
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
|
||||
// One real turn → one user-prompt block → turn_count() > 0, so the rewind
|
||||
// arm is eligible. The prompt is empty and idle afterwards.
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("turn rendered (has a user turn)");
|
||||
harness
|
||||
.wait_for_turn_idle(Duration::from_secs(15))
|
||||
.expect("turn idle");
|
||||
|
||||
// First Esc: arm the rewind picker SILENTLY — no clear/rewind confirm hint.
|
||||
// Settle between the presses: a single `ESC ESC` byte pair collapses to
|
||||
// one `Esc` in crossterm.
|
||||
harness.inject_keys(keys::ESC).expect("first esc");
|
||||
harness.update(Duration::from_millis(200));
|
||||
let after_first = harness.screen_contents();
|
||||
assert!(
|
||||
!after_first.contains("press again"),
|
||||
"first rewind Esc must be silent (no confirm hint)\nscreen:\n{after_first}"
|
||||
);
|
||||
assert!(
|
||||
!after_first.contains("Rewind to which turn?"),
|
||||
"rewind picker must not open until the second Esc\nscreen:\n{after_first}"
|
||||
);
|
||||
|
||||
// Second Esc opens the rewind picker (same as /rewind).
|
||||
harness.inject_keys(keys::ESC).expect("second esc");
|
||||
harness
|
||||
.wait_for_text("Rewind to which turn?", Duration::from_secs(15))
|
||||
.expect("rewind picker opens on the second Esc");
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
// Dismiss the picker (Esc) before the scrollback phase.
|
||||
harness.inject_keys(keys::ESC).expect("dismiss rewind");
|
||||
harness.update(Duration::from_millis(200));
|
||||
|
||||
// Phase 2: the same double-Esc must arm and open from the SCROLLBACK pane.
|
||||
// Single Tab leaves the prompt; the "Space:prompt" footer proves the
|
||||
// scrollback owns keys (Tab toggles, so poll the render instead of
|
||||
// re-pressing).
|
||||
harness.inject_keys(b"\t").expect("tab to scrollback");
|
||||
harness
|
||||
.wait_for_text("Space:prompt", Duration::from_secs(10))
|
||||
.expect("scrollback must own keys before the rewind Esc");
|
||||
|
||||
harness
|
||||
.inject_keys(keys::ESC)
|
||||
.expect("first esc (scrollback)");
|
||||
harness.update(Duration::from_millis(200));
|
||||
let after_first = harness.screen_contents();
|
||||
assert!(
|
||||
!after_first.contains("press again"),
|
||||
"first scrollback rewind Esc must be silent (no confirm hint)\nscreen:\n{after_first}"
|
||||
);
|
||||
assert!(
|
||||
!after_first.contains("Rewind to which turn?"),
|
||||
"rewind picker must not open until the second scrollback Esc\nscreen:\n{after_first}"
|
||||
);
|
||||
|
||||
harness
|
||||
.inject_keys(keys::ESC)
|
||||
.expect("second esc (scrollback)");
|
||||
harness
|
||||
.wait_for_text("Rewind to which turn?", Duration::from_secs(15))
|
||||
.expect("rewind picker opens on the second Esc from scrollback");
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
// Dismiss the picker (Esc) so the child can exit cleanly.
|
||||
harness.inject_keys(keys::ESC).expect("dismiss rewind");
|
||||
harness.update(Duration::from_millis(200));
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// Esc policy (idle, empty prompt, NO user turns): **Esc is a swallowed
|
||||
/// no-op** — it must NOT focus the scrollback (the pre-port behavior) and must
|
||||
/// not panic or arm anything. Guards the `try_handle_esc_policy` final
|
||||
/// `Some(InputOutcome::Changed)` swallow branch on a fresh session.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn esc_idle_empty_no_messages_is_swallowed_noop() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} unused."));
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness =
|
||||
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
|
||||
// Promote the welcome screen into a real (idle) agent session by typing,
|
||||
// then wipe the draft so the prompt is empty with NO submitted user turn.
|
||||
harness
|
||||
.inject_keys(b"NOMSG")
|
||||
.expect("type to promote session");
|
||||
harness
|
||||
.wait_for_text("NOMSG", Duration::from_secs(10))
|
||||
.expect("draft renders in the promoted agent prompt");
|
||||
harness.inject_keys(b"\x15").expect("Ctrl+U clear to empty");
|
||||
wait_for_labels_absent(&mut harness, &["NOMSG"], Duration::from_secs(5));
|
||||
|
||||
// Baseline: the prompt owns keys (scrollback's "Space:prompt" hint absent).
|
||||
assert!(
|
||||
!harness.contains_text("Space:prompt"),
|
||||
"precondition: prompt should be focused before Esc\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
// Esc TWICE on idle + empty + no-messages: a true swallow stays a no-op on
|
||||
// the second press. Pressing once and typing couldn't distinguish a swallow
|
||||
// from a silently-armed rewind (the next keystroke clears any pending); a
|
||||
// wrongly-armed rewind would instead OPEN the picker on the second Esc.
|
||||
harness.inject_keys(keys::ESC).expect("press esc (1)");
|
||||
harness.update(Duration::from_millis(250));
|
||||
harness.inject_keys(keys::ESC).expect("press esc (2)");
|
||||
harness.update(Duration::from_millis(400));
|
||||
|
||||
let screen = harness.screen_contents();
|
||||
assert!(
|
||||
!screen.contains("Space:prompt"),
|
||||
"idle empty Esc must be swallowed, NOT focus scrollback\nscreen:\n{screen}"
|
||||
);
|
||||
assert!(
|
||||
!screen.contains("press again"),
|
||||
"idle empty Esc must not arm any double-press\nscreen:\n{screen}"
|
||||
);
|
||||
assert!(
|
||||
!screen.contains("Rewind to which turn?"),
|
||||
"Esc Esc with no messages must NOT open the rewind picker\nscreen:\n{screen}"
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{screen}"
|
||||
);
|
||||
|
||||
// The prompt is still live: typing lands in the composer.
|
||||
harness.inject_keys(b"STILLHERE").expect("type after esc");
|
||||
harness
|
||||
.wait_for_text("STILLHERE", Duration::from_secs(10))
|
||||
.expect("prompt still accepts input after the no-op Esc");
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// Mid-turn Esc from the PROMPT pane is a swallowed no-op: it must NOT cancel
|
||||
/// the turn and must NOT arm idle clear/rewind, even with a non-empty draft.
|
||||
/// Draft text stays in the composer; cancel remains on Ctrl+C / palette / etc.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn esc_mid_turn_from_prompt_is_swallowed_preserves_draft() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
// Long paced stream so the turn is still visibly running when Esc lands.
|
||||
let long_response = format!(
|
||||
"{MOCK_RESPONSE_SENTINEL} {}",
|
||||
"streaming filler words for the cancellation window. ".repeat(120)
|
||||
);
|
||||
content.set_response(long_response);
|
||||
content.set_chunk_delay(Some(Duration::from_millis(50)));
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness =
|
||||
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("stream started");
|
||||
|
||||
// Type a draft into the prompt WHILE the turn streams (prompt stays focused
|
||||
// after submit). A distinctive single token avoids any wrapping ambiguity.
|
||||
let draft = "DRAFTKEEPME";
|
||||
harness.inject_keys(draft.as_bytes()).expect("type draft");
|
||||
harness
|
||||
.wait_for_text(draft, Duration::from_secs(10))
|
||||
.expect("draft renders in the composer");
|
||||
|
||||
// 1× Esc mid-turn must swallow (not cancel, not arm clear).
|
||||
harness.inject_keys(keys::ESC).expect("press esc");
|
||||
harness.update(Duration::from_millis(1000));
|
||||
let screen = harness.screen_contents();
|
||||
|
||||
assert!(
|
||||
!screen.contains("Turn cancelled by user"),
|
||||
"mid-turn Esc must NOT cancel the turn\nscreen:\n{screen}"
|
||||
);
|
||||
assert!(
|
||||
screen.contains(draft),
|
||||
"mid-turn Esc must preserve the draft\nscreen:\n{screen}"
|
||||
);
|
||||
assert!(
|
||||
!screen.contains("press again to clear"),
|
||||
"running-turn Esc must not arm the idle clear\nscreen:\n{screen}"
|
||||
);
|
||||
|
||||
// Positive tail: prove the turn was still alive at Esc-time (the negative
|
||||
// check above would false-pass on an already-finished turn) and that
|
||||
// Ctrl+C — the replacement cancel gesture — works from this pane. With a
|
||||
// non-empty draft the first Ctrl+C clears the draft and keeps the turn;
|
||||
// the second (now on an empty prompt) cancels it.
|
||||
harness.inject_keys(keys::CTRL_C).expect("first ctrl+c");
|
||||
wait_for_labels_absent(&mut harness, &[draft], Duration::from_secs(10));
|
||||
assert!(
|
||||
!harness.contains_text(draft),
|
||||
"first Ctrl+C must clear the draft, not cancel\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
harness.inject_keys(keys::CTRL_C).expect("second ctrl+c");
|
||||
harness
|
||||
.wait_for_text("Turn cancelled by user", Duration::from_secs(15))
|
||||
.expect("Ctrl+C on the empty prompt must cancel the still-running turn");
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// Mid-turn Esc from the SCROLLBACK pane is a swallowed no-op: it must NOT
|
||||
/// cancel the running turn. Cancel remains on Ctrl+C / palette / etc.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn esc_mid_turn_from_scrollback_is_swallowed() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
let long_response = format!(
|
||||
"{MOCK_RESPONSE_SENTINEL} {}",
|
||||
"streaming filler words for the cancellation window. ".repeat(120)
|
||||
);
|
||||
content.set_response(long_response);
|
||||
content.set_chunk_delay(Some(Duration::from_millis(50)));
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness =
|
||||
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("stream started");
|
||||
|
||||
// Leave the prompt with a SINGLE Tab, then wait for the footer to prove the
|
||||
// scrollback owns keys. Tab TOGGLES focus, so re-pressing it could bounce
|
||||
// focus back to the prompt — press once and poll the render instead.
|
||||
harness.inject_keys(b"\t").expect("tab to scrollback");
|
||||
harness
|
||||
.wait_for_text("Space:prompt", Duration::from_secs(10))
|
||||
.expect("scrollback must own keys before the mid-turn Esc");
|
||||
|
||||
// 1× Esc from scrollback must swallow (not cancel).
|
||||
harness.inject_keys(keys::ESC).expect("press esc");
|
||||
harness.update(Duration::from_millis(1000));
|
||||
let screen = harness.screen_contents();
|
||||
assert!(
|
||||
!screen.contains("Turn cancelled by user"),
|
||||
"mid-turn Esc from scrollback must NOT cancel\nscreen:\n{screen}"
|
||||
);
|
||||
|
||||
// Positive tail: prove the turn was still alive at Esc-time (the negative
|
||||
// check above would false-pass on an already-finished turn) and that
|
||||
// Ctrl+C — the replacement cancel gesture — works from the scrollback pane.
|
||||
harness.inject_keys(keys::CTRL_C).expect("press ctrl+c");
|
||||
harness
|
||||
.wait_for_text("Turn cancelled by user", Duration::from_secs(15))
|
||||
.expect("Ctrl+C from scrollback must cancel the still-running turn");
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
//
|
||||
// Plugins-tab footer shows contextual Space enable/disable (not "toggle")
|
||||
// and freeform `a install` (not `a add`). Run with `--nocapture` to dump
|
||||
// screen contents when debugging failures.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
const ENABLED_PLUGIN: &str = "copy-enabled";
|
||||
const DISABLED_PLUGIN: &str = "copy-disabled";
|
||||
|
||||
fn seed_plugins_for_copy_hints(content: &ContentController) {
|
||||
let kigi_home = content.home().join(".kigi");
|
||||
let plugins_dir = kigi_home.join("plugins");
|
||||
for name in [ENABLED_PLUGIN, DISABLED_PLUGIN] {
|
||||
let dir = plugins_dir.join(name);
|
||||
std::fs::create_dir_all(&dir).expect("create plugin dir");
|
||||
std::fs::write(
|
||||
dir.join("plugin.json"),
|
||||
format!(
|
||||
r#"{{"name":"{name}","version":"0.0.1","description":"extensions modal copy fixture"}}"#
|
||||
),
|
||||
)
|
||||
.expect("write plugin.json");
|
||||
}
|
||||
std::fs::create_dir_all(&kigi_home).expect("create .kigi");
|
||||
// User plugins default to disabled unless listed under enabled.
|
||||
let config = format!(
|
||||
"[plugins]\nenabled = [\"{ENABLED_PLUGIN}\"]\ndisabled = [\"{DISABLED_PLUGIN}\"]\n"
|
||||
);
|
||||
std::fs::write(kigi_home.join("config.toml"), config).expect("write config.toml");
|
||||
}
|
||||
|
||||
fn dump_screen(label: &str, harness: &PtyHarness) {
|
||||
let screen = harness.screen_contents();
|
||||
eprintln!(
|
||||
"\n========== PTY CAPTURE: {label} ==========\n{screen}\n========== END: {label} ==========\n"
|
||||
);
|
||||
}
|
||||
|
||||
/// True when the screen shows the Space footer verb we asked for.
|
||||
///
|
||||
/// Important: `space enable` is a substring of the fallback
|
||||
/// `space enable/disable`, so a naive `contains("space enable")` would
|
||||
/// pass while the contextual hint is still the combined form.
|
||||
fn screen_has_space_verb(screen: &str, verb: &str) -> bool {
|
||||
match verb {
|
||||
"enable/disable" => screen.contains("space enable/disable"),
|
||||
"enable" => screen.contains("space enable") && !screen.contains("space enable/disable"),
|
||||
"disable" => screen.contains("space disable"),
|
||||
_ => screen.contains(&format!("space {verb}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for_space_verb(harness: &mut PtyHarness, verb: &str) {
|
||||
let needle = format!("space {verb}");
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while Instant::now() < deadline {
|
||||
if screen_has_space_verb(&harness.screen_contents(), verb) {
|
||||
return;
|
||||
}
|
||||
harness.update(Duration::from_millis(150));
|
||||
}
|
||||
dump_screen(&format!("missing {needle}"), harness);
|
||||
panic!("expected footer hint `{needle}` (not a fallback false-positive)");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
|
||||
async fn extensions_modal_copy_hints_pty() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
seed_plugins_for_copy_hints(&content);
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness =
|
||||
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
|
||||
harness.inject_keys(b"/plugins\r").expect("submit /plugins");
|
||||
harness
|
||||
.wait_for_text("Plugins", Duration::from_secs(15))
|
||||
.expect("extensions modal Plugins tab chrome");
|
||||
|
||||
// Both fixture plugins share one source group, seeded collapsed on load.
|
||||
// Expand it (selection starts on the header row) so the rows are visible.
|
||||
harness
|
||||
.wait_for_text("(2 plugins)", Duration::from_secs(20))
|
||||
.expect("plugin source group header");
|
||||
harness.inject_keys(b"l").expect("expand plugin group");
|
||||
harness
|
||||
.wait_for_text(ENABLED_PLUGIN, Duration::from_secs(20))
|
||||
.expect("enabled plugin row");
|
||||
harness
|
||||
.wait_for_text(DISABLED_PLUGIN, Duration::from_secs(10))
|
||||
.expect("disabled plugin row");
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while Instant::now() < deadline {
|
||||
if harness.contains_text("a install") {
|
||||
break;
|
||||
}
|
||||
harness.update(Duration::from_millis(150));
|
||||
}
|
||||
assert!(
|
||||
harness.contains_text("a install"),
|
||||
"Plugins tab must show `a install` (not `a add`)\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("a add"),
|
||||
"Plugins tab must not show legacy `a add`\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("space toggle"),
|
||||
"Plugins tab must not show legacy `space toggle`\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness.inject_keys(b"/").expect("start search");
|
||||
harness.update(Duration::from_millis(200));
|
||||
for ch in DISABLED_PLUGIN.bytes() {
|
||||
harness
|
||||
.inject_keys(std::slice::from_ref(&ch))
|
||||
.expect("type filter char");
|
||||
harness.update(Duration::from_millis(30));
|
||||
}
|
||||
harness.update(Duration::from_millis(300));
|
||||
harness.inject_keys(b"\r").expect("commit search");
|
||||
harness.update(Duration::from_millis(400));
|
||||
// Search results keep the group header as row 0; step onto the plugin row.
|
||||
harness.inject_keys(b"j").expect("move to plugin row");
|
||||
harness.update(Duration::from_millis(200));
|
||||
wait_for_space_verb(&mut harness, "enable");
|
||||
dump_screen("plugins-tab disabled selected → space enable", &harness);
|
||||
assert!(
|
||||
harness.contains_text("a install"),
|
||||
"install hint must remain with space enable\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness.inject_keys(b"/").expect("start search again");
|
||||
harness.update(Duration::from_millis(200));
|
||||
harness.inject_keys(b"\x15").expect("Ctrl+U clear query");
|
||||
harness.update(Duration::from_millis(100));
|
||||
for ch in ENABLED_PLUGIN.bytes() {
|
||||
harness
|
||||
.inject_keys(std::slice::from_ref(&ch))
|
||||
.expect("type filter char");
|
||||
harness.update(Duration::from_millis(30));
|
||||
}
|
||||
harness.update(Duration::from_millis(300));
|
||||
harness.inject_keys(b"\r").expect("commit search enabled");
|
||||
harness.update(Duration::from_millis(400));
|
||||
harness.inject_keys(b"j").expect("move to plugin row");
|
||||
harness.update(Duration::from_millis(200));
|
||||
wait_for_space_verb(&mut harness, "disable");
|
||||
dump_screen("plugins-tab enabled selected → space disable", &harness);
|
||||
assert!(
|
||||
harness.contains_text("a install"),
|
||||
"install hint must remain with space disable\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness.inject_keys(b"/").expect("start search empty");
|
||||
harness.update(Duration::from_millis(200));
|
||||
harness.inject_keys(b"\x15").expect("Ctrl+U clear");
|
||||
for ch in b"zzz-no-such-plugin" {
|
||||
harness
|
||||
.inject_keys(std::slice::from_ref(ch))
|
||||
.expect("type no-match filter");
|
||||
harness.update(Duration::from_millis(30));
|
||||
}
|
||||
harness.update(Duration::from_millis(300));
|
||||
harness.inject_keys(b"\r").expect("commit empty search");
|
||||
harness.update(Duration::from_millis(400));
|
||||
wait_for_space_verb(&mut harness, "enable/disable");
|
||||
dump_screen(
|
||||
"plugins-tab no matching selection → space enable/disable",
|
||||
&harness,
|
||||
);
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// Tutor report: plain-text file paths with spaces only partially linkify —
|
||||
/// OSC 8 / click / underline stopped at the first space in a synthetic
|
||||
/// macOS app-bundle path (`Demo` vs `Demo App.app`). Prove the full path is
|
||||
/// on screen AND the PTY stream carries an OSC 8 hyperlink whose `file://`
|
||||
/// URL encodes the space (`%20`), so the click target spans the whole
|
||||
/// filename — not a truncated prefix.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn file_path_with_space_emits_full_osc8_hyperlink() {
|
||||
// Synthetic macOS app-bundle path with a space in the final segment.
|
||||
const PATH_PREFIX: &str = "/Users/alice/src/app/release/mac-arm64/Demo";
|
||||
const FULL_PATH: &str = "/Users/alice/src/app/release/mac-arm64/Demo App.app";
|
||||
// file:// URL percent-encodes the space; this is what OSC 8 must carry.
|
||||
const FILE_URL_MARKER: &str = "Demo%20App.app";
|
||||
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!(
|
||||
"{MOCK_RESPONSE_SENTINEL} open \"{FULL_PATH}\" (or release/mac/ on Intel)."
|
||||
));
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
// OSC 8 emission is gated on a Native-capable brand (`hyperlink_route`).
|
||||
// The default harness PTY only sets `TERM=xterm-256color`, so brand is
|
||||
// `Unknown` and the pager deliberately skips OSC 8. Pin WezTerm so the
|
||||
// byte-level proof below is meaningful (same override as `pty_xtversion`).
|
||||
let mut env = content.env_for_pager();
|
||||
env.push(("TERM_PROGRAM".into(), "WezTerm".into()));
|
||||
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
// Wide enough that the path does not wrap mid-segment (wrap would still
|
||||
// linkify, but we want a single-row assertion on the screen text).
|
||||
let mut harness = PtyHarness::new(&binary, DEFAULT_ROWS, 160, &[], &env_refs)
|
||||
.expect("spawn pager with content");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
|
||||
// Wait until the distinctive path segment is painted (streaming may lag).
|
||||
harness
|
||||
.wait_for_text(PATH_PREFIX, Duration::from_secs(30))
|
||||
.expect("path prefix on screen");
|
||||
// Give the frame a beat to flush OSC 8 for the completed line.
|
||||
harness.update(Duration::from_millis(400));
|
||||
|
||||
let screen = harness.screen_contents();
|
||||
assert!(
|
||||
screen.contains(FULL_PATH) || (screen.contains(PATH_PREFIX) && screen.contains("App.app")),
|
||||
"full path (incl. space + `App.app`) must render on screen;\n\
|
||||
missing either the prefix or `App.app` means the link still truncates.\n\
|
||||
screen excerpt:\n{}",
|
||||
screen.chars().take(2000).collect::<String>()
|
||||
);
|
||||
assert!(
|
||||
screen.contains("App.app"),
|
||||
"filename suffix after the space must be visible"
|
||||
);
|
||||
|
||||
// Strongest proof: OSC 8 in the raw PTY stream targets the *full* path.
|
||||
// Pre-fix the scanner stopped at the space, so the hyperlink URL would
|
||||
// end at `…/Demo` with no `%20App.app`.
|
||||
let raw = String::from_utf8_lossy(harness.raw_output());
|
||||
assert!(
|
||||
raw.contains("\x1b]8;"),
|
||||
"expected OSC 8 hyperlink sequences in PTY output (path should be clickable)"
|
||||
);
|
||||
assert!(
|
||||
raw.contains(FILE_URL_MARKER),
|
||||
"OSC 8 file:// URL must include the space-encoded suffix `{FILE_URL_MARKER}` so the \
|
||||
click/underline region covers `Demo App.app`, not just `Demo`.\n\
|
||||
(truncated link would omit this marker.)\n\
|
||||
OSC 8 snippets: {}",
|
||||
osc8_snippets(&raw)
|
||||
);
|
||||
// Guard against a partial link *and* a full one: the truncated form must
|
||||
// not be the only match. A naive prefix link would use `…/Demo` with
|
||||
// no following `%20`.
|
||||
let has_truncated_only =
|
||||
raw.contains("mac-arm64/Demo\x07") || raw.contains("mac-arm64/Demo\x1b\\");
|
||||
assert!(
|
||||
!has_truncated_only || raw.contains(FILE_URL_MARKER),
|
||||
"must not emit a truncated OSC 8 ending at `Demo` without the space suffix"
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// Folder-trust Case 2 — cwd IS `$HOME` (a git repo) => no prompt, no re-prompt
|
||||
/// loop. `$HOME` (and its default `~/.kigi`) can never be recorded by the trust
|
||||
/// store, so `decide` resolves Trusted rather than prompting on a key that could
|
||||
/// never persist. The pager boots straight to the normal welcome; without the
|
||||
/// fix the trust question would render (and re-appear every session).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn folder_trust_cwd_is_home_git_repo_no_prompt() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
|
||||
// $HOME is a git repo with a home-level repo-local marker, and cwd IS $HOME —
|
||||
// so there is genuinely "something to gate", yet the key is unrecordable.
|
||||
git2::Repository::init(content.home()).expect("git init $HOME");
|
||||
std::fs::write(content.home().join(".mcp.json"), "{}").expect("write $HOME/.mcp.json");
|
||||
|
||||
let env = trust_env(&content, true);
|
||||
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
let cwd = content.home().to_str().expect("utf8 home path");
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness = PtyHarness::new(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&["--cwd", cwd],
|
||||
&env_refs,
|
||||
)
|
||||
.expect("spawn pager");
|
||||
|
||||
// Normal welcome boots; the trust question never appears (unrecordable key =>
|
||||
// Trusted), so the session can proceed and never re-prompts.
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("normal welcome renders");
|
||||
assert!(
|
||||
!harness.contains_text(TRUST_QUESTION_SENTINEL),
|
||||
"cwd == $HOME must not render the trust question (unrecordable key => Trusted)\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// 14. **Folder-trust decline quits the pager (no grant).**
|
||||
/// Same setup; pressing `n` exits the pager (the process ends) and writes NO
|
||||
/// grant — the product decision is decline => quit, not proceed-gated.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn folder_trust_decline_quits_without_grant() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
let repo = git_repo_with_mcp_json();
|
||||
let env = trust_env(&content, true);
|
||||
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
let cwd = repo.path().to_str().expect("utf8 repo path");
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness = PtyHarness::new(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&["--cwd", cwd],
|
||||
&env_refs,
|
||||
)
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(TRUST_QUESTION_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("trust question renders");
|
||||
|
||||
// Decline => the pager quits (no session, no grant).
|
||||
harness.inject_keys(b"n").expect("inject n");
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while harness.is_running() && Instant::now() < deadline {
|
||||
harness.update(Duration::from_millis(100));
|
||||
}
|
||||
assert!(
|
||||
!harness.is_running(),
|
||||
"declining the trust question must quit the pager\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
!folder_is_trusted(&content, repo.path()),
|
||||
"declining must NOT persist a grant",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// 15. **Feature off => no trust question.**
|
||||
/// With `KIGI_FOLDER_TRUST=0` (explicit opt-out) the feature is off, so the repo
|
||||
/// boots straight to the welcome (the default is now on).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn folder_trust_feature_off_shows_no_question() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
let repo = git_repo_with_mcp_json();
|
||||
let env = trust_env(&content, false);
|
||||
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
let cwd = repo.path().to_str().expect("utf8 repo path");
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness = PtyHarness::new(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&["--cwd", cwd],
|
||||
&env_refs,
|
||||
)
|
||||
.expect("spawn pager");
|
||||
|
||||
// Normal welcome boots; the question never appears.
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("normal welcome renders");
|
||||
assert!(
|
||||
!harness.contains_text(TRUST_QUESTION_SENTINEL),
|
||||
"feature off must not render the trust question\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
use kigi_workspace::trust::{TRUST_FILE_NAME, TrustStore};
|
||||
|
||||
/// Folder-trust home-is-a-git-repo (dotfiles-in-home), Case 1 — the reported bug.
|
||||
/// `$HOME` is itself a git repo; the session is launched in a SUBDIR
|
||||
/// (`<home>/proj`) that has its own repo-local `.mcp.json`. The trust question
|
||||
/// must render for — and the accepted grant must persist keyed on — the SUBDIR,
|
||||
/// NEVER on `$HOME` (the bug resolved the prompt/key up to `$HOME` because the
|
||||
/// git up-walk landed on the home repo root).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn folder_trust_home_git_repo_subdir_keys_on_subdir() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
|
||||
// $HOME is a dotfiles-style git repo; the launch dir is a subdir carrying its
|
||||
// own repo-local code-exec config (so the SUBDIR has something to gate).
|
||||
git2::Repository::init(content.home()).expect("git init $HOME");
|
||||
let proj = content.home().join("proj");
|
||||
std::fs::create_dir_all(&proj).expect("create proj subdir");
|
||||
std::fs::write(proj.join(".mcp.json"), "{}").expect("write proj/.mcp.json");
|
||||
|
||||
let env = trust_env(&content, true);
|
||||
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
let cwd = proj.to_str().expect("utf8 proj path");
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness = PtyHarness::new(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&["--cwd", cwd],
|
||||
&env_refs,
|
||||
)
|
||||
.expect("spawn pager");
|
||||
|
||||
// Check the trust store DIRECTLY, not via `folder_is_trusted` (which
|
||||
// re-derives `workspace_key` in the TEST process, whose `$HOME` is NOT
|
||||
// `content.home()`, so its home guard wouldn't fire and it would resolve the
|
||||
// wrong key). `TrustStore::is_trusted` canonicalizes + ancestor-prefix matches
|
||||
// internally, so passing the raw path is HOME-independent.
|
||||
let store_path = content.home().join(".kigi").join(TRUST_FILE_NAME);
|
||||
|
||||
// The question renders (keyed on the subdir), and the store is empty first.
|
||||
harness
|
||||
.wait_for_text(TRUST_QUESTION_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("trust question renders for the subdir");
|
||||
assert!(
|
||||
!TrustStore::load_from(store_path.clone()).is_trusted(&proj),
|
||||
"store must be empty before the user answers",
|
||||
);
|
||||
|
||||
// Accept => the grant persists, trusting the SUBDIR. The child writes the
|
||||
// store async after `y`, so reload it each poll iteration.
|
||||
harness.inject_keys(b"y").expect("inject y");
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let mut trusted = false;
|
||||
while Instant::now() < deadline {
|
||||
if TrustStore::load_from(store_path.clone()).is_trusted(&proj) {
|
||||
trusted = true;
|
||||
break;
|
||||
}
|
||||
harness.update(Duration::from_millis(100));
|
||||
}
|
||||
assert!(
|
||||
trusted,
|
||||
"accepting must persist a grant that trusts the subdir\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
// Core regression: the grant must NOT trust $HOME (the reported bug keyed on $HOME).
|
||||
assert!(
|
||||
!TrustStore::load_from(store_path).is_trusted(content.home()),
|
||||
"trust must key on the subdir, never on $HOME",
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// 13. **Folder-trust question renders + accept persists the grant.**
|
||||
/// Feature on + an untrusted git repo with `.mcp.json` + empty store => the
|
||||
/// trust question renders BEFORE any session. Pressing `y` writes the grant to
|
||||
/// `trusted_folders.toml` and lets the session proceed (prompt streams back).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn folder_trust_question_renders_and_accept_persists_grant() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} trusted and running."));
|
||||
let repo = git_repo_with_mcp_json();
|
||||
let env = trust_env(&content, true);
|
||||
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
let cwd = repo.path().to_str().expect("utf8 repo path");
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness = PtyHarness::new(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&["--cwd", cwd],
|
||||
&env_refs,
|
||||
)
|
||||
.expect("spawn pager");
|
||||
|
||||
// The question must render before any session exists.
|
||||
harness
|
||||
.wait_for_text(TRUST_QUESTION_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("trust question renders");
|
||||
assert!(
|
||||
!folder_is_trusted(&content, repo.path()),
|
||||
"store must be empty before the user answers",
|
||||
);
|
||||
|
||||
// BUG A regression: the global `Ctrl+N` shortcut bypasses the welcome
|
||||
// interceptor (it confirms a pending action consumed above the interceptor),
|
||||
// but the dispatch chokepoint must still refuse to create a session while
|
||||
// trust is Pending. Two presses (NewSession requires confirmation) must
|
||||
// leave us on the trust question, NOT in a session.
|
||||
harness.inject_keys(b"\x0e").expect("inject Ctrl+N"); // arm
|
||||
harness.inject_keys(b"\x0e").expect("inject Ctrl+N"); // confirm
|
||||
harness.update(Duration::from_millis(400));
|
||||
assert!(
|
||||
harness.contains_text(TRUST_QUESTION_SENTINEL),
|
||||
"Ctrl+N must not start a session while trust is Pending\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
!folder_is_trusted(&content, repo.path()),
|
||||
"Ctrl+N must not write a trust grant",
|
||||
);
|
||||
|
||||
// Accept => grant persists.
|
||||
harness.inject_keys(b"y").expect("inject y");
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while !folder_is_trusted(&content, repo.path()) && Instant::now() < deadline {
|
||||
harness.update(Duration::from_millis(100));
|
||||
}
|
||||
assert!(
|
||||
folder_is_trusted(&content, repo.path()),
|
||||
"accepting must persist the trust grant\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
// Session proceeds: typing a prompt now starts a session and streams.
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("session proceeds after trusting");
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
#[allow(unused_imports)]
|
||||
use super::scroll::*;
|
||||
|
||||
// ── A5: env-forced scroll settings reach the live scroll config ───────────
|
||||
//
|
||||
// `KIGI_SCROLL_MODE=wheel` + `KIGI_SCROLL_LINES=1` on a `TERM_PROGRAM=zed`
|
||||
// harness (Zed profile: ept=1, wheel_lines=3) must price a 3-event burst at
|
||||
// exactly 3 rows — one row per event:
|
||||
//
|
||||
// desired = 3 events x (1 line / 1 ept) x speed 1.0 = 3
|
||||
//
|
||||
// Each env var is observably load-bearing:
|
||||
// - Without KIGI_SCROLL_LINES=1, Zed's wheel profile prices the burst at
|
||||
// 3 lines/event → 9 rows.
|
||||
// - Without KIGI_SCROLL_MODE=wheel, Auto finalizes a 3-event ZERO-interval
|
||||
// burst as Trackpad on ept=1 (event_count > 2), repricing it at the
|
||||
// normalized trackpad divisor (~1 row) — and mid-burst timing jitter picks
|
||||
// between the two. Forcing wheel removes that classification variance
|
||||
// entirely, which is what makes an EXACT row assertion CI-safe.
|
||||
//
|
||||
// Remaining determinism notes: back-to-back PTY writes only compress arrival
|
||||
// gaps (no mid-burst >80ms split; see the driver contract in `scroll.rs`),
|
||||
// the per-flush cap floor (6) exceeds the 3-line total, the wheel path has
|
||||
// no acceleration, and the harness's hermetic KIGI_SHARE_DIR pins scroll_speed at
|
||||
// its default (50 → 1.0x).
|
||||
|
||||
/// 120 one-row markers ≫ the 50-row PTY: early markers sit off-screen-top.
|
||||
const MARKER_COUNT: usize = 120;
|
||||
|
||||
/// One wheel notch worth of reports on an ept=1 brand: 3 distinct events.
|
||||
const BURST_EVENTS: usize = 3;
|
||||
|
||||
/// Exactly 1 row per event under the forced env (see header math).
|
||||
const EXPECTED_ROWS: usize = 3;
|
||||
|
||||
/// **Env-forced wheel pricing e2e.** The `KIGI_SCROLL_MODE` /
|
||||
/// `KIGI_SCROLL_LINES` overrides must reach the live config: a 3-event burst
|
||||
/// scrolls the viewport up by exactly 3 rows.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn forced_wheel_mode_env_scrolls_exact_rows() {
|
||||
let (mut harness, _content, top_before) = spawn_bottom_pinned_marker_scrollback_with_env(
|
||||
MARKER_COUNT,
|
||||
&[
|
||||
("TERM_PROGRAM", "zed"),
|
||||
("KIGI_SCROLL_MODE", "wheel"),
|
||||
("KIGI_SCROLL_LINES", "1"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
send_wheel_burst(
|
||||
&mut harness,
|
||||
SGR_SCROLL_UP,
|
||||
BURST_EVENTS,
|
||||
WHEEL_ROW,
|
||||
WHEEL_COL,
|
||||
std::time::Duration::ZERO,
|
||||
);
|
||||
// Outlasts the 80ms stream gap + finalize cadence with CI slack.
|
||||
harness.update(std::time::Duration::from_millis(600));
|
||||
|
||||
assert!(
|
||||
harness.is_running() && !harness.contains_text("panicked"),
|
||||
"pager broke during the forced-wheel burst\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
let top_after = topmost_visible_marker(&harness).unwrap_or_else(|| {
|
||||
panic!(
|
||||
"no marker visible after the burst\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
// Direction guard first: a wrong-direction regression must fail with the
|
||||
// suite's screen-dump diagnostics, not a bare usize subtract overflow.
|
||||
assert!(
|
||||
top_after < top_before,
|
||||
"wheel-up burst did not scroll the viewport up: topmost visible marker \
|
||||
{} → {} (expected a decrease)\nscreen:\n{}",
|
||||
marker_line(top_before),
|
||||
marker_line(top_after),
|
||||
harness.screen_contents()
|
||||
);
|
||||
assert_eq!(
|
||||
top_before - top_after,
|
||||
EXPECTED_ROWS,
|
||||
"forced wheel + 1 line/tick must move exactly {EXPECTED_ROWS} rows \
|
||||
({} → {})\nscreen:\n{}",
|
||||
marker_line(top_before),
|
||||
marker_line(top_after),
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// Verifies path-free preview metadata appears and dismisses on a non-graphics PTY.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn image_chip_preview_path_free_pty() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} image preview turn."));
|
||||
|
||||
let png_name = "preview-fixture.png";
|
||||
let png_path = content.home().join(png_name);
|
||||
std::fs::write(&png_path, PNG_8X8_GRAY).expect("write png fixture");
|
||||
let path_str = png_path.display().to_string();
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness =
|
||||
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
|
||||
// Bracketed-paste the bare path alone so drop-classifier turns it into a chip.
|
||||
harness
|
||||
.inject_keys(format!("\x1b[200~{}\x1b[201~", path_str).as_bytes())
|
||||
.expect("paste png path");
|
||||
harness
|
||||
.wait_for_text("Image #1", Duration::from_secs(15))
|
||||
.expect("image chip attached");
|
||||
|
||||
// Allow a frame for the preview overlay to paint (cursor is post-chip).
|
||||
harness.update(Duration::from_millis(400));
|
||||
|
||||
let screen = harness.screen_contents();
|
||||
assert!(
|
||||
screen.contains("Image #1"),
|
||||
"chip label must render: {screen}"
|
||||
);
|
||||
assert!(
|
||||
!screen.contains("Image #1:") && !screen.contains(&format!("[Image #1: {path_str}]")),
|
||||
"chip must be path-free; screen still shows path-in-chip form:\n{screen}"
|
||||
);
|
||||
|
||||
assert!(
|
||||
screen.contains("Format:"),
|
||||
"expected format metadata after path-paste; screen:\n{screen}"
|
||||
);
|
||||
assert!(
|
||||
screen.contains(png_name),
|
||||
"expected the pasted filename in preview; screen:\n{screen}"
|
||||
);
|
||||
|
||||
let dismiss_sentinel = "PREVIEW_DISMISSED_64280529";
|
||||
harness
|
||||
.inject_keys(dismiss_sentinel.as_bytes())
|
||||
.expect("type after chip");
|
||||
harness
|
||||
.wait_for_text(dismiss_sentinel, Duration::from_secs(5))
|
||||
.expect("typed sentinel echoes");
|
||||
harness.update(Duration::from_millis(300));
|
||||
let after = harness.screen_contents();
|
||||
assert!(
|
||||
after.contains("Image #1"),
|
||||
"chip remains after typing: {after}"
|
||||
);
|
||||
assert!(
|
||||
after.contains(dismiss_sentinel),
|
||||
"dismissal sentinel must be visible: {after}"
|
||||
);
|
||||
assert!(
|
||||
!after.contains("Format:") && !after.contains("Path:") && !after.contains(png_name),
|
||||
"preview metadata/path must disappear after typing:\n{after}"
|
||||
);
|
||||
#[cfg(unix)]
|
||||
write_cast_if_requested(&harness, "image_chip_preview_path_free.cast");
|
||||
|
||||
// Keep both states for video stills.
|
||||
if let Ok(dir) = std::env::var("PTY_E2E_ARTIFACT_DIR") {
|
||||
let path = std::path::Path::new(&dir).join("image_chip_preview_path_free_pty.txt");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let _ = std::fs::write(
|
||||
&path,
|
||||
format!("--- after paste ---\n{screen}\n--- after type ---\n{after}\n"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal valid 8×8 grayscale PNG (same fixture as edit-interject e2e).
|
||||
const PNG_8X8_GRAY: &[u8] = &[
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x08, 0x08, 0x00, 0x00, 0x00, 0x00, 0xe1, 0x64, 0xe1,
|
||||
0x57, 0x00, 0x00, 0x00, 0x0e, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x68, 0x80, 0x02, 0x06,
|
||||
0xca, 0x18, 0x00, 0x80, 0x84, 0x20, 0x01, 0x0d, 0x80, 0x24, 0x61, 0x00, 0x00, 0x00, 0x00, 0x49,
|
||||
0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
];
|
||||
@@ -0,0 +1,42 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// 11. **Initial prompt positional.**
|
||||
/// Launching `grok "<prompt>"` (the prompt passed as a positional CLI arg)
|
||||
/// auto-starts a new session and submits the prompt as the first turn —
|
||||
/// no keystrokes injected. This exercises the full loop end-to-end:
|
||||
/// CLI positional → TUI launch → NewSession → SendPrompt → shell agent →
|
||||
/// mock inference → streamed chunks → pager render.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn initial_prompt_positional_auto_submits() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!(
|
||||
"{MOCK_RESPONSE_SENTINEL} hello from the auto-submitted initial prompt."
|
||||
));
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
// Pass the prompt as a positional argument, exactly like `grok "go"`.
|
||||
let mut harness =
|
||||
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[PROMPT])
|
||||
.expect("spawn pager with initial prompt");
|
||||
|
||||
// No keys are injected: the positional prompt must auto-run and the
|
||||
// mock response must appear on screen on its own.
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("mock response from auto-submitted initial prompt");
|
||||
assert!(
|
||||
content.has_chat_completion(),
|
||||
"mock inference server never received a chat completion request\nrequests: {:?}",
|
||||
content.requests()
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// Idle-input regression (enterprise deploy report): after a turn completes and the
|
||||
/// session goes idle, a keystroke must echo promptly. The pager used to rely on
|
||||
/// an always-on `tracing_rx` animation tick to wake the parked event loop;
|
||||
/// removing it exposed that crossterm's
|
||||
/// `EventStream` strands its waker when its `next()` future is dropped by a
|
||||
/// losing `select!` arm (crossterm #936), so idle input was not serviced until
|
||||
/// an unrelated poll timer fired. Reading input on a dedicated thread behind a
|
||||
/// cancellation-safe channel fixes the wake; this test FAILS without that fix.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn input_echoes_at_idle_prompt() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} short idle reply."));
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness =
|
||||
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
|
||||
.expect("spawn pager with content");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("response on screen");
|
||||
|
||||
// Let the turn finish and post-turn animation settle so the loop goes fully
|
||||
// idle (needs_animation() == false) and parks. 3s reliably reaches true idle
|
||||
// here — the guard was empirically red->green at this settle.
|
||||
harness.update(Duration::from_secs(3));
|
||||
|
||||
// Type a distinctive marker at the idle prompt (no Enter): it must echo.
|
||||
const TYPED: &str = "ZZIDLEKEYSTROKEZZ";
|
||||
harness.inject_keys(TYPED.as_bytes()).expect("type at idle");
|
||||
|
||||
if harness
|
||||
.wait_for_text(TYPED, Duration::from_secs(2))
|
||||
.is_err()
|
||||
{
|
||||
panic!(
|
||||
"typed text did not echo at an idle prompt within 2s: the parked event loop \
|
||||
was not woken by input (idle wake regression).\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
}
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// 19b. **VS Code family: Ctrl+L (form feed) is the send-now chord** with the
|
||||
/// same cancel-and-send semantics as the default Ctrl+Enter binding: the
|
||||
/// running turn is cancelled silently and the composer text runs as its own
|
||||
/// next turn (standard `<user_query>` prompt, no interjection preamble).
|
||||
/// Harness strips `TERM_PROGRAM` then applies env — pass `vscode` so
|
||||
/// defaults bind the chord to Ctrl+L.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn interjection_reaches_model_ctrl_l_in_vscode_family() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
// Gate turn 1's terminal event so the typed text + chord provably land
|
||||
// mid-turn regardless of suite load.
|
||||
content.hold_agent_completions();
|
||||
content.set_turns([
|
||||
slow_turn_text("TURNONE"),
|
||||
"TURNTWO reply to the sent-now message.".to_owned(),
|
||||
]);
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut env = content.env_for_pager();
|
||||
env.push(("TERM_PROGRAM".into(), "vscode".into()));
|
||||
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
let mut harness = PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs)
|
||||
.expect("spawn pager with vscode brand");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text("TURNONE", Duration::from_secs(30))
|
||||
.expect("turn 1 streaming");
|
||||
|
||||
harness
|
||||
.inject_keys(b"please also check the logs")
|
||||
.expect("type message");
|
||||
harness.inject_keys(CTRL_L).expect("send-now via Ctrl+L");
|
||||
content.release_agent_completions();
|
||||
harness
|
||||
.wait_for_text(
|
||||
"\u{276F} please also check the logs",
|
||||
Duration::from_secs(15),
|
||||
)
|
||||
.expect("send-now prompt block");
|
||||
harness
|
||||
.wait_for_text("TURNTWO", Duration::from_secs(40))
|
||||
.expect("sent-now message ran as the next turn");
|
||||
|
||||
// The send-now cancel of turn 1 is silent.
|
||||
assert!(
|
||||
!harness.contains_text("Turn cancelled by user"),
|
||||
"send-now cancel must not render a cancelled marker\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
let users = all_user_messages(&content);
|
||||
let sent = users
|
||||
.iter()
|
||||
.find(|u| u.contains("please also check the logs"))
|
||||
.unwrap_or_else(|| panic!("sent-now message never reached the wire: {users:#?}"));
|
||||
assert!(
|
||||
!sent.contains(INTERJECTION_WIRE_PREFIX),
|
||||
"send-now must not use the interjection preamble: {sent}"
|
||||
);
|
||||
assert!(
|
||||
sent.contains("<user_query>"),
|
||||
"send-now must arrive as a standard user_query prompt: {sent}"
|
||||
);
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// 19. **Send-now chord delivers the composer text as its own next turn.**
|
||||
/// (Historical name: the chord used to interject into the SAME turn.)
|
||||
/// Ctrl+Enter with text mid-stream is cancel-and-send: the running turn is
|
||||
/// cancelled silently and the text runs as the next turn — a standard
|
||||
/// `<user_query>` prompt with no interjection preamble, rendered as a "❯ "
|
||||
/// user block via the turn-start adoption.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn interjection_reaches_model_in_same_turn() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
// Gate turn 1's terminal event so the typed text + chord provably land
|
||||
// mid-turn regardless of suite load. Chunk delay widens the mid-stream
|
||||
// window under remote CI load (same shape as cancel_discards_*).
|
||||
content.hold_agent_completions();
|
||||
content.set_chunk_delay(Some(Duration::from_millis(100)));
|
||||
content.set_turns([
|
||||
slow_turn_text("TURNONE"),
|
||||
"TURNTWO reply to the sent-now message.".to_owned(),
|
||||
]);
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness =
|
||||
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text("TURNONE", Duration::from_secs(30))
|
||||
.expect("turn 1 streaming");
|
||||
// Still mid-stream (hold gates completion) — not "Worked for".
|
||||
assert!(
|
||||
!harness.contains_text("Worked for"),
|
||||
"turn must still be open before send-now\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness
|
||||
.inject_keys(b"please also check the logs")
|
||||
.expect("type message");
|
||||
harness
|
||||
.wait_for_text("please also check the logs", Duration::from_secs(5))
|
||||
.expect("draft visible in composer");
|
||||
harness.inject_keys(CTRL_ENTER).expect("send-now chord");
|
||||
content.release_agent_completions();
|
||||
|
||||
// Cancel-and-send: message leaves the composer and commits as a scrollback
|
||||
// user block (not just the draft line that also carries ❯).
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
loop {
|
||||
harness.update(Duration::from_millis(100));
|
||||
if !composer_holds(&harness, "please also check the logs")
|
||||
&& block_lines_containing(&harness, "please also check the logs") >= 1
|
||||
{
|
||||
break;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
panic!(
|
||||
"send-now did not commit draft to scrollback\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
}
|
||||
}
|
||||
harness
|
||||
.wait_for_text("TURNTWO", Duration::from_secs(40))
|
||||
.expect("sent-now message ran as the next turn");
|
||||
|
||||
// The send-now cancel of turn 1 is silent.
|
||||
assert!(
|
||||
!harness.contains_text("Turn cancelled by user"),
|
||||
"send-now cancel must not render a cancelled marker\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
let users = all_user_messages(&content);
|
||||
let sent = users
|
||||
.iter()
|
||||
.find(|u| u.contains("please also check the logs"))
|
||||
.unwrap_or_else(|| panic!("sent-now message never reached the wire: {users:#?}"));
|
||||
assert!(
|
||||
!sent.contains(INTERJECTION_WIRE_PREFIX),
|
||||
"send-now must not use the interjection preamble: {sent}"
|
||||
);
|
||||
assert!(
|
||||
sent.contains("<user_query>"),
|
||||
"send-now must arrive as a standard user_query prompt: {sent}"
|
||||
);
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// Seed `[ui] keep_text_selection = "hold"`, open Settings (F2) after a turn,
|
||||
/// and assert the Mouse row label is visible (modal registration smoke).
|
||||
/// flash-vs-hold is covered by unit tests (UiConfig / cache / dispatch).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn keep_text_selection_settings_visible_pty() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
seed_keep_text_selection_config(&content);
|
||||
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} keep selection turn."));
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness =
|
||||
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("turn rendered");
|
||||
harness.update(Duration::from_millis(400));
|
||||
|
||||
// F2 opens settings on AgentScreen (SS3 form used by most terminals).
|
||||
const F2: &[u8] = b"\x1bOQ";
|
||||
harness.inject_keys(F2).expect("F2 open settings");
|
||||
harness.update(Duration::from_millis(500));
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(8);
|
||||
let mut saw_label = false;
|
||||
while Instant::now() < deadline {
|
||||
if harness.contains_text("Text selection")
|
||||
|| harness.contains_text("Flash after copy")
|
||||
|| harness.contains_text("Hold until dismissed")
|
||||
{
|
||||
saw_label = true;
|
||||
break;
|
||||
}
|
||||
// Filter search narrows Mouse rows if the viewport is short.
|
||||
harness
|
||||
.inject_keys(b"/selection")
|
||||
.expect("filter selection");
|
||||
harness.update(Duration::from_millis(400));
|
||||
if harness.contains_text("Text selection")
|
||||
|| harness.contains_text("Flash after copy")
|
||||
|| harness.contains_text("Hold until dismissed")
|
||||
{
|
||||
saw_label = true;
|
||||
break;
|
||||
}
|
||||
harness.inject_keys(keys::ESC).expect("clear filter");
|
||||
harness.update(Duration::from_millis(200));
|
||||
harness.inject_keys(F2).expect("re-open settings");
|
||||
harness.update(Duration::from_millis(400));
|
||||
}
|
||||
|
||||
assert!(
|
||||
saw_label,
|
||||
"settings modal must show Text selection row (config_seeded={})\nscreen:\n{}",
|
||||
content.home().join(".kigi").join("config.toml").exists(),
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// The fail-closed gate refuses during bootstrap, after the TUI redirects fd 2 to `/dev/null`; its refusal must
|
||||
/// still reach the real terminal (a regression that doesn't restore fd 2 leaves a managed user a blank `exit 1`).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
|
||||
async fn managed_policy_gate_refusal_reaches_real_terminal() {
|
||||
let home = tempfile::tempdir().expect("tempdir");
|
||||
let home_path = home.path();
|
||||
std::fs::write(
|
||||
home_path.join("config.toml"),
|
||||
// Dead local port so any incidental fetch fails fast offline (the gate is synchronous anyway).
|
||||
"[endpoints]\n\
|
||||
deployment_key = \"KEY-AAA\"\n\
|
||||
managed_config_url = \"http://127.0.0.1:1/deployment/config\"\n\
|
||||
cli_chat_proxy_base_url = \"http://127.0.0.1:1\"\n",
|
||||
)
|
||||
.expect("write config.toml");
|
||||
std::fs::write(
|
||||
home_path.join("managed_config.toml"),
|
||||
"[cli]\ntheme = \"dark\"\n",
|
||||
)
|
||||
.expect("write managed_config.toml");
|
||||
// requirements.toml is deliberately absent (served-then-deleted).
|
||||
let synced_at = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("clock after epoch")
|
||||
.as_secs();
|
||||
std::fs::write(
|
||||
home_path.join("managed_config_cache.json"),
|
||||
format!(
|
||||
"{{\"synced_at\":{synced_at},\"principal\":\"deploy-A\",\
|
||||
\"had_managed_config\":true,\"had_requirements\":true,\
|
||||
\"key_fingerprint\":null,\"fail_closed\":true}}"
|
||||
),
|
||||
)
|
||||
.expect("write marker");
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let home_str = home_path.to_str().expect("utf8 home path");
|
||||
let mut harness = PtyHarness::new(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&["--no-auto-update"],
|
||||
// KIGI_MANAGED_CONFIG=0 disables the background refetch so the gate decision is deterministic and offline.
|
||||
&[
|
||||
("KIGI_SHARE_DIR", home_str),
|
||||
("KIGI_MANAGED_CONFIG", "0"),
|
||||
("NO_COLOR", "1"),
|
||||
],
|
||||
)
|
||||
.expect("spawn pager");
|
||||
|
||||
// The gate refuses synchronously and exits; drain output, capturing the exit code once.
|
||||
let gate_msg = "Managed policy is required for this account";
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
let mut exit_code = None;
|
||||
while Instant::now() < deadline {
|
||||
harness.update(Duration::from_millis(100));
|
||||
// Poll non-blocking; `wait_exit_code` reaps, so capture it exactly once.
|
||||
if exit_code.is_none() {
|
||||
exit_code = harness.wait_exit_code(Duration::ZERO);
|
||||
if exit_code.is_some() {
|
||||
harness.update(Duration::from_millis(200)); // final drain after exit
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let raw = String::from_utf8_lossy(harness.raw_output()).into_owned();
|
||||
assert!(
|
||||
harness.contains_text(gate_msg) || raw.contains(gate_msg),
|
||||
"fail-closed gate refusal must reach the real terminal (fd 2 restored \
|
||||
from the /dev/null redirect); screen:\n{}\nraw:\n{raw}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
assert_eq!(
|
||||
exit_code,
|
||||
Some(1),
|
||||
"fail-closed gate must exit 1 (refusal); got {exit_code:?}\nraw:\n{raw}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// 14. **MCP menu loads in a non-project dir** (fake `$HOME` as cwd).
|
||||
/// Guards the create-on-demand path: `/mcps` bypasses the project picker,
|
||||
/// so the deferred session must be created when the menu needs it.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn mcp_menu_loads_servers_in_non_project_dir() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
|
||||
// cwd == home_dir() classifies as non-project.
|
||||
let home = content.home().to_path_buf();
|
||||
drive_mcp_menu_load(&content, &home).await;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// 13. **MCP menu loads in a project dir** (`.git` ancestor → eager session).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn mcp_menu_loads_servers_in_project_dir() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
|
||||
let project = tempfile::tempdir().expect("create project dir");
|
||||
// The `.git` ancestor check precedes the system-temp exclusion in `is_project_dir`.
|
||||
std::fs::create_dir_all(project.path().join(".git")).expect("create .git");
|
||||
|
||||
drive_mcp_menu_load(&content, project.path()).await;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
//
|
||||
// A plain prompt with a mid-text `/skill` token must keep
|
||||
// the composer's teal token highlight in the scrollback echo — live, with the
|
||||
// real shell advertising the skill from an on-disk SKILL.md.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
use kigi_pager_pty_harness::StyledLine;
|
||||
|
||||
/// Typed prompt: plain text with the skill referenced mid-message.
|
||||
const TYPED: &str = "great /test-skill do it";
|
||||
/// The token the composer (and the echo) must render in the skill accent.
|
||||
const TOKEN: &str = "/test-skill";
|
||||
/// Leading word of the prompt — must stay in the plain body color.
|
||||
const BODY_WORD: &str = "great";
|
||||
|
||||
const DONE_SENTINEL: &str = "MIDTEXT_SKILL_ECHO_DONE";
|
||||
|
||||
/// On the first screen row whose text contains `row_marker`, return the fg of
|
||||
/// the styled run containing `needle`. `None` until the row (or run) exists.
|
||||
fn run_fg_on_row(rows: &[StyledLine], row_marker: &str, needle: &str) -> Option<Option<String>> {
|
||||
for row in rows {
|
||||
let text: String = row.runs.iter().map(|r| r.text.as_str()).collect();
|
||||
if !text.contains(row_marker) {
|
||||
continue;
|
||||
}
|
||||
for run in &row.runs {
|
||||
if run.text.contains(needle) {
|
||||
return Some(run.fg.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Seed a user-invocable skill under `dir/.kigi/skills`. The workspace must
|
||||
/// be a git repo: the live session advertises workspace-local skills only for
|
||||
/// git workspaces (offline `grok inspect` scans plain dirs too) — same shape
|
||||
/// as the mid_text_skill_token_echo.yaml scenario's `git_init: true`.
|
||||
fn seed_test_skill(dir: &Path) {
|
||||
let skill_dir = dir.join(".kigi").join("skills").join("test-skill");
|
||||
std::fs::create_dir_all(&skill_dir).expect("create skill dir");
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\nname: test-skill\ndescription: Test skill for echo styling.\n---\n\nDo the thing.\n",
|
||||
)
|
||||
.expect("write SKILL.md");
|
||||
}
|
||||
|
||||
/// Type `great /test-skill do it`, wait for the composer to highlight the
|
||||
/// advertised skill token, submit, and assert the scrollback echo renders the
|
||||
/// SAME token in the same accent fg while the body word stays a different fg.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
|
||||
async fn mid_text_skill_token_echo_styled_pty() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!("{DONE_SENTINEL} echo styling verified."));
|
||||
let workspace = tempfile::tempdir().expect("workspace tempdir");
|
||||
git2::Repository::init(workspace.path()).expect("git init workspace");
|
||||
seed_test_skill(workspace.path());
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness = PtyHarness::spawn_with_content_in_dir(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&content,
|
||||
&["--trust"],
|
||||
Some(workspace.path()),
|
||||
)
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
|
||||
// The first char promotes the welcome prompt to a session.
|
||||
inject_keys_paced(&mut harness, TYPED.as_bytes());
|
||||
harness
|
||||
.wait_for_text(TYPED, Duration::from_secs(10))
|
||||
.expect("typed prompt echoed in the composer");
|
||||
|
||||
// Wait for the shell to advertise the skill: once the registry syncs, the
|
||||
// composer restyles the token — its fg diverges from the body word's fg.
|
||||
let composer_deadline = Instant::now() + Duration::from_secs(30);
|
||||
let composer_token_fg = loop {
|
||||
harness.update(Duration::from_millis(100));
|
||||
let rows = harness.screen_styled();
|
||||
let token_fg = run_fg_on_row(&rows, TYPED, TOKEN);
|
||||
let body_fg = run_fg_on_row(&rows, TYPED, BODY_WORD);
|
||||
if let (Some(token_fg), Some(body_fg)) = (token_fg, body_fg)
|
||||
&& token_fg != body_fg
|
||||
{
|
||||
break token_fg;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < composer_deadline,
|
||||
"composer never highlighted {TOKEN} (skill not advertised?)\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
};
|
||||
|
||||
harness.inject_keys(b"\r").expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(DONE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("mock response rendered (turn finished)");
|
||||
|
||||
// The composer cleared on submit, so the only row with the full typed
|
||||
// text is the scrollback echo. It may take a paint to settle.
|
||||
let echo_deadline = Instant::now() + Duration::from_secs(10);
|
||||
let (echo_token_fg, echo_body_fg) = loop {
|
||||
harness.update(Duration::from_millis(100));
|
||||
let rows = harness.screen_styled();
|
||||
if let (Some(token_fg), Some(body_fg)) = (
|
||||
run_fg_on_row(&rows, TYPED, TOKEN),
|
||||
run_fg_on_row(&rows, TYPED, BODY_WORD),
|
||||
) {
|
||||
break (token_fg, body_fg);
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < echo_deadline,
|
||||
"echo row never painted\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
};
|
||||
|
||||
assert_ne!(
|
||||
echo_token_fg,
|
||||
echo_body_fg,
|
||||
"echo must style {TOKEN} differently from the body\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
assert_eq!(
|
||||
echo_token_fg,
|
||||
composer_token_fg,
|
||||
"echo token fg must match the composer's highlight fg\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// Overlay-steal precedence: while a turn is streaming, opening the slash
|
||||
/// dropdown and pressing **Esc dismisses the dropdown and does NOT cancel the
|
||||
/// turn** (and does not hit the mid-turn swallow). The pane-level slash handler
|
||||
/// returns `Changed` before `try_handle_esc_policy` ever runs.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn mid_turn_slash_dropdown_esc_dismisses_not_cancel() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
let long_response = format!(
|
||||
"{MOCK_RESPONSE_SENTINEL} {}",
|
||||
"streaming filler words while the dropdown is open. ".repeat(120)
|
||||
);
|
||||
content.set_response(long_response);
|
||||
content.set_chunk_delay(Some(Duration::from_millis(50)));
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness =
|
||||
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("stream started");
|
||||
|
||||
// Open the slash dropdown mid-turn. "/mod" narrows to `/model`, whose
|
||||
// description renders only in the dropdown (not in the typed text).
|
||||
inject_keys_paced(&mut harness, b"/mod");
|
||||
harness
|
||||
.wait_for_text("Switch the active model", Duration::from_secs(10))
|
||||
.expect("slash dropdown open mid-turn");
|
||||
|
||||
// Esc: dropdown steals it (dismiss), the turn must keep running.
|
||||
harness.inject_keys(keys::ESC).expect("press esc");
|
||||
harness.update(Duration::from_millis(400));
|
||||
|
||||
let screen = harness.screen_contents();
|
||||
assert!(
|
||||
!screen.contains("Switch the active model"),
|
||||
"Esc must dismiss the slash dropdown\nscreen:\n{screen}"
|
||||
);
|
||||
assert!(
|
||||
!screen.contains("Turn cancelled by user"),
|
||||
"slash-dropdown Esc must NOT cancel the turn\nscreen:\n{screen}"
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{screen}"
|
||||
);
|
||||
|
||||
// Settle a little more and re-confirm the turn was never cancelled by the
|
||||
// dropdown-dismiss Esc.
|
||||
harness.update(Duration::from_millis(600));
|
||||
assert!(
|
||||
!harness.contains_text("Turn cancelled by user"),
|
||||
"turn must still be running after the dropdown-dismiss Esc\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "Linux X11 PTY e2e; run in CI with the built pager"]
|
||||
async fn middle_click_pastes_primary_linux() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
const PRIMARY: &str = "X11PRIMARYQQQ exact selection";
|
||||
const CLIPBOARD: &str = "X11CLIPBOARDQQQ must stay unused";
|
||||
|
||||
let tmp = tempfile::tempdir().expect("tempdir for fake X11 clipboard tools");
|
||||
let bin_dir = tmp.path().join("bin");
|
||||
std::fs::create_dir_all(&bin_dir).expect("mkdir fake bin");
|
||||
let argv_log = tmp.path().join("clipboard-argv.log");
|
||||
|
||||
let recorder = format!(
|
||||
"tool=${{0##*/}}\nprintf '%s' \"$tool\" >> '{log}'\n\
|
||||
for arg in \"$@\"; do printf '\\t%s' \"$arg\" >> '{log}'; done\n\
|
||||
printf '\\n' >> '{log}'\n",
|
||||
log = argv_log.display(),
|
||||
);
|
||||
let xclip = format!(
|
||||
"#!/bin/sh\n{recorder}case \"$*\" in\n\
|
||||
\"--version\") exit 0 ;;\n\
|
||||
\"-o -selection primary\") printf '%s' '{PRIMARY}' ;;\n\
|
||||
\"-o -selection clipboard\") printf '%s' '{CLIPBOARD}' ;;\n\
|
||||
*) exit 1 ;;\nesac\n"
|
||||
);
|
||||
let xsel = format!(
|
||||
"#!/bin/sh\n{recorder}case \"$*\" in\n\
|
||||
\"--version\") exit 0 ;;\n\
|
||||
\"--primary --output\") printf '%s' '{PRIMARY}' ;;\n\
|
||||
\"--clipboard --output\") printf '%s' '{CLIPBOARD}' ;;\n\
|
||||
*) exit 1 ;;\nesac\n"
|
||||
);
|
||||
for (name, body) in [("xclip", xclip), ("xsel", xsel)] {
|
||||
let path = bin_dir.join(name);
|
||||
std::fs::write(&path, body).expect("write fake X11 clipboard tool");
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
|
||||
.expect("chmod fake X11 clipboard tool");
|
||||
}
|
||||
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} primary paste turn."));
|
||||
let path_env = format!(
|
||||
"{}:{}",
|
||||
bin_dir.display(),
|
||||
std::env::var("PATH").unwrap_or_default()
|
||||
);
|
||||
let env: Vec<(String, String)> = {
|
||||
let mut env = content.env_for_pager();
|
||||
env.push(("PATH".into(), path_env));
|
||||
env.push(("TERM".into(), "xterm".into()));
|
||||
env.push(("DISPLAY".into(), ":99".into()));
|
||||
env.push(("WAYLAND_DISPLAY".into(), String::new()));
|
||||
env
|
||||
};
|
||||
let env_refs: Vec<(&str, &str)> = env
|
||||
.iter()
|
||||
.map(|(key, value)| (key.as_str(), value.as_str()))
|
||||
.collect();
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness =
|
||||
PtyHarness::new_in_dir(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs, None)
|
||||
.expect("spawn pager");
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
|
||||
harness
|
||||
.inject_keys(b"\x1b[<1;10;10M\x1b[<1;10;10m")
|
||||
.expect("inject SGR middle down and up");
|
||||
harness
|
||||
.wait_for_text(PRIMARY, Duration::from_secs(10))
|
||||
.expect("PRIMARY reaches the promoted session prompt");
|
||||
let prompt_screen = harness.screen_contents();
|
||||
assert_eq!(
|
||||
prompt_screen.matches(PRIMARY).count(),
|
||||
1,
|
||||
"middle down must insert PRIMARY exactly once\nscreen:\n{prompt_screen}"
|
||||
);
|
||||
assert!(
|
||||
!prompt_screen.contains(CLIPBOARD),
|
||||
"middle click must never insert CLIPBOARD\nscreen:\n{}",
|
||||
prompt_screen
|
||||
);
|
||||
|
||||
harness.inject_keys(b"\r").expect("submit PRIMARY");
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("PRIMARY prompt reaches the mock backend");
|
||||
|
||||
// Recorded requests repeat cumulative history, so wire presence is the stable assertion here.
|
||||
let user_messages = all_user_message_blobs(&content);
|
||||
assert!(
|
||||
user_messages
|
||||
.iter()
|
||||
.any(|message| message.contains(PRIMARY)),
|
||||
"PRIMARY must reach the model; messages: {user_messages:#?}"
|
||||
);
|
||||
assert!(
|
||||
user_messages
|
||||
.iter()
|
||||
.all(|message| !message.contains(CLIPBOARD)),
|
||||
"CLIPBOARD must remain untouched; messages: {user_messages:#?}"
|
||||
);
|
||||
|
||||
let argv = std::fs::read_to_string(&argv_log).expect("read fake-tool argv log");
|
||||
let primary_argv = "xclip\t-o\t-selection\tprimary";
|
||||
assert_eq!(
|
||||
argv.lines().filter(|line| *line == primary_argv).count(),
|
||||
1,
|
||||
"middle release and welcome forwarding must not re-read PRIMARY:\n{argv}"
|
||||
);
|
||||
assert!(
|
||||
!argv.lines().any(|line| {
|
||||
line.contains("\t-selection\tclipboard") || line.contains("\t--clipboard")
|
||||
}),
|
||||
"middle click must not invoke CLIPBOARD argv:\n{argv}"
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// CLI `--minimal` / `--fullscreen` must not write `[ui] screen_mode` to
|
||||
/// config.toml. Mode flags are session-scoped; only a manual config.toml edit
|
||||
/// should make a mode sticky across plain `grok` launches.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_cli_screen_mode_does_not_persist() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!("{} no-sticky payload.", turn_sentinel(1)));
|
||||
|
||||
// Sessions are keyed by cwd: both runs must share a stable project dir.
|
||||
let project = tempfile::tempdir().expect("create project dir");
|
||||
std::fs::create_dir_all(project.path().join(".git")).expect("create .git");
|
||||
|
||||
// First run: explicit `--minimal` — must open minimal but not write config.
|
||||
let mut first = spawn_minimal_in_dir(&content, DEFAULT_ROWS, DEFAULT_COLS, &[], project.path());
|
||||
wait_minimal_ready(&mut first);
|
||||
|
||||
// Give any fire-and-forget persist path time to have written if it still
|
||||
// existed, pumping the PTY so the pager never blocks on a full buffer.
|
||||
let config_path = content.home().join(".kigi").join("config.toml");
|
||||
let deadline = Instant::now() + Duration::from_secs(3);
|
||||
while Instant::now() < deadline {
|
||||
first.update(Duration::from_millis(100));
|
||||
}
|
||||
let body = std::fs::read_to_string(&config_path).unwrap_or_default();
|
||||
assert!(
|
||||
!body.contains("screen_mode"),
|
||||
"--minimal must not persist [ui] screen_mode; config.toml:\n{body}"
|
||||
);
|
||||
quit_minimal(&mut first);
|
||||
|
||||
// Second run: NO mode flag. Without a manual config preference the plain
|
||||
// launch must open fullscreen (welcome screen), not minimal.
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut second = PtyHarness::spawn_with_content_in_dir(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&content,
|
||||
&["--no-leader"],
|
||||
Some(project.path()),
|
||||
)
|
||||
.expect("spawn plain pager");
|
||||
second.set_respond_to_queries(true);
|
||||
|
||||
second
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"plain grok should open fullscreen after --minimal (no sticky write): {e}\nscreen:\n{}",
|
||||
second.screen_contents()
|
||||
)
|
||||
});
|
||||
|
||||
assert!(
|
||||
!second.contains_text(MINIMAL_IDLE_SENTINEL),
|
||||
"plain launch must not be minimal without config screen_mode\nscreen:\n{}",
|
||||
second.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
!second.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
second.screen_contents()
|
||||
);
|
||||
|
||||
second.quit().expect("clean quit");
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Minimal mode's core contract: a finalized assistant block is printed once
|
||||
/// into the terminal's NATIVE scrollback (via `insert_before`), not redrawn in
|
||||
/// the pinned live region. We force the commit above the viewport by streaming a
|
||||
/// response taller than the screen, then assert the head line — which scrolls
|
||||
/// off the top into history — is readable via the harness scrollback helpers.
|
||||
/// (Short responses stay on the visible static band above the live region — the
|
||||
/// content-anchored live region keeps them on screen; only a response genuinely
|
||||
/// taller than the screen proves content reaches *scrollback* specifically.)
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_commits_response_to_scrollback() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
// Sentinel on the FIRST rendered row; 80 code-block rows >> the 50-row
|
||||
// screen, so the head scrolls into native scrollback once the block commits.
|
||||
// (Prose lines would markdown-reflow into one short paragraph that fits on
|
||||
// screen — see `tall_response`.)
|
||||
content.set_response(tall_response(MOCK_RESPONSE_SENTINEL, 80));
|
||||
|
||||
let mut harness = spawn_minimal(&content);
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
|
||||
// The assistant block is the running turn — it only commits to native
|
||||
// scrollback once the turn ends. Poll until the head sentinel lands in
|
||||
// scrollback (above the pinned viewport), which proves it committed rather
|
||||
// than merely streaming in the live tail.
|
||||
let deadline = Instant::now() + Duration::from_secs(40);
|
||||
while Instant::now() < deadline && !harness.scrollback_text().contains(MOCK_RESPONSE_SENTINEL) {
|
||||
harness.update(Duration::from_millis(100));
|
||||
}
|
||||
assert!(
|
||||
harness.scrollback_text().contains(MOCK_RESPONSE_SENTINEL),
|
||||
"committed assistant block must reach native scrollback\nscrollback:\n{}\nscreen:\n{}",
|
||||
harness.scrollback_text(),
|
||||
harness.screen_contents(),
|
||||
);
|
||||
assert!(
|
||||
content.has_chat_completion(),
|
||||
"mock inference server never received a chat completion\nrequests: {:?}",
|
||||
content.requests()
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Overlay host: growing the live viewport for an overlay must
|
||||
/// scroll committed rows up into native scrollback (the `set_viewport_height`
|
||||
/// grow fix) rather than clobbering them, and shrinking it back when the overlay
|
||||
/// closes must leave them intact. Commit a tall response into scrollback, open
|
||||
/// the slash dropdown (grows the viewport over committed rows), close it
|
||||
/// (shrinks + re-anchors), and assert the committed head survived in scrollback.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_committed_content_survives_overlay_grow() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
// Sentinel on the first rendered row; 80 code-block rows overflow the screen
|
||||
// so the head reaches native scrollback (prose would reflow to fit on screen
|
||||
// and never scroll — see `tall_response`).
|
||||
content.set_response(tall_response(MOCK_RESPONSE_SENTINEL, 80));
|
||||
|
||||
let mut harness = spawn_minimal(&content);
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
|
||||
// Precondition: the committed head must be in native scrollback first.
|
||||
let deadline = Instant::now() + Duration::from_secs(40);
|
||||
while Instant::now() < deadline && !harness.scrollback_text().contains(MOCK_RESPONSE_SENTINEL) {
|
||||
harness.update(Duration::from_millis(100));
|
||||
}
|
||||
assert!(
|
||||
harness.scrollback_text().contains(MOCK_RESPONSE_SENTINEL),
|
||||
"precondition: committed block must reach scrollback before the overlay\nscrollback:\n{}",
|
||||
harness.scrollback_text()
|
||||
);
|
||||
|
||||
// Open the slash dropdown → grows the live viewport over committed rows.
|
||||
inject_keys_paced(&mut harness, b"/mod");
|
||||
let dropdown_deadline = Instant::now() + Duration::from_secs(10);
|
||||
while Instant::now() < dropdown_deadline
|
||||
&& !harness
|
||||
.screen_contents()
|
||||
.contains("Switch the active model")
|
||||
{
|
||||
harness.update(Duration::from_millis(100));
|
||||
}
|
||||
assert!(
|
||||
harness
|
||||
.screen_contents()
|
||||
.contains("Switch the active model"),
|
||||
"slash dropdown must grow the viewport and render its items even when \
|
||||
committed content fills the screen\nscreen:\n{}\nscrollback:\n{}",
|
||||
harness.screen_contents(),
|
||||
harness.scrollback_text(),
|
||||
);
|
||||
// Close it → shrinks + re-anchors to the bottom.
|
||||
harness.inject_keys(keys::ESC).expect("close dropdown");
|
||||
harness.update(Duration::from_millis(400));
|
||||
|
||||
// The committed head must still be readable in scrollback: the grow/shrink
|
||||
// cycle must neither clobber nor lose it.
|
||||
assert!(
|
||||
harness.scrollback_text().contains(MOCK_RESPONSE_SENTINEL),
|
||||
"committed block must survive the overlay grow/shrink cycle\nscrollback:\n{}",
|
||||
harness.scrollback_text()
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Resuming a minimal session with `--continue` reprints
|
||||
/// the prior transcript into native scrollback. Minimal has no separate history
|
||||
/// pane (the terminal owns history), so a resumed session would otherwise look
|
||||
/// empty — rather than a compact resume marker, minimal reprints the full
|
||||
/// transcript instead. This asserts the prior
|
||||
/// turn's content reappears after resume and a follow-up turn still works.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_continue_reprints_transcript() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!("{} first session payload.", turn_sentinel(1)));
|
||||
|
||||
// Sessions are keyed by cwd: both runs must share a stable project dir.
|
||||
let project = tempfile::tempdir().expect("create project dir");
|
||||
std::fs::create_dir_all(project.path().join(".git")).expect("create .git");
|
||||
|
||||
let mut first = spawn_minimal_in_dir(&content, DEFAULT_ROWS, DEFAULT_COLS, &[], project.path());
|
||||
wait_minimal_ready(&mut first);
|
||||
first
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit turn 1");
|
||||
first
|
||||
.wait_for_full_text(&turn_sentinel(1), Duration::from_secs(30))
|
||||
.expect("turn 1 committed to scrollback");
|
||||
quit_minimal(&mut first);
|
||||
|
||||
// Resume the same session. The transcript is reprinted into native
|
||||
// scrollback (no separate history pane in minimal).
|
||||
let mut resumed = spawn_minimal_in_dir(
|
||||
&content,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&["--continue"],
|
||||
project.path(),
|
||||
);
|
||||
resumed
|
||||
.wait_for_full_text(&turn_sentinel(1), WELCOME_TIMEOUT)
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"history must be reprinted after --continue: {e}\nfull:\n{}",
|
||||
resumed.full_text()
|
||||
)
|
||||
});
|
||||
|
||||
// A follow-up turn still works in the resumed session.
|
||||
content.set_response(format!("{} resumed payload.", turn_sentinel(2)));
|
||||
resumed
|
||||
.inject_keys(b"again\r")
|
||||
.expect("submit turn 2 after resume");
|
||||
resumed
|
||||
.wait_for_full_text(&turn_sentinel(2), Duration::from_secs(30))
|
||||
.expect("turn 2 rendered in resumed session");
|
||||
|
||||
assert!(
|
||||
!resumed.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
resumed.screen_contents()
|
||||
);
|
||||
|
||||
quit_minimal(&mut resumed);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Minimal mode has no shortcuts bar, so the double-press quit confirmation must
|
||||
/// surface under the prompt instead: a first Ctrl+C on an empty, idle prompt
|
||||
/// arms the quit confirmation and shows "press Ctrl+c again to quit", and a
|
||||
/// second Ctrl+C within the window exits. (Ctrl+Q / Ctrl+D arm the same way; the
|
||||
/// hint is rendered by `minimal::live::render_exit_hint`.)
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_ctrl_c_arms_and_quits() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
let mut harness = spawn_minimal(&content);
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
// First Ctrl+C (CSI legacy ETX). Arms quit + shows the hint under the prompt.
|
||||
harness.inject_keys(b"\x03").expect("inject Ctrl+C");
|
||||
harness
|
||||
.wait_for_text("again to quit", Duration::from_secs(5))
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"quit-confirmation hint expected after first Ctrl+C: {e}\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
|
||||
// Second Ctrl+C within the confirm window exits the process.
|
||||
harness.inject_keys(b"\x03").expect("inject Ctrl+C again");
|
||||
let code = harness.wait_exit_code(Duration::from_secs(5));
|
||||
assert!(
|
||||
code.is_some(),
|
||||
"second Ctrl+C should quit minimal\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Minimal + Apple Terminal: Ctrl+O is the send-now chord. With an empty
|
||||
/// composer and a mid-turn queued follow-up it must send that row now —
|
||||
/// cancel-and-send: turn 1 is cancelled silently and the row runs as its own
|
||||
/// next turn (no interjection preamble) — not open the transcript pager remap.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_ctrl_o_send_now_queued_apple_terminal() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_turns([
|
||||
slow_turn_text("STEPONE"),
|
||||
"STEPTWO send-now via Ctrl+O acknowledged.".to_owned(),
|
||||
]);
|
||||
// Hold turn 1 open deterministically: its content streams, but its
|
||||
// completion is gated until we release it below. Chunk-delay pacing alone
|
||||
// left a wall-clock race — under parallel-suite load turn 1 could finish
|
||||
// before Ctrl+O landed, so the follow-up was promoted FIFO as a plain
|
||||
// prompt and the send-now chrome never appeared.
|
||||
content.hold_agent_completions();
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut env = content.env_for_pager();
|
||||
env.push(("TERM_PROGRAM".into(), "Apple_Terminal".into()));
|
||||
// Non-interactive $PAGER so a mistaken transcript open fails fast rather
|
||||
// than hanging in `less` if the predicate regresses.
|
||||
env.push(("PAGER".into(), "cat".into()));
|
||||
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
let mut harness = PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, MINIMAL_ARGS, &env_refs)
|
||||
.expect("spawn minimal + Apple_Terminal");
|
||||
harness.set_respond_to_queries(true);
|
||||
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text("STEPONE", Duration::from_secs(30))
|
||||
.expect("turn 1 streaming");
|
||||
|
||||
harness
|
||||
.inject_keys(b"minimal send-now payload\r")
|
||||
.expect("queue follow-up");
|
||||
harness
|
||||
.wait_for_text("1 queued", Duration::from_secs(10))
|
||||
.expect("queue indicator");
|
||||
|
||||
// Empty composer + queue: Ctrl+O must yield to send-now, not transcript.
|
||||
// Cancel-and-send: the shell silently cancels turn 1 (its held completion
|
||||
// is irrelevant — the abort wins) and the row commits as a standard "❯ "
|
||||
// prompt block for its own turn. Turn 1 is still gated open here, so the
|
||||
// queued row cannot have promoted FIFO.
|
||||
harness.inject_keys(CTRL_O).expect("Ctrl+O send-now");
|
||||
// Generous deadline: with turn 1 gated open there is no promotion race
|
||||
// left to mask — this wait is pure render latency, which under heavy
|
||||
// parallel-suite load can exceed the old 15s budget.
|
||||
harness
|
||||
.wait_for_text("\u{276F} minimal send-now payload", Duration::from_secs(60))
|
||||
.expect("send-now chrome (not a silent transcript open)");
|
||||
|
||||
// Let the mock's gate go so the promoted turn streams its reply.
|
||||
content.release_agent_completions();
|
||||
harness
|
||||
.wait_for_text("STEPTWO", Duration::from_secs(40))
|
||||
.expect("send-now turn reply");
|
||||
|
||||
// The send-now cancel of turn 1 is silent (scrollback-aware check:
|
||||
// minimal commits blocks into native history).
|
||||
assert!(
|
||||
!harness.contains_full_text("Turn cancelled by user"),
|
||||
"send-now cancel must not render a cancelled marker\nfull contents:\n{}",
|
||||
harness.full_text()
|
||||
);
|
||||
|
||||
let users = all_user_message_blobs(&content);
|
||||
let sent = users
|
||||
.iter()
|
||||
.find(|u| u.contains("minimal send-now payload"))
|
||||
.unwrap_or_else(|| panic!("queued follow-up never on wire: {users:#?}"));
|
||||
assert!(
|
||||
!sent.contains(INTERJECTION_WIRE_PREFIX),
|
||||
"send-now must not use the interjection preamble: {sent}"
|
||||
);
|
||||
assert!(
|
||||
sent.contains("<user_query>"),
|
||||
"send-now must arrive as a standard user_query prompt: {sent}"
|
||||
);
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Minimal mode guards the documented `in_flight_committed` dogfood
|
||||
/// double-show: a promoted queued prompt's "❯ " block commits (prints) into
|
||||
/// native scrollback immediately, so cancelling its turn pre-first-token
|
||||
/// (minimal's cancel gesture is Ctrl+C; Esc is swallowed) must SKIP the
|
||||
/// composer rewind — a rewind would leave the printed block on screen AND
|
||||
/// refill the composer, showing the prompt twice. Standard cancel instead:
|
||||
/// the block renders exactly once and the cancel marker is visible.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_double_esc_committed_queued_prompt_single_render() {
|
||||
const QUEUED_PROMPT: &str = "bravo promoted block";
|
||||
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
// Gate turn 1's completion so the queue provably lands mid-turn; turn 2
|
||||
// (the promoted prompt's) streams nothing before the cancel thanks to
|
||||
// the pacing set just before the release.
|
||||
content.hold_agent_completions();
|
||||
content.set_turns([
|
||||
"STEPONE first reply.".to_owned(),
|
||||
"STEPTWO never streams before the cancel.".to_owned(),
|
||||
]);
|
||||
|
||||
let mut harness = spawn_minimal(&content);
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text("STEPONE", Duration::from_secs(30))
|
||||
.expect("turn 1 streamed (completion still gated)");
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{QUEUED_PROMPT}\r").as_bytes())
|
||||
.expect("queue follow-up mid-turn");
|
||||
harness
|
||||
.wait_for_text("1 queued", Duration::from_secs(10))
|
||||
.expect("queue indicator");
|
||||
|
||||
// Promote: turn 1 ends, the queued prompt's block commits (prints) and
|
||||
// its turn starts — but its first token is 30s away, the exact window
|
||||
// where a naive rewind would double-show the committed block.
|
||||
content.set_chunk_delay(Some(Duration::from_secs(30)));
|
||||
content.release_agent_completions();
|
||||
harness
|
||||
.wait_for_full_text(
|
||||
&format!("\u{276F} {QUEUED_PROMPT}"),
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.expect("promoted prompt block committed");
|
||||
let committed = harness.full_text().matches(QUEUED_PROMPT).count();
|
||||
assert_eq!(
|
||||
committed,
|
||||
1,
|
||||
"committed block must print once\nfull contents:\n{}",
|
||||
harness.full_text()
|
||||
);
|
||||
|
||||
// Cancel the promoted turn pre-first-token. The committed block forces
|
||||
// the standard cancel path (rewind skipped): marker renders, composer
|
||||
// stays empty, and the prompt count does NOT grow.
|
||||
harness.inject_keys(keys::CTRL_C).expect("Ctrl+C cancel");
|
||||
harness
|
||||
.wait_for_full_text("Turn cancelled by user", Duration::from_secs(15))
|
||||
.expect("standard cancel marker (not a silent rewind)");
|
||||
|
||||
harness.update(Duration::from_millis(500));
|
||||
assert_eq!(
|
||||
harness.full_text().matches(QUEUED_PROMPT).count(),
|
||||
committed,
|
||||
"cancel must not re-show the committed prompt (composer refill = double render)\n\
|
||||
full contents:\n{}",
|
||||
harness.full_text()
|
||||
);
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Mid-turn Esc in minimal mode is a swallowed no-op (the prompt is always
|
||||
/// focused). Esc must NOT cancel; cancel remains on Ctrl+C.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_esc_mid_turn_is_swallowed() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
// Paced, long stream so the turn is provably still running when Esc lands.
|
||||
let long = format!(
|
||||
"{MOCK_RESPONSE_SENTINEL} {}",
|
||||
"streaming filler words for the cancellation window. ".repeat(120)
|
||||
);
|
||||
content.set_response(long);
|
||||
content.set_chunk_delay(Some(Duration::from_millis(50)));
|
||||
|
||||
let mut harness = spawn_minimal(&content);
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("turn streaming in the live tail");
|
||||
|
||||
harness.inject_keys(keys::ESC).expect("press esc");
|
||||
harness.update(Duration::from_millis(1000));
|
||||
|
||||
// Full-text: minimal commits the cancel marker to native scrollback, so it
|
||||
// may sit above the pinned viewport — check scrollback + screen.
|
||||
assert!(
|
||||
!harness.contains_full_text("Turn cancelled by user"),
|
||||
"mid-turn Esc must NOT cancel in minimal mode\nfull contents:\n{}",
|
||||
harness.full_text()
|
||||
);
|
||||
|
||||
// Positive tail: prove the turn was still alive at Esc-time (the negative
|
||||
// check above would false-pass on an already-finished turn) and that
|
||||
// Ctrl+C — the replacement cancel gesture — works in minimal mode. The
|
||||
// prompt is empty and the turn is running, so Ctrl+C cancels (the minimal
|
||||
// quit arm applies only to an idle empty prompt).
|
||||
harness.inject_keys(keys::CTRL_C).expect("press ctrl+c");
|
||||
harness
|
||||
.wait_for_full_text("Turn cancelled by user", Duration::from_secs(15))
|
||||
.expect("Ctrl+C must cancel the still-running turn in minimal mode");
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Minimal mode paints the welcome card edge-to-edge (no outer horizontal pad).
|
||||
/// The live region's status / prompt / info rows and committed user+agent blocks
|
||||
/// must share that left edge — previously they sat at `block_pad_left + accent`
|
||||
/// (= 3 columns of blank gutter), which looked misaligned against the welcome
|
||||
/// box. Assert every non-blank visible row either is welcome-card chrome
|
||||
/// (border / logo interior) or starts at column 0.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_flush_left_no_hpad() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!(
|
||||
"{MOCK_RESPONSE_SENTINEL} — flush-left alignment check."
|
||||
));
|
||||
|
||||
let mut harness = spawn_minimal(&content);
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
// Idle: status + prompt + info bar must be flush-left (no leading spaces).
|
||||
assert_flush_left_live_rows(
|
||||
&harness.screen_contents(),
|
||||
&["minimal · /help"],
|
||||
"idle live region",
|
||||
);
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("response renders");
|
||||
// Let the turn finish and commit settle into scrollback / live region.
|
||||
harness
|
||||
.wait_for_text(MINIMAL_IDLE_SENTINEL, Duration::from_secs(20))
|
||||
.expect("return to idle after response");
|
||||
harness.update(Duration::from_millis(200));
|
||||
|
||||
let screen = harness.screen_contents();
|
||||
assert!(
|
||||
screen.contains(MOCK_RESPONSE_SENTINEL),
|
||||
"response must be on screen\nscreen:\n{screen}"
|
||||
);
|
||||
// Committed user line (`❯ go`), agent response, and live chrome all
|
||||
// flush-left with the welcome card.
|
||||
assert_flush_left_live_rows(
|
||||
&screen,
|
||||
&[
|
||||
&format!("❯ {PROMPT}"),
|
||||
MOCK_RESPONSE_SENTINEL,
|
||||
"minimal · /help",
|
||||
],
|
||||
"after response",
|
||||
);
|
||||
|
||||
// Slash menu: typing a command prefix opens the dropdown below the prompt.
|
||||
// Its rows must be flush-left too — previously the panel sat at the layout
|
||||
// hpad and the item rows one further column in (`❯ /transcript` at col 3).
|
||||
harness
|
||||
.inject_keys(b"/tra")
|
||||
.expect("type slash command prefix");
|
||||
harness
|
||||
.wait_for_text(
|
||||
"View the full conversation transcript",
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.expect("slash dropdown opens");
|
||||
assert_flush_left_live_rows(
|
||||
&harness.screen_contents(),
|
||||
&["View the full conversation transcript"],
|
||||
"slash dropdown",
|
||||
);
|
||||
// Close the dropdown / clear the prompt so quit isn't intercepted.
|
||||
harness.inject_keys(b"\x1b").expect("esc closes dropdown");
|
||||
harness.update(Duration::from_millis(100));
|
||||
|
||||
// Permission modal: a scripted `run_terminal_command` tool call (no --yolo)
|
||||
// opens the prompt-replacing permission modal. Its rows must be flush-left
|
||||
// too — previously the whole modal sat at the layout hpad (2 columns in).
|
||||
// The accent `┃` paints the modal's first column, so a correct row has zero
|
||||
// leading spaces.
|
||||
content.set_response("PERMISSION_SETTLED — turn finished after the allow.");
|
||||
let args = json!({
|
||||
"command": "touch flush_marker.txt",
|
||||
"description": "flush-left permission check",
|
||||
})
|
||||
.to_string();
|
||||
content.enqueue_response(
|
||||
"/v1/responses",
|
||||
ScriptedResponse::sse(responses_api_tool_call_events(
|
||||
"call_flush",
|
||||
"run_terminal_command",
|
||||
&args,
|
||||
)),
|
||||
);
|
||||
content.enqueue_response(
|
||||
"/v1/chat/completions",
|
||||
ScriptedResponse::sse(chat_completions_tool_call_events(
|
||||
"run_terminal_command",
|
||||
&args,
|
||||
)),
|
||||
);
|
||||
harness
|
||||
.inject_keys(b"run the flush check\r")
|
||||
.expect("submit tool prompt");
|
||||
harness
|
||||
.wait_for_text("No, reject", Duration::from_secs(30))
|
||||
.expect("permission modal opens");
|
||||
assert_flush_left_live_rows(
|
||||
&harness.screen_contents(),
|
||||
&["No, reject"],
|
||||
"permission modal",
|
||||
);
|
||||
// Allow once (shortcut `1`) so the turn settles, then wait for idle.
|
||||
harness.inject_keys(b"1").expect("allow once");
|
||||
harness
|
||||
.wait_for_text("PERMISSION_SETTLED", Duration::from_secs(30))
|
||||
.expect("turn settles after allow");
|
||||
harness
|
||||
.wait_for_text(MINIMAL_IDLE_SENTINEL, Duration::from_secs(20))
|
||||
.expect("return to idle after permission turn");
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
|
||||
/// For each `needle`, find the first screen line containing it and assert that
|
||||
/// line has no leading ASCII spaces (flush-left). Welcome-card rows start with
|
||||
/// box-drawing chars at col 0 already; this targets the previously-padded
|
||||
/// live/committed content.
|
||||
fn assert_flush_left_live_rows(screen: &str, needles: &[&str], phase: &str) {
|
||||
for needle in needles {
|
||||
let line = screen
|
||||
.lines()
|
||||
.find(|l| l.contains(needle))
|
||||
.unwrap_or_else(|| {
|
||||
panic!("{phase}: no screen line contains {needle:?}\nscreen:\n{screen}")
|
||||
});
|
||||
let lead = line.len() - line.trim_start_matches(' ').len();
|
||||
assert_eq!(
|
||||
lead, 0,
|
||||
"{phase}: line containing {needle:?} must be flush-left \
|
||||
(no leading spaces), got lead={lead}: {line:?}\nscreen:\n{screen}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// `/help` opens the command palette inline in minimal mode, hosted in
|
||||
/// the grown live viewport by the generalized app-modal host (the same host
|
||||
/// that renders settings) — proving minimal now renders the full `ActiveModal`
|
||||
/// family, not just settings. Esc dismisses it back to the prompt.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_help_opens_command_palette() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
let mut harness = spawn_minimal(&content);
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
inject_keys_paced(&mut harness, b"/help");
|
||||
harness.inject_keys(b"\r").expect("submit /help");
|
||||
|
||||
// "New Session" is a stable command-palette entry that renders only inside
|
||||
// the palette modal (not the status line or the slash dropdown).
|
||||
harness
|
||||
.wait_for_text("New Session", Duration::from_secs(10))
|
||||
.expect("command palette opens inline");
|
||||
|
||||
// Esc closes it. The palette opens in input mode, so the first Esc may exit
|
||||
// input mode and a second closes — press up to twice.
|
||||
for _ in 0..2 {
|
||||
harness.inject_keys(keys::ESC).expect("press esc");
|
||||
harness.update(Duration::from_millis(300));
|
||||
if !harness.contains_text("New Session") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
harness
|
||||
.wait_for_text(MINIMAL_IDLE_SENTINEL, Duration::from_secs(10))
|
||||
.expect("palette closed, back to the prompt");
|
||||
assert!(
|
||||
!harness.contains_text("New Session"),
|
||||
"command palette must be gone after Esc\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// `/new` in minimal mode starts a fresh session — it commits a
|
||||
/// second welcome card and resets the committed frontier — while leaving the
|
||||
/// prior turn's committed lines in the terminal's native scrollback (we cannot,
|
||||
/// and must not, un-print them). After `/new` a fresh turn streams normally.
|
||||
///
|
||||
/// The first turn is genuinely taller than the screen (a fenced code block, not
|
||||
/// reflowable prose) so the first welcome card and the turn's head scroll into
|
||||
/// *native scrollback* before `/new`. The robust "new session" signal is then
|
||||
/// that a *second* welcome card appears (two `Grok Build` banners in
|
||||
/// scrollback+screen), independent of exactly how content scrolled.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_new_session_keeps_history_and_resets() {
|
||||
/// Substring printed once per minimal welcome card (see `minimal::welcome`).
|
||||
const WELCOME_BANNER: &str = "Grok Build";
|
||||
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
// Code-block rows (not prose, which markdown-reflows to fit on screen) so
|
||||
// turn 1 is genuinely taller than the screen and its head commits into
|
||||
// native scrollback before `/new` — see `tall_response`.
|
||||
content.set_response(tall_response(&turn_sentinel(1), 80));
|
||||
|
||||
let mut harness = spawn_minimal(&content);
|
||||
wait_minimal_ready(&mut harness);
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit turn 1");
|
||||
|
||||
// Wait until turn 1's head has committed into *native scrollback* (above the
|
||||
// viewport). The first welcome card was printed before it, so it is in
|
||||
// scrollback too by this point.
|
||||
let deadline = Instant::now() + Duration::from_secs(40);
|
||||
while Instant::now() < deadline && !harness.scrollback_text().contains(&turn_sentinel(1)) {
|
||||
harness.update(Duration::from_millis(100));
|
||||
}
|
||||
assert!(
|
||||
harness.scrollback_text().contains(&turn_sentinel(1)),
|
||||
"turn 1 must reach native scrollback before /new\nscrollback:\n{}",
|
||||
harness.scrollback_text()
|
||||
);
|
||||
|
||||
// `/new` → fresh session: commits a second welcome card and resets the frontier.
|
||||
inject_keys_paced(&mut harness, b"/new");
|
||||
harness.inject_keys(b"\r").expect("submit /new");
|
||||
|
||||
// The "new session" signal: a *second* welcome banner now exists across
|
||||
// scrollback + screen (the first is preserved in native scrollback).
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while Instant::now() < deadline && harness.full_text().matches(WELCOME_BANNER).count() < 2 {
|
||||
harness.update(Duration::from_millis(100));
|
||||
}
|
||||
assert!(
|
||||
harness.full_text().matches(WELCOME_BANNER).count() >= 2,
|
||||
"/new must commit a second welcome card (first preserved in scrollback)\nfull:\n{}",
|
||||
harness.full_text()
|
||||
);
|
||||
|
||||
// Prior turn's committed lines remain in native scrollback (not wiped).
|
||||
assert!(
|
||||
harness.contains_full_text(&turn_sentinel(1)),
|
||||
"prior turn must remain in native scrollback after /new\nfull:\n{}",
|
||||
harness.full_text()
|
||||
);
|
||||
|
||||
// A fresh turn streams in the new session.
|
||||
content.set_response(format!("{} new session payload.", turn_sentinel(2)));
|
||||
harness
|
||||
.inject_keys(b"hi\r")
|
||||
.expect("submit a turn in the new session");
|
||||
harness
|
||||
.wait_for_full_text(&turn_sentinel(2), Duration::from_secs(30))
|
||||
.expect("new-session turn streams");
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Minimal status line: pressing Enter with a draft while a turn is
|
||||
/// running queues the prompt, and the status line reports the pending count
|
||||
/// ("N queued") together with the `/queue` inspection hint — minimal has no
|
||||
/// interactive queue pane, so without the hint the count is a dead end.
|
||||
/// Running `/queue` mid-turn commits the read-only snapshot listing the queued
|
||||
/// text. Standalone (no leader), so the queue is the client-local
|
||||
/// pending-prompts list. The queued prompt then promotes and runs once the
|
||||
/// first turn finishes.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_queue_indicator_shows_while_running() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
// Pace turn 1 so it's still streaming when we queue behind it.
|
||||
content.set_chunk_delay(Some(Duration::from_millis(150)));
|
||||
content.set_turns([
|
||||
slow_turn_text("STEPONE"),
|
||||
"STEPTWO queued prompt handled.".to_owned(),
|
||||
]);
|
||||
|
||||
let mut harness = spawn_minimal(&content);
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_text("STEPONE", Duration::from_secs(30))
|
||||
.expect("turn 1 streaming in the live tail");
|
||||
|
||||
// Enter with a draft while the turn runs → queue it behind the running turn.
|
||||
harness
|
||||
.inject_keys(b"second queued prompt\r")
|
||||
.expect("queue a prompt mid-turn");
|
||||
// The count and the inspection hint share the info row: "1 queued · /queue".
|
||||
harness
|
||||
.wait_for_text("1 queued \u{b7} /queue", Duration::from_secs(10))
|
||||
.expect("info row reports the queued count with the /queue hint");
|
||||
|
||||
// `/queue` (slash commands run immediately, they don't queue) commits the
|
||||
// read-only snapshot listing the queued prompt.
|
||||
inject_keys_paced(&mut harness, b"/queue");
|
||||
harness.inject_keys(b"\r").expect("run /queue");
|
||||
harness
|
||||
.wait_for_full_text("Queued prompt", Duration::from_secs(10))
|
||||
.expect("/queue commits the queue snapshot block");
|
||||
harness
|
||||
.wait_for_full_text("#1 second queued prompt", Duration::from_secs(10))
|
||||
.expect("the snapshot lists the queued text");
|
||||
|
||||
// The queued prompt eventually promotes and runs as turn 2.
|
||||
harness
|
||||
.wait_for_full_text("STEPTWO", Duration::from_secs(40))
|
||||
.expect("queued prompt promoted and ran");
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Resize hazard: minimal mode uses only the built-in viewport
|
||||
/// machinery and never re-emits committed history (`resize_purge_rerender` /
|
||||
/// `emit_to_scrollback` are forbidden). The terminal reflows committed
|
||||
/// scrollback natively on resize. This commits a tall response into native
|
||||
/// scrollback, resizes the terminal smaller (rows AND cols), and asserts the
|
||||
/// committed content survives (not wiped, not double-printed), the pager does
|
||||
/// not panic or exit, and the prompt is still functional afterwards.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_resize_preserves_committed_scrollback() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
// Sentinel on the first rendered row; 80 code-block rows >> screen, so the
|
||||
// head scrolls into native scrollback once the block commits. (Prose would
|
||||
// markdown-reflow into one short on-screen paragraph — see `tall_response`.)
|
||||
content.set_response(tall_response(MOCK_RESPONSE_SENTINEL, 80));
|
||||
|
||||
let mut harness = spawn_minimal(&content);
|
||||
wait_minimal_ready(&mut harness);
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
|
||||
// Precondition: the committed head must be in native scrollback first.
|
||||
let deadline = Instant::now() + Duration::from_secs(40);
|
||||
while Instant::now() < deadline && !harness.scrollback_text().contains(MOCK_RESPONSE_SENTINEL) {
|
||||
harness.update(Duration::from_millis(100));
|
||||
}
|
||||
assert!(
|
||||
harness.scrollback_text().contains(MOCK_RESPONSE_SENTINEL),
|
||||
"precondition: committed block must reach scrollback before the resize\nscrollback:\n{}",
|
||||
harness.scrollback_text()
|
||||
);
|
||||
|
||||
// Resize smaller in both dimensions. The terminal reflows committed history
|
||||
// natively; minimal must neither reprint (double-print) nor wipe it.
|
||||
harness.resize(30, 80).expect("resize smaller");
|
||||
harness.update(Duration::from_millis(800));
|
||||
|
||||
assert!(
|
||||
harness.is_running(),
|
||||
"pager exited during resize\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked after resize\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
harness.contains_full_text(MOCK_RESPONSE_SENTINEL),
|
||||
"committed content must survive the resize reflow\nfull:\n{}",
|
||||
harness.full_text()
|
||||
);
|
||||
|
||||
// The prompt is still functional: a second turn streams after the resize.
|
||||
content.set_response(format!("{} after resize.", turn_sentinel(2)));
|
||||
harness
|
||||
.inject_keys(b"again\r")
|
||||
.expect("submit a second turn after resize");
|
||||
harness
|
||||
.wait_for_full_text(&turn_sentinel(2), Duration::from_secs(30))
|
||||
.expect("second turn streams after the resize");
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// `/settings` opens the full settings editor inline in minimal mode —
|
||||
/// hosted in the grown live viewport, reusing the real `render_settings_modal`
|
||||
/// so behavior matches the full TUI — and Esc closes it back to the prompt.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_settings_modal_opens_and_closes() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
let mut harness = spawn_minimal(&content);
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
// Open the settings editor (paced so the slash dropdown opens rather than
|
||||
// the bytes paste-coalescing), then submit.
|
||||
inject_keys_paced(&mut harness, b"/settings");
|
||||
harness.inject_keys(b"\r").expect("submit /settings");
|
||||
|
||||
// "Appearance" is the first settings category header — it renders only in
|
||||
// the settings editor, never in the status line or the slash dropdown.
|
||||
harness
|
||||
.wait_for_text("Appearance", Duration::from_secs(10))
|
||||
.expect("settings editor renders inline");
|
||||
|
||||
// Esc closes it; the idle prompt status returns and the editor is gone.
|
||||
harness.inject_keys(keys::ESC).expect("close settings");
|
||||
harness
|
||||
.wait_for_text(MINIMAL_IDLE_SENTINEL, Duration::from_secs(10))
|
||||
.expect("settings closed, back to the prompt");
|
||||
assert!(
|
||||
!harness.contains_text("Appearance"),
|
||||
"settings editor must be gone after Esc\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Minimal mode surfaces the Shift+Tab session mode in the one-line info bar
|
||||
/// directly under the prompt. The mode cycle itself is shared with the full TUI,
|
||||
/// but minimal had no persistent indicator, so pressing Shift+Tab "did nothing
|
||||
/// visible" (dogfood nit). The first press (Normal → Plan, gate-independent)
|
||||
/// must light a lowercase `plan` flag in the info bar that
|
||||
/// `crate::minimal::live::render_prompt_info` draws below the prompt.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_shift_tab_shows_mode_in_info_bar() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
let mut harness = spawn_minimal(&content);
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
// Baseline: nothing on the idle screen says "plan" — the welcome card hint
|
||||
// is "/help for commands …", the status line "minimal · /help", and the
|
||||
// empty-prompt placeholder "Build anything". So a lowercase "plan" can only
|
||||
// come from the mode flag under test. (The shell's transient
|
||||
// "Switched to mode: Plan" banner uses a capital P, which we don't match.)
|
||||
assert!(
|
||||
!harness.contains_text("plan"),
|
||||
"precondition: idle minimal screen must not already show 'plan'\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
// Shift+Tab → BackTab (CSI Z). First press cycles Normal → Plan.
|
||||
harness.inject_keys(b"\x1b[Z").expect("inject BackTab");
|
||||
harness
|
||||
.wait_for_text("plan", Duration::from_secs(10))
|
||||
.expect("plan flag in the info bar under the prompt after Shift+Tab");
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Content-anchored live region: a response that FITS on the
|
||||
/// screen stays on the visible static band with the prompt directly after it. It
|
||||
/// is NOT force-pushed to the bottom of the screen — the earlier bottom-pin
|
||||
/// behavior left a large blank gap *above* a short conversation (the reported
|
||||
/// regression: "you see a big gap … input snapped to the bottom").
|
||||
///
|
||||
/// Discriminating signals (all robust to how the emulator pads blank rows):
|
||||
/// - the response stays on the visible screen, and is NOT pushed into native
|
||||
/// scrollback (a response that fits never needs to scroll);
|
||||
/// - the always-focused prompt (the cursor) sits HIGH on the screen, directly
|
||||
/// after the short conversation, with the rest of the window left blank below
|
||||
/// it — bottom-pin would instead put the cursor near the last row.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_short_response_stays_on_screen() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
// A short answer — a couple of rendered rows, far shorter than the 50-row
|
||||
// screen, so it never needs to scroll into native history.
|
||||
content.set_response(format!(
|
||||
"{MOCK_RESPONSE_SENTINEL} — short answer that fits."
|
||||
));
|
||||
|
||||
let mut harness = spawn_minimal(&content);
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
|
||||
// Wait for the response to render on the visible screen, then let the turn
|
||||
// finish and the commit settle.
|
||||
harness
|
||||
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("short response renders on screen");
|
||||
harness.update(Duration::from_millis(400));
|
||||
|
||||
let rows = DEFAULT_ROWS;
|
||||
|
||||
// 1) The short response is on the visible screen …
|
||||
assert!(
|
||||
harness.screen_contents().contains(MOCK_RESPONSE_SENTINEL),
|
||||
"short response must stay on the visible screen\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
// 2) … and was NOT force-pushed into native scrollback. Content-anchored: a
|
||||
// response that fits stays put; only content taller than the screen scrolls
|
||||
// (proven separately by `minimal_commits_response_to_scrollback`).
|
||||
assert!(
|
||||
!harness.scrollback_text().contains(MOCK_RESPONSE_SENTINEL),
|
||||
"short response must not be pushed into scrollback\nscrollback:\n{}",
|
||||
harness.scrollback_text()
|
||||
);
|
||||
|
||||
// 3) The prompt sits directly after the (short) conversation, HIGH on the
|
||||
// screen, with the rest of the window left blank below it — NOT pinned to
|
||||
// the bottom with a big gap above (the regression). The cursor is always
|
||||
// on the focused prompt, so its row is the robust signal: bottom-pin puts
|
||||
// it near `rows - 1`; content-anchored keeps it in the upper portion.
|
||||
let (cursor_row, _cursor_col) = harness.cursor_position();
|
||||
assert!(
|
||||
cursor_row < rows - 12,
|
||||
"prompt/cursor should sit high on the screen (content-anchored), not \
|
||||
pinned near the bottom: cursor_row={cursor_row}, rows={rows}\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
// 4) Nothing is rendered near the bottom of the screen: the last non-blank
|
||||
// row (the prompt's info bar) is well above the last row. Found explicitly
|
||||
// (not via trailing padding) so the check is independent of how the
|
||||
// emulator represents empty rows.
|
||||
let screen = harness.screen_contents();
|
||||
let last_non_blank = screen
|
||||
.lines()
|
||||
.enumerate()
|
||||
.filter(|(_, l)| !l.trim().is_empty())
|
||||
.map(|(i, _)| i)
|
||||
.last()
|
||||
.unwrap_or(0);
|
||||
assert!(
|
||||
last_non_blank < (rows as usize) - 10,
|
||||
"content-anchored live region must leave the bottom of the screen blank; \
|
||||
last non-blank row was {last_non_blank} of {rows}\nscreen:\n{screen}"
|
||||
);
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Minimal overlay host: typing `/` opens a prompt-anchored slash
|
||||
/// dropdown above the prompt (growing the pinned live viewport to make room),
|
||||
/// and a single Esc dismisses it. The pane-level slash handler must consume the
|
||||
/// Esc before the idle clear / rewind policy ever runs.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_slash_dropdown_dismisses_with_esc() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
let mut harness = spawn_minimal(&content);
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
// "/mod" narrows to `/model`, whose description renders only inside the
|
||||
// dropdown — not in the typed text nor the `minimal · /help` status line —
|
||||
// so it's an unambiguous "dropdown is open" sentinel.
|
||||
inject_keys_paced(&mut harness, b"/mod");
|
||||
harness
|
||||
.wait_for_text("Switch the active model", Duration::from_secs(10))
|
||||
.expect("slash dropdown open above the prompt");
|
||||
|
||||
harness.inject_keys(keys::ESC).expect("press esc");
|
||||
harness.update(Duration::from_millis(400));
|
||||
|
||||
let screen = harness.screen_contents();
|
||||
assert!(
|
||||
!screen.contains("Switch the active model"),
|
||||
"Esc must dismiss the slash dropdown\nscreen:\n{screen}"
|
||||
);
|
||||
// Dismiss only — Esc must not have armed the idle clear or opened rewind.
|
||||
assert!(
|
||||
!screen.contains("press again to clear"),
|
||||
"slash-dropdown Esc must not fall through to the idle clear\nscreen:\n{screen}"
|
||||
);
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{screen}"
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// `/minimal` from a fullscreen session re-execs the pager with `--minimal
|
||||
/// --resume <id>` so the same conversation reopens under scrollback-native
|
||||
/// rendering. Proves the end-to-end screen-mode switch path (slash command →
|
||||
/// quit → exec → resume in minimal) that unit tests cannot cover.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_slash_switches_from_fullscreen() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
let sentinel = turn_sentinel(1);
|
||||
content.set_response(format!("{sentinel} fullscreen payload."));
|
||||
|
||||
// Stable project dir so the resumed session is findable by id after re-exec.
|
||||
let project = tempfile::tempdir().expect("create project dir");
|
||||
std::fs::create_dir_all(project.path().join(".git")).expect("create .git");
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
// Start fullscreen (default), standalone. Enable query responses *before*
|
||||
// the re-exec so the post-switch minimal probe does not silently downgrade
|
||||
// to full-height inline.
|
||||
let mut harness = PtyHarness::spawn_with_content_in_dir(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&content,
|
||||
&["--no-leader"],
|
||||
Some(project.path()),
|
||||
)
|
||||
.expect("spawn fullscreen pager");
|
||||
harness.set_respond_to_queries(true);
|
||||
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
|
||||
.expect("welcome text");
|
||||
|
||||
// Establish a real session with content so `--resume` has history to load.
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit turn");
|
||||
harness
|
||||
.wait_for_text(&sentinel, Duration::from_secs(30))
|
||||
.expect("mock response in fullscreen");
|
||||
|
||||
// Switch: `/minimal` should re-exec into scrollback-native mode with the
|
||||
// same session. Pace keystrokes so the slash dropdown opens rather than
|
||||
// paste-coalescing, then confirm once the description row is visible.
|
||||
inject_keys_paced(&mut harness, b"/minimal");
|
||||
harness
|
||||
.wait_for_text(
|
||||
"Reopen this session in minimal (scrollback-native) mode",
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.expect("slash dropdown offers /minimal");
|
||||
harness.update(Duration::from_millis(150));
|
||||
harness.inject_keys(b"\r").expect("submit /minimal");
|
||||
|
||||
// After the relaunch the PTY stays live (Unix: same process via `exec`;
|
||||
// Windows: child on the same console with the parent parked in `wait`);
|
||||
// wait for minimal's idle status. A `/minimal` re-exec shows the
|
||||
// switch-back form (`… /fullscreen to go back · /help`), not the cold-start
|
||||
// `minimal · /help` sentinel alone.
|
||||
harness
|
||||
.wait_for_text(MINIMAL_SWITCH_BACK_IDLE_SENTINEL, Duration::from_secs(45))
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"/minimal did not reopen session in minimal mode: {e}\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
)
|
||||
});
|
||||
harness
|
||||
.wait_for_full_text(&sentinel, Duration::from_secs(30))
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"prior turn must be present after /minimal resume: {e}\nfull:\n{}",
|
||||
harness.full_text()
|
||||
)
|
||||
});
|
||||
|
||||
// Main-screen clear on relaunch: "Reopening session…" was printed just
|
||||
// before exec and must not remain above the resumed UI (the clear wipes
|
||||
// residual main-buffer detritus so the welcome card sits at the top).
|
||||
let screen = harness.screen_contents();
|
||||
assert!(
|
||||
!screen.contains("Reopening session"),
|
||||
"main screen should be cleared on /minimal relaunch; leftover reopen text:\n{screen}"
|
||||
);
|
||||
assert!(
|
||||
screen.contains("Grok Build") || harness.full_text().contains("Grok Build"),
|
||||
"welcome card should re-anchor at top after /minimal relaunch\nscreen:\n{screen}"
|
||||
);
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked after /minimal\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// `/fullscreen` from a minimal session re-execs the pager without `--minimal`
|
||||
/// and with `--resume <id>`, reopening the same conversation under the
|
||||
/// fullscreen alt-screen TUI. The reverse of `minimal_slash_switches_from_fullscreen`.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_slash_switches_to_fullscreen() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
let sentinel = turn_sentinel(1);
|
||||
content.set_response(format!("{sentinel} minimal payload."));
|
||||
|
||||
let project = tempfile::tempdir().expect("create project dir");
|
||||
std::fs::create_dir_all(project.path().join(".git")).expect("create .git");
|
||||
|
||||
let mut harness =
|
||||
spawn_minimal_in_dir(&content, DEFAULT_ROWS, DEFAULT_COLS, &[], project.path());
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit turn");
|
||||
harness
|
||||
.wait_for_full_text(&sentinel, Duration::from_secs(30))
|
||||
.expect("turn committed in minimal");
|
||||
|
||||
// Switch back to fullscreen. Wait for the dropdown row so Enter confirms
|
||||
// the command (not a bare paste of the text).
|
||||
inject_keys_paced(&mut harness, b"/fullscreen");
|
||||
harness
|
||||
.wait_for_text(
|
||||
"Reopen this session in fullscreen mode",
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.expect("slash dropdown offers /fullscreen");
|
||||
harness.update(Duration::from_millis(150));
|
||||
harness.inject_keys(b"\r").expect("submit /fullscreen");
|
||||
|
||||
// Prior turn content is already on the minimal screen, so we cannot use
|
||||
// `wait_for_text(sentinel)` as the transition signal — it would return
|
||||
// immediately. Wait until the minimal idle status line is gone (proves we
|
||||
// left scrollback-native mode) while the prior turn remains visible.
|
||||
let deadline = Instant::now() + Duration::from_secs(45);
|
||||
loop {
|
||||
harness.update(Duration::from_millis(100));
|
||||
let screen = harness.screen_contents();
|
||||
let left_minimal = !screen.contains(MINIMAL_IDLE_SENTINEL)
|
||||
&& !screen.contains(MINIMAL_SWITCH_BACK_IDLE_SENTINEL)
|
||||
&& !screen.contains("Reopen this session in fullscreen mode");
|
||||
let history_present = screen.contains(&sentinel) || harness.full_text().contains(&sentinel);
|
||||
if left_minimal && history_present {
|
||||
break;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
panic!(
|
||||
"/fullscreen did not leave minimal mode with history intact\nscreen:\n{}\nfull:\n{}",
|
||||
harness.screen_contents(),
|
||||
harness.full_text()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked after /fullscreen\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
// Slash-command mode switches are session-scoped: `/fullscreen` relaunch
|
||||
// must not write `[ui] screen_mode` (manual config only).
|
||||
let config_path = content.home().join(".kigi").join("config.toml");
|
||||
// Brief settle so a fire-and-forget write would have landed if still present.
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < deadline {
|
||||
harness.update(Duration::from_millis(100));
|
||||
}
|
||||
let body = std::fs::read_to_string(&config_path).unwrap_or_default();
|
||||
assert!(
|
||||
!body.contains("screen_mode"),
|
||||
"/fullscreen must not persist [ui] screen_mode; config.toml:\n{body}"
|
||||
);
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Reasoning text streamed by the mock. Must never appear in the answer text
|
||||
/// so screen assertions can tell the two apart.
|
||||
const REASONING_SENTINEL: &str = "REASONINGSENTINEL";
|
||||
|
||||
/// Dogfood bug: "I don't see thoughts in the transcript". With thinking
|
||||
/// enabled (`[ui] show_thinking_blocks` — the default, set
|
||||
/// explicitly here so the test doesn't depend on the rollout default),
|
||||
/// minimal commits reasoning as a **collapsed** `Thought for Xs` header
|
||||
/// (print-once display policy) — the body is intentionally not in the live
|
||||
/// scrollback. The advertised full-fidelity `/transcript` view must therefore
|
||||
/// render the thinking body **expanded**, or the reasoning is unreachable.
|
||||
///
|
||||
/// Flow: stream a reasoning+text turn → the answer commits, the reasoning
|
||||
/// collapses to its header (body nowhere on screen) → `/transcript` with
|
||||
/// `PAGER=cat` dumps the full view → the reasoning body appears.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_transcript_expands_collapsed_thinking() {
|
||||
// The model must run on the Responses backend — reasoning summary deltas
|
||||
// are a Responses-API stream shape (the scripted events below).
|
||||
let content = ContentController::start_with_models(vec![
|
||||
MockModel::new("test-model").with_api_backend("responses"),
|
||||
])
|
||||
.await
|
||||
.expect("start content");
|
||||
// The scripted turn streams reasoning deltas before the visible answer.
|
||||
// Two copies so an auxiliary request can't starve the prompt turn
|
||||
// (consumed FIFO; unconsumed scripts are dropped with the server).
|
||||
let reasoning = format!("{REASONING_SENTINEL} pondering syllables quietly");
|
||||
let answer = format!("{MOCK_RESPONSE_SENTINEL} the answer body.");
|
||||
for _ in 0..2 {
|
||||
content.enqueue_response(
|
||||
"/v1/responses",
|
||||
ScriptedResponse::sse(sse::responses_api_reasoning_and_text_events(
|
||||
&reasoning,
|
||||
&answer,
|
||||
"test-model",
|
||||
)),
|
||||
);
|
||||
}
|
||||
// Fallback mode for any further auxiliary traffic.
|
||||
content.set_response(answer.clone());
|
||||
|
||||
// Thinking blocks explicitly ON (ingestion is gated on this toggle; the
|
||||
// sandbox `$HOME` starts with no config at all).
|
||||
std::fs::create_dir_all(content.home().join(".kigi")).expect("mk .kigi");
|
||||
std::fs::write(
|
||||
content.home().join(".kigi/config.toml"),
|
||||
"[ui]\nshow_thinking_blocks = true\n",
|
||||
)
|
||||
.expect("write config");
|
||||
|
||||
// Minimal env + PAGER=cat (non-interactive dump, same as
|
||||
// `minimal_transcript_opens_in_pager`).
|
||||
let mut env = content.env_for_pager();
|
||||
env.push(("PAGER".to_string(), "cat".to_string()));
|
||||
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness = PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, MINIMAL_ARGS, &env_refs)
|
||||
.expect("spawn minimal pager");
|
||||
harness.set_respond_to_queries(true);
|
||||
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_full_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("turn committed");
|
||||
|
||||
// The reasoning committed as its collapsed header: the body is NOT in the
|
||||
// live view (that's the print-once display policy, not a bug)…
|
||||
harness
|
||||
.wait_for_full_text("Thought for", Duration::from_secs(10))
|
||||
.expect("collapsed thinking header committed");
|
||||
assert!(
|
||||
!harness.full_text().contains(REASONING_SENTINEL),
|
||||
"reasoning body must be collapsed in the live view\nfull:\n{}",
|
||||
harness.full_text()
|
||||
);
|
||||
|
||||
// …so the transcript is the only way to read it. cat dumps the full view.
|
||||
inject_keys_paced(&mut harness, b"/transcript");
|
||||
harness.inject_keys(b"\r").expect("submit /transcript");
|
||||
|
||||
harness
|
||||
.wait_for_full_text(REASONING_SENTINEL, Duration::from_secs(15))
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"transcript must expand the collapsed thinking body: {e}\nfull:\n{}",
|
||||
harness.full_text()
|
||||
)
|
||||
});
|
||||
|
||||
// And the inline TUI survives the suspend/restore round trip.
|
||||
harness
|
||||
.wait_for_text(MINIMAL_IDLE_SENTINEL, Duration::from_secs(10))
|
||||
.expect("inline TUI restored after the pager exited");
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::*;
|
||||
|
||||
/// Minimal full view: `/transcript` renders the WHOLE conversation
|
||||
/// fully expanded (reasoning in full, tool output uncapped) as ANSI to a temp
|
||||
/// file and opens it in `$PAGER`, suspending the inline TUI, then restores. We
|
||||
/// set `PAGER=cat` so the child dumps the transcript and exits immediately (no
|
||||
/// interactive `less` to drive). Proof the pager ran on the transcript: the
|
||||
/// turn's sentinel then appears **twice** — once in the live conversation and
|
||||
/// once in the dumped transcript — and the inline TUI restores to idle after.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn minimal_transcript_opens_in_pager() {
|
||||
let content = ContentController::start().await.expect("start content");
|
||||
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} transcript body."));
|
||||
|
||||
// Minimal env + PAGER=cat (non-interactive). Response forwarding on so the
|
||||
// inline-viewport cursor probe completes (see spawn_minimal).
|
||||
let mut env = content.env_for_pager();
|
||||
env.push(("PAGER".to_string(), "cat".to_string()));
|
||||
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness = PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, MINIMAL_ARGS, &env_refs)
|
||||
.expect("spawn minimal pager");
|
||||
harness.set_respond_to_queries(true);
|
||||
|
||||
wait_minimal_ready(&mut harness);
|
||||
|
||||
// Produce a turn so there is something to transcribe.
|
||||
harness
|
||||
.inject_keys(format!("{PROMPT}\r").as_bytes())
|
||||
.expect("submit prompt");
|
||||
harness
|
||||
.wait_for_full_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
|
||||
.expect("turn committed");
|
||||
|
||||
// Open the transcript in $PAGER (cat dumps it and exits).
|
||||
inject_keys_paced(&mut harness, b"/transcript");
|
||||
harness.inject_keys(b"\r").expect("submit /transcript");
|
||||
|
||||
// The pager (cat) dumps the full transcript, which re-emits the turn body,
|
||||
// so the sentinel now appears at least twice across scrollback + screen (the
|
||||
// live turn plus the dumped transcript). This proves the pager ran on the
|
||||
// rendered transcript rather than the command being a no-op.
|
||||
let deadline = Instant::now() + Duration::from_secs(15);
|
||||
while Instant::now() < deadline
|
||||
&& harness.full_text().matches(MOCK_RESPONSE_SENTINEL).count() < 2
|
||||
{
|
||||
harness.update(Duration::from_millis(100));
|
||||
}
|
||||
assert!(
|
||||
harness.full_text().matches(MOCK_RESPONSE_SENTINEL).count() >= 2,
|
||||
"transcript must be dumped by $PAGER (sentinel should appear in both the \
|
||||
live turn and the dumped transcript)\nfull:\n{}",
|
||||
harness.full_text()
|
||||
);
|
||||
|
||||
// The pager process survives the suspend/restore round-trip and returns to
|
||||
// the idle prompt.
|
||||
harness
|
||||
.wait_for_text(MINIMAL_IDLE_SENTINEL, Duration::from_secs(10))
|
||||
.expect("inline TUI restored after the pager exited");
|
||||
assert!(
|
||||
!harness.contains_text("panicked"),
|
||||
"pager panicked\nscreen:\n{}",
|
||||
harness.screen_contents()
|
||||
);
|
||||
|
||||
quit_minimal(&mut harness);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user