Skip to content

Commit faeec31

Browse files
claudesinelaw
authored andcommitted
test(themes): fix never-hit waits; use wait_until — single theme ~10-13s
The git/LSP/terminal scenes were burning fixed time budgets because their conditions were never actually met: - The git file-status scene put the modified file in a subdir (src/main.rs) that the explorer never auto-expands, so the "M" decoration was never visible and the poll ran to its full cap. Use a root-level file (matching the passing file_explorer git test); the "M" now appears in ~70ms. - Sharing the main harness's dir-context broke git_explorer plugin loading; each extra-harness scene now builds its own theme context (make_theme_ctx). - Replace every fixed sleep / polling budget with wait_until, which returns the instant its condition holds: git "M" ~70ms, LSP "(on)" pill ~10ms, terminal pane ~10ms (were 3s / 0.6s / 0.5s). The LSP harness also skips plugin loading. Verified the fast conditions are real: file_status_modified_fg, status_lsp_on_bg and terminal_bg each render in their own frame. A single theme now renders in ~10-13s (before + after), down from ~18-20s. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cj5c8LTx6qGzHrHD8v2qGd
1 parent fa7ec84 commit faeec31

3 files changed

Lines changed: 61 additions & 73 deletions

File tree

.github/workflows/theme-screenshots.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ jobs:
2424
theme-diff:
2525
name: theme diff gallery
2626
runs-on: ubuntu-latest
27-
# Each changed theme renders ~15-20s (before + after); a job-level cap keeps
28-
# a stuck render from wedging the runner. The render step also wraps cargo
29-
# in `timeout` for a tighter, clearer failure.
27+
# Each changed theme renders ~10-12s (before + after); a job-level cap keeps
28+
# a stuck wait_until from wedging the runner. The render step also wraps
29+
# cargo in `timeout` for a tighter, clearer failure.
3030
timeout-minutes: 20
3131
steps:
3232
- name: Checkout (full history for base diff)

crates/fresh-editor/tests/e2e/theme_screenshots.rs

Lines changed: 51 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -608,11 +608,9 @@ fn scene_terminal(h: &mut EditorTestHarness, s: &mut BlogShowcase) {
608608
}
609609
h.render().unwrap();
610610
h.send_key(KeyCode::Enter, KeyModifiers::NONE).unwrap();
611-
h.render().unwrap();
612-
613-
// Give the PTY a beat to spawn its shell and paint a prompt, but return as
614-
// soon as the terminal pane shows output (usually well under the cap).
615-
poll_until(h, 300, |h| h.screen_to_string().contains('$'));
611+
// Wait for the terminal pane to exist (its tab name appears) — that's when
612+
// terminal_bg/fg start rendering. Returns the instant it's up.
613+
let _ = h.wait_until(|h| h.screen_to_string().contains("Terminal"));
616614
h.render().unwrap();
617615
snap(h, s, Some("Terminal"), 300);
618616

@@ -883,30 +881,33 @@ fn rewrite_theme_name(json: &str, token: &str) -> String {
883881
}
884882
}
885883

884+
/// Create a throwaway config dir holding the theme version under `theme_token`
885+
/// as a user theme, so a harness can select it via `config.theme`. Each harness
886+
/// gets its *own* context (sharing one across harnesses breaks plugin loading),
887+
/// so the returned `TempDir` must be kept alive for the harness's lifetime.
888+
fn make_theme_ctx(
889+
theme_token: &str,
890+
theme_json: &str,
891+
) -> Option<(tempfile::TempDir, DirectoryContext)> {
892+
let cfg_temp = tempfile::TempDir::new().ok()?;
893+
let ctx = DirectoryContext::for_testing(cfg_temp.path());
894+
let themes_dir = ctx.themes_dir();
895+
fs::create_dir_all(&themes_dir).ok()?;
896+
let rewritten = rewrite_theme_name(theme_json, theme_token);
897+
fs::write(themes_dir.join(format!("{theme_token}.json")), &rewritten).ok()?;
898+
Some((cfg_temp, ctx))
899+
}
900+
886901
/// Render the scene suite with `theme_json` active, writing frames under
887902
/// `docs/blog/<gallery_name>/`. Returns `true` if the theme actually loaded
888903
/// and frames were produced; `false` (without writing frames) if the version
889904
/// could not be selected — which happens when a baseline JSON is incompatible
890905
/// with the current theme schema. The caller decides whether that's fatal.
891906
fn try_render_version(theme_token: &str, theme_json: &str, gallery_name: &str) -> bool {
892-
let cfg_temp = match tempfile::TempDir::new() {
893-
Ok(t) => t,
894-
Err(e) => {
895-
eprintln!("theme-diff: could not create temp dir: {e}");
896-
return false;
897-
}
898-
};
899-
let ctx = DirectoryContext::for_testing(cfg_temp.path());
900-
let themes_dir = ctx.themes_dir();
901-
if let Err(e) = fs::create_dir_all(&themes_dir) {
902-
eprintln!("theme-diff: could not create themes dir: {e}");
907+
let Some((cfg_temp, ctx)) = make_theme_ctx(theme_token, theme_json) else {
908+
eprintln!("theme-diff: could not stage theme ctx for '{theme_token}'");
903909
return false;
904-
}
905-
let rewritten = rewrite_theme_name(theme_json, theme_token);
906-
if let Err(e) = fs::write(themes_dir.join(format!("{theme_token}.json")), &rewritten) {
907-
eprintln!("theme-diff: could not write theme file: {e}");
908-
return false;
909-
}
910+
};
910911

911912
let mut config = Config {
912913
theme: ThemeName(theme_token.to_string()),
@@ -921,9 +922,7 @@ fn try_render_version(theme_token: &str, theme_json: &str, gallery_name: &str) -
921922
let opts = HarnessOptions::new()
922923
.with_project_root()
923924
.with_config(config)
924-
// Clone the shared dir context (themes dir) so the git/LSP scenes below
925-
// can spin up their own harnesses against the same theme.
926-
.with_shared_dir_context(ctx.clone())
925+
.with_shared_dir_context(ctx)
927926
.with_full_grammar_registry();
928927
let mut h = match EditorTestHarness::create(120, 35, opts) {
929928
Ok(h) => h,
@@ -956,11 +955,12 @@ fn try_render_version(theme_token: &str, theme_json: &str, gallery_name: &str) -
956955

957956
run_all_scenes(&mut h, &mut s);
958957
drop(h); // release the main harness before spinning up the extra ones
958+
drop(cfg_temp); // main theme ctx no longer needed
959959

960960
// Scenes that need a different harness setup (own working dir / LSP config)
961-
// but the same theme. They share the themes dir via the cloned dir context.
962-
scene_git_file_status(&mut s, ctx.clone(), theme_token);
963-
scene_lsp_status(&mut s, ctx, theme_token);
961+
// but the same theme. Each makes its own theme ctx (see make_theme_ctx).
962+
scene_git_file_status(&mut s, theme_token, theme_json);
963+
scene_lsp_status(&mut s, theme_token, theme_json);
964964

965965
s.finalize().expect("finalize diff gallery");
966966
true
@@ -971,15 +971,19 @@ fn try_render_version(theme_token: &str, theme_json: &str, gallery_name: &str) -
971971
/// file_status_added_fg, file_status_untracked_fg. Best-effort — if the git
972972
/// plugin/status doesn't surface in time it snaps whatever rendered (the same
973973
/// on both before/after, so frames stay aligned).
974-
fn scene_git_file_status(s: &mut BlogShowcase, ctx: DirectoryContext, theme_token: &str) {
974+
fn scene_git_file_status(s: &mut BlogShowcase, theme_token: &str, theme_json: &str) {
975+
let Some((_cfg_temp, ctx)) = make_theme_ctx(theme_token, theme_json) else {
976+
return;
977+
};
975978
let repo = GitTestRepo::new();
976979
repo.setup_git_explorer_plugin();
977-
repo.create_file("src/main.rs", "fn main() {}\n");
980+
// Root-level files so the explorer shows them without expanding a subdir.
981+
repo.create_file("main.rs", "fn main() {}\n");
978982
repo.create_file("README.md", "# demo\n");
979983
repo.git_add_all();
980984
repo.git_commit("initial");
981-
// Now create the working-tree states the explorer decorates.
982-
repo.modify_file("src/main.rs", "fn main() { println!(\"hi\"); }\n");
985+
// Working-tree states the explorer decorates: a modified + an untracked file.
986+
repo.modify_file("main.rs", "fn main() { println!(\"hi\"); }\n");
983987
repo.create_file("notes.txt", "untracked\n");
984988

985989
let config = Config {
@@ -1000,13 +1004,11 @@ fn scene_git_file_status(s: &mut BlogShowcase, ctx: DirectoryContext, theme_toke
10001004
};
10011005

10021006
h.editor_mut().toggle_file_explorer();
1003-
poll_until(&mut h, 2000, |h| {
1004-
h.screen_to_string().contains("File Explorer")
1005-
});
1006-
// Bounded wait for the git plugin to decorate a modified file. `wait_until`
1007-
// has no timeout, so we poll ourselves and snap whatever rendered — the
1008-
// same on both before/after, so frames stay aligned even if git is slow.
1009-
poll_until(&mut h, 3000, |h| {
1007+
let _ = h.wait_until(|h| h.screen_to_string().contains("File Explorer"));
1008+
// Wait for the git plugin to decorate the modified file with its "M"
1009+
// status (file_status_modified_fg). `wait_until` returns the instant the
1010+
// condition holds, pumping async each tick (~tens of ms in practice).
1011+
let _ = h.wait_until(|h| {
10101012
h.screen_to_string()
10111013
.lines()
10121014
.any(|l| l.contains("main.rs") && l.contains('M'))
@@ -1015,31 +1017,13 @@ fn scene_git_file_status(s: &mut BlogShowcase, ctx: DirectoryContext, theme_toke
10151017
snap(&mut h, s, Some("Git Status"), 300);
10161018
}
10171019

1018-
/// Poll up to `max_ms`, ticking the harness, until `cond` holds. Returns
1019-
/// whether the condition was met. Unlike `wait_until` this has a timeout, so a
1020-
/// best-effort scene never hangs the suite when a feature doesn't surface.
1021-
fn poll_until(
1022-
h: &mut EditorTestHarness,
1023-
max_ms: u64,
1024-
mut cond: impl FnMut(&EditorTestHarness) -> bool,
1025-
) -> bool {
1026-
let start = std::time::Instant::now();
1027-
loop {
1028-
let _ = h.tick_and_render();
1029-
if cond(h) {
1030-
return true;
1031-
}
1032-
if start.elapsed().as_millis() as u64 >= max_ms {
1033-
return false;
1034-
}
1035-
h.sleep(std::time::Duration::from_millis(50));
1036-
}
1037-
}
1038-
10391020
/// Scene: LSP status indicator in the status bar (own harness with a fake LSP
10401021
/// attached, sharing the theme). Covers: ui.status_lsp_on_fg/bg. Best-effort
10411022
/// and PTY/bash-dependent; skips cleanly if the fake server can't spawn.
1042-
fn scene_lsp_status(s: &mut BlogShowcase, ctx: DirectoryContext, theme_token: &str) {
1023+
fn scene_lsp_status(s: &mut BlogShowcase, theme_token: &str, theme_json: &str) {
1024+
let Some((_cfg_temp, ctx)) = make_theme_ctx(theme_token, theme_json) else {
1025+
return;
1026+
};
10431027
let lsp_dir = match tempfile::TempDir::new() {
10441028
Ok(d) => d,
10451029
Err(_) => return,
@@ -1087,7 +1071,8 @@ fn scene_lsp_status(s: &mut BlogShowcase, ctx: DirectoryContext, theme_token: &s
10871071
.with_working_dir(work_dir)
10881072
.with_config(config)
10891073
.with_shared_dir_context(ctx)
1090-
.without_empty_plugins_dir();
1074+
// LSP status needs no plugins; skip embedded plugin loading for speed.
1075+
.with_empty_plugins_dir();
10911076
let mut h = match EditorTestHarness::create(120, 35, opts) {
10921077
Ok(h) => h,
10931078
Err(e) => {
@@ -1099,11 +1084,10 @@ fn scene_lsp_status(s: &mut BlogShowcase, ctx: DirectoryContext, theme_token: &s
10991084
if h.open_file(&test_file).is_err() {
11001085
return;
11011086
}
1102-
// Let the fake server initialize so the status-bar LSP indicator settles.
1103-
for _ in 0..12 {
1104-
let _ = h.process_async_and_render();
1105-
h.sleep(std::time::Duration::from_millis(50));
1106-
}
1087+
// Wait until the fake server is running and the status bar shows the
1088+
// "on" pill (status_lsp_on_* colors). `wait_until` returns the instant
1089+
// it appears, pumping async LSP messages each tick.
1090+
let _ = h.wait_until(|h| h.screen_to_string().contains("LSP (on)"));
11071091
let _ = h.render();
11081092
snap(&mut h, s, Some("LSP Status"), 300);
11091093

docs/theme-screenshot-diff.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,13 @@ The test is `theme_diff_gallery`
8282
- **Completeness backstop.** The per-theme changed-keys table lists *every*
8383
key the PR changed (with swatches), so a key that no scene happens to show
8484
in context is still surfaced explicitly.
85-
- **Cost.** A theme renders in ~15-20s (before + after, two extra harnesses for
86-
git/LSP); the run scales linearly with the number of *changed* themes. The
87-
CI job and the render step are both time-boxed so a hang fails fast.
85+
- **Cost.** A theme renders in ~10-12s (before + after). Every wait uses
86+
`wait_until`, which returns the instant its condition holds (git status,
87+
LSP "on" pill, and terminal pane each settle in tens of ms) — no fixed
88+
sleeps or polling budgets. The bulk is the ~4s/side of real keystroke +
89+
render work across the scene suite. The run scales linearly with the number
90+
of *changed* themes; the CI job and render step are time-boxed so a stuck
91+
`wait_until` fails fast instead of hanging.
8892
- **Determinism.** Both sides run the identical scene sequence at the same
8993
terminal size, so frame indices line up one-to-one for pairing.
9094
- **Robustness.** Missing git, a missing base file, or an unparseable baseline

0 commit comments

Comments
 (0)