test: measure panel shell startup path

This commit is contained in:
Keisuke Hirata 2026-06-19 13:04:45 +09:00
parent 7593202492
commit caf18dbaab
No known key found for this signature in database
6 changed files with 270 additions and 0 deletions

View File

@ -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"
}
]
}

View File

@ -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 <yoi> 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 '<binary>' '<args>'...` を送信。
- 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 基準で計測・改善する。

View File

@ -0,0 +1,7 @@
<!-- event: create author: "yoi ticket" at: 2026-06-18T16:02:56Z -->
## 作成
LocalTicketBackend によって作成されました。
---

View File

@ -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<F>(
&mut self,
what: impl Into<String>,
@ -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::<Vec<_>>()
.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;

View File

@ -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()?;