From caf18dbaab7a50e1a96489c1b69af7bfbbe9a2e2 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 19 Jun 2026 13:04:45 +0900 Subject: [PATCH] test: measure panel shell startup path --- .yoi/tickets/00001KVDQH839/artifacts/.gitkeep | 0 .../00001KVDQH839/artifacts/relations.json | 13 ++ .yoi/tickets/00001KVDQH839/item.md | 57 +++++++ .yoi/tickets/00001KVDQH839/thread.md | 7 + tests/e2e/src/lib.rs | 141 ++++++++++++++++++ tests/e2e/tests/panel.rs | 52 +++++++ 6 files changed, 270 insertions(+) create mode 100644 .yoi/tickets/00001KVDQH839/artifacts/.gitkeep create mode 100644 .yoi/tickets/00001KVDQH839/artifacts/relations.json create mode 100644 .yoi/tickets/00001KVDQH839/item.md create mode 100644 .yoi/tickets/00001KVDQH839/thread.md diff --git a/.yoi/tickets/00001KVDQH839/artifacts/.gitkeep b/.yoi/tickets/00001KVDQH839/artifacts/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/.yoi/tickets/00001KVDQH839/artifacts/relations.json b/.yoi/tickets/00001KVDQH839/artifacts/relations.json new file mode 100644 index 00000000..f5eee181 --- /dev/null +++ b/.yoi/tickets/00001KVDQH839/artifacts/relations.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "relations": [ + { + "ticket_id": "00001KVDQH839", + "kind": "related", + "target": "00001KVDETSN6", + "note": "Adds shell-enter launch-path coverage on top of dashboard content-ready metric.", + "author": "yoi ticket", + "at": "2026-06-18T16:03:59Z" + } + ] +} diff --git a/.yoi/tickets/00001KVDQH839/item.md b/.yoi/tickets/00001KVDQH839/item.md new file mode 100644 index 00000000..1600bf19 --- /dev/null +++ b/.yoi/tickets/00001KVDQH839/item.md @@ -0,0 +1,57 @@ +--- +title: 'Panel E2E に shell Enter 起動経路の dashboard readiness 計測を追加する' +state: 'done' +created_at: '2026-06-18T16:02:56Z' +updated_at: '2026-06-18T16:03:59Z' +assignee: null +readiness: 'implementation_ready' +risk_flags: ['panel', 'e2e', 'startup-latency', 'shell-launch', 'dashboard-content-ready'] +--- + +## Background + +既存の Panel dashboard readiness E2E は direct `Command::spawn(yoi panel ...)` から dashboard content ready までを測っていた。ユーザー目線の「Enter した瞬間から実コンテンツが表示されるまで」に近づけるため、isolated fixture は維持しつつ、shell 上で command line を投入して Enter された起動経路を部分的に模した E2E を追加する。 + +この Ticket は live workspace の遅延改善そのものではなく、E2E の起動経路を実利用に近づける追加計測である。 + +## Requirements + +- PTY 上で `/bin/sh` を起動し、`exec panel ...` を command line として送って Enter 相当から測定する。 +- 測定開始点は command line を PTY に送る直前とする。 +- dashboard readiness は既存の `dashboard_content_ready` snapshot matcher を使う。 +- first frame だけ、単一 row だけでは通さない。 +- Isolated fixture / isolated HOME / XDG dirs / runtime dirs は維持する。 +- `YOI_POD_RUNTIME_COMMAND` は tested binary を明示して渡す。 +- Direct spawn E2E は残し、shell-enter path は追加 coverage とする。 + +## Implementation summary + +- `PanelHarness::spawn_via_shell_enter` を追加した。 + - `/bin/sh` を PTY 上で起動。 + - `exec '' ''...` を送信。 + - command line送信直前の `Instant` を返す。 + - artifacts `run.json` に `launch_mode: shell_enter_exec` を記録。 +- shell quote helper を追加した。 +- `panel_dashboard_content_ready_from_shell_enter_path` E2E を追加した。 + - Enter相当から first frame / dashboard content ready を測定。 + - expected dashboard snapshot / source breakdown を検証。 + +## Validation + +- `cargo test -p yoi-e2e --features e2e --test panel panel_dashboard_content_ready_from_shell_enter_path -- --nocapture` + - observed: dashboard content ready 約 `220ms`, first frame 約 `20ms` in isolated fixture。 +- `cargo test -p yoi-e2e --features e2e --test panel` +- `cargo check -p yoi-e2e -p yoi -p tui --features tui/e2e-test` +- `cargo fmt --check` +- `git diff --check` + +## Non-goals + +- Live workspace startup latency の改善。 +- Interactive shell の command lookup / user typing latency の完全再現。 +- User's actual shell rc/profile を読むこと。 +- Panel architecture / lifecycle の変更。 + +## Related work + +- `00001KVDETSN6` — Panel startup latency をユーザー目線の dashboard content ready 基準で計測・改善する。 diff --git a/.yoi/tickets/00001KVDQH839/thread.md b/.yoi/tickets/00001KVDQH839/thread.md new file mode 100644 index 00000000..5f82d5fb --- /dev/null +++ b/.yoi/tickets/00001KVDQH839/thread.md @@ -0,0 +1,7 @@ + + +## 作成 + +LocalTicketBackend によって作成されました。 + +--- diff --git a/tests/e2e/src/lib.rs b/tests/e2e/src/lib.rs index ad643279..6c7b5563 100644 --- a/tests/e2e/src/lib.rs +++ b/tests/e2e/src/lib.rs @@ -606,6 +606,7 @@ impl PanelHarness { "xdg_config_home": config.xdg_config_home, "xdg_runtime_dir": config.xdg_runtime_dir, "fixture_root": config.fixture_root, + "launch_mode": "direct_exec", "runtime_policy": { "host_runtime_inherited": false, "host_xdg_runtime_dir_present": std::env::var_os("XDG_RUNTIME_DIR").is_some(), @@ -686,6 +687,122 @@ impl PanelHarness { }) } + pub fn spawn_via_shell_enter(config: PanelHarnessConfig) -> Result<(Self, Instant)> { + if !config.binary.exists() { + return Err(HarnessError::MissingBinary(config.binary)); + } + fs::create_dir_all(&config.artifacts_dir)?; + let artifacts = PanelArtifacts { + dir: config.artifacts_dir.clone(), + events_jsonl: config.artifacts_dir.join("events.jsonl"), + input_log: config.artifacts_dir.join("input.log"), + output_log: config.artifacts_dir.join("pty-output.log"), + run_json: config.artifacts_dir.join("run.json"), + }; + fs::write(&artifacts.events_jsonl, "")?; + fs::write(&artifacts.input_log, "")?; + fs::write(&artifacts.output_log, "")?; + let env_policy = + tui_env_policy(config.hold_background_task.is_some(), config.rewind_fixture); + fs::write( + &artifacts.run_json, + serde_json::to_vec_pretty(&serde_json::json!({ + "binary": config.binary, + "args": &config.command_args, + "workspace": config.workspace, + "home": config.home, + "xdg_data_home": config.xdg_data_home, + "xdg_state_home": config.xdg_state_home, + "xdg_config_home": config.xdg_config_home, + "xdg_runtime_dir": config.xdg_runtime_dir, + "fixture_root": config.fixture_root, + "launch_mode": "shell_enter_exec", + "runtime_policy": { + "host_runtime_inherited": false, + "host_xdg_runtime_dir_present": std::env::var_os("XDG_RUNTIME_DIR").is_some(), + "tested_yoi_runtime_source": "fixture XDG_RUNTIME_DIR" + }, + "terminal_size": { + "columns": config.terminal_size.0, + "rows": config.terminal_size.1, + }, + "hold_background_task": config.hold_background_task, + "rewind_fixture": config.rewind_fixture, + "tested_yoi_env_policy": &env_policy, + }))?, + )?; + + let (master, slave) = open_pty(config.terminal_size)?; + let slave_for_stdin = slave.try_clone()?; + let slave_for_stdout = slave.try_clone()?; + let mut command = Command::new("/bin/sh"); + command + .current_dir(&config.workspace) + .env_clear() + .env("YOI_TUI_TEST_EVENTS", &artifacts.events_jsonl) + .env("YOI_POD_RUNTIME_COMMAND", &config.binary) + .env("HOME", &config.home) + .env("XDG_DATA_HOME", &config.xdg_data_home) + .env("XDG_STATE_HOME", &config.xdg_state_home) + .env("XDG_CONFIG_HOME", &config.xdg_config_home) + .env("XDG_RUNTIME_DIR", &config.xdg_runtime_dir) + .env("TERM", "xterm-256color") + .stdin(Stdio::from(slave_for_stdin)) + .stdout(Stdio::from(slave_for_stdout)) + .stderr(Stdio::from(slave)); + if let Some(task) = &config.hold_background_task { + command.env("YOI_TUI_TEST_HOLD_BACKGROUND_TASK", task); + } + if config.rewind_fixture { + command.env("YOI_TUI_TEST_REWIND_FIXTURE", "1"); + } + let child = command.spawn()?; + + let output = Arc::new(Mutex::new(Vec::new())); + let output_for_thread = Arc::clone(&output); + let mut reader_file = master.try_clone()?; + let output_log = artifacts.output_log.clone(); + let reader = thread::spawn(move || { + let mut sink = OpenOptions::new() + .append(true) + .create(true) + .open(output_log) + .ok(); + let mut buf = [0_u8; 4096]; + loop { + match reader_file.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + if let Some(sink) = sink.as_mut() { + let _ = sink.write_all(&buf[..n]); + } + if let Ok(mut output) = output_for_thread.lock() { + output.extend_from_slice(&buf[..n]); + } + } + Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + } + }); + + let mut harness = Self { + child, + master, + reader: Some(reader), + output, + last_event_offset: 0, + artifacts, + }; + let command_line = shell_exec_command(&config.binary, &config.command_args); + let started = Instant::now(); + harness.write_input( + "shell Enter yoi panel", + format!("{command_line}\n").as_bytes(), + )?; + Ok((harness, started)) + } + pub fn wait_for( &mut self, what: impl Into, @@ -1665,6 +1782,30 @@ fn command_display(program: &Path, args: &[String]) -> String { .join(" ") } +fn shell_exec_command(program: &Path, args: &[String]) -> String { + std::iter::once("exec".to_string()) + .chain(std::iter::once(shell_quote(&program.to_string_lossy()))) + .chain(args.iter().map(|arg| shell_quote(arg))) + .collect::>() + .join(" ") +} + +fn shell_quote(value: &str) -> String { + if value.is_empty() { + return "''".to_string(); + } + let mut quoted = String::from("'"); + for ch in value.chars() { + if ch == '\'' { + quoted.push_str("'\\''"); + } else { + quoted.push(ch); + } + } + quoted.push('\''); + quoted +} + fn open_pty(size: (u16, u16)) -> Result<(File, File)> { let mut master = 0; let mut slave = 0; diff --git a/tests/e2e/tests/panel.rs b/tests/e2e/tests/panel.rs index c755ef22..a7395502 100644 --- a/tests/e2e/tests/panel.rs +++ b/tests/e2e/tests/panel.rs @@ -372,6 +372,58 @@ fn panel_dashboard_content_ready_has_startup_budget() -> yoi_e2e::Result<()> { Ok(()) } +#[test] +fn panel_dashboard_content_ready_from_shell_enter_path() -> yoi_e2e::Result<()> { + let binary = yoi_binary()?; + let fixture = FixtureWorkspace::new(&binary)?; + assert_fixture_paths_are_isolated(&fixture); + let expected_content = fixture.expected_dashboard_content(); + + let (mut panel, started) = PanelHarness::spawn_via_shell_enter(fixture.panel_config(binary))?; + let first_visible_remaining = FIRST_VISIBLE_RENDER_BUDGET + .checked_sub(started.elapsed()) + .unwrap_or_else(|| Duration::from_millis(0)); + panel.wait_for_first_visible_frame(first_visible_remaining)?; + let first_visible_elapsed = started.elapsed(); + + let content_ready_remaining = DASHBOARD_CONTENT_READY_BUDGET + .checked_sub(started.elapsed()) + .unwrap_or_else(|| Duration::from_millis(0)); + let content_ready = + panel.wait_for_dashboard_content_ready(&expected_content, content_ready_remaining)?; + assert_eq!( + content_ready.snapshot_for_expected(&expected_content), + expected_content.snapshot(), + "shell-enter dashboard content ready must match expected Ticket/action/overlay/header snapshot; artifacts at {}", + panel.artifacts().dir.display() + ); + let content_ready_elapsed = started.elapsed(); + eprintln!( + "panel shell-enter dashboard content ready: {content_ready_elapsed:?} (budget {DASHBOARD_CONTENT_READY_BUDGET:?}; first frame {first_visible_elapsed:?}); artifacts at {}", + panel.artifacts().dir.display() + ); + assert!( + content_ready_elapsed <= DASHBOARD_CONTENT_READY_BUDGET, + "shell-enter dashboard content ready took {content_ready_elapsed:?}, budget {DASHBOARD_CONTENT_READY_BUDGET:?}; artifacts at {}", + panel.artifacts().dir.display() + ); + + let source_breakdown = panel.expect_dashboard_source_breakdown()?; + assert!( + source_breakdown.has_source("workspace_panel.build.total"), + "shell-enter dashboard source breakdown should include total panel build source; got {:?}; artifacts at {}", + source_breakdown, + panel.artifacts().dir.display() + ); + + panel.press(KeyPress::CtrlC)?; + let status = panel.expect_exit_within(PanelHarness::default_exit_wait())?; + assert!(status.success(), "panel should exit cleanly with Ctrl+C"); + drop(panel); + assert_fixture_cleanup(fixture.cleanup()?); + Ok(()) +} + #[test] fn panel_mouse_click_selects_row_without_dispatching_action() -> yoi_e2e::Result<()> { let binary = yoi_binary()?;