test: measure panel shell startup path
This commit is contained in:
parent
7593202492
commit
caf18dbaab
0
.yoi/tickets/00001KVDQH839/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KVDQH839/artifacts/.gitkeep
Normal file
13
.yoi/tickets/00001KVDQH839/artifacts/relations.json
Normal file
13
.yoi/tickets/00001KVDQH839/artifacts/relations.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
57
.yoi/tickets/00001KVDQH839/item.md
Normal file
57
.yoi/tickets/00001KVDQH839/item.md
Normal 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 基準で計測・改善する。
|
||||||
7
.yoi/tickets/00001KVDQH839/thread.md
Normal file
7
.yoi/tickets/00001KVDQH839/thread.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<!-- event: create author: "yoi ticket" at: 2026-06-18T16:02:56Z -->
|
||||||
|
|
||||||
|
## 作成
|
||||||
|
|
||||||
|
LocalTicketBackend によって作成されました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
@ -606,6 +606,7 @@ impl PanelHarness {
|
||||||
"xdg_config_home": config.xdg_config_home,
|
"xdg_config_home": config.xdg_config_home,
|
||||||
"xdg_runtime_dir": config.xdg_runtime_dir,
|
"xdg_runtime_dir": config.xdg_runtime_dir,
|
||||||
"fixture_root": config.fixture_root,
|
"fixture_root": config.fixture_root,
|
||||||
|
"launch_mode": "direct_exec",
|
||||||
"runtime_policy": {
|
"runtime_policy": {
|
||||||
"host_runtime_inherited": false,
|
"host_runtime_inherited": false,
|
||||||
"host_xdg_runtime_dir_present": std::env::var_os("XDG_RUNTIME_DIR").is_some(),
|
"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>(
|
pub fn wait_for<F>(
|
||||||
&mut self,
|
&mut self,
|
||||||
what: impl Into<String>,
|
what: impl Into<String>,
|
||||||
|
|
@ -1665,6 +1782,30 @@ fn command_display(program: &Path, args: &[String]) -> String {
|
||||||
.join(" ")
|
.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)> {
|
fn open_pty(size: (u16, u16)) -> Result<(File, File)> {
|
||||||
let mut master = 0;
|
let mut master = 0;
|
||||||
let mut slave = 0;
|
let mut slave = 0;
|
||||||
|
|
|
||||||
|
|
@ -372,6 +372,58 @@ fn panel_dashboard_content_ready_has_startup_budget() -> yoi_e2e::Result<()> {
|
||||||
Ok(())
|
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]
|
#[test]
|
||||||
fn panel_mouse_click_selects_row_without_dispatching_action() -> yoi_e2e::Result<()> {
|
fn panel_mouse_click_selects_row_without_dispatching_action() -> yoi_e2e::Result<()> {
|
||||||
let binary = yoi_binary()?;
|
let binary = yoi_binary()?;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user