From 59ae40aac5e6907f39585903269b12818903f1a6 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 19:05:41 -0600 Subject: [PATCH 1/8] test(cli): the terminal helpers quote each word (a macOS root has a space) The portable macOS job: the command line handed to `script` was the words joined by spaces, and `Library/Application Support/wanderbooks` split into two arguments ("unknown argument: Support/wanderbooks"). Each word is single-quoted now, on every OS; a word that is a shell operator a test means as one (the consent test's `>`) stays bare. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- crates/ledger-cli/tests/format_upgrade.rs | 26 ++++++++++++++++++---- crates/ledger-cli/tests/open_while_held.rs | 19 +++++++++++++++- crates/ledger-cli/tests/setup.rs | 26 ++++++++++++++++++---- 3 files changed, 62 insertions(+), 9 deletions(-) diff --git a/crates/ledger-cli/tests/format_upgrade.rs b/crates/ledger-cli/tests/format_upgrade.rs index b5a9dba..c5b5d44 100644 --- a/crates/ledger-cli/tests/format_upgrade.rs +++ b/crates/ledger-cli/tests/format_upgrade.rs @@ -139,10 +139,11 @@ impl Home { /// asked for (a prompt ending ": " or "] "): its exit code and what /// the terminal showed. fn at_terminal(&self, words: &[String], lines: &[&str]) -> (i32, String) { - let command = std::iter::once(env!("CARGO_BIN_EXE_wanderbooks").to_string()) - .chain(words.iter().cloned()) - .collect::>() - .join(" "); + let command = shell_join( + &std::iter::once(env!("CARGO_BIN_EXE_wanderbooks").to_string()) + .chain(words.iter().cloned()) + .collect::>(), + ); let mut child = Command::new("script") .args(script_args(&command)) .env_clear() @@ -985,3 +986,20 @@ fn script_args(command: &str) -> Vec { }; args.iter().map(|a| a.to_string()).collect() } + +/// `words` as one shell command line, each word single-quoted (a macOS +/// root has a space in it: `Library/Application Support`), except a +/// word that is a shell operator a test means as one (`>`, `2>&1`, `|`). +fn shell_join(words: &[String]) -> String { + words + .iter() + .map(|w| { + if matches!(w.as_str(), ">" | ">>" | "<" | "|" | "2>&1") { + w.clone() + } else { + format!("'{}'", w.replace('\'', "'\\''")) + } + }) + .collect::>() + .join(" ") +} diff --git a/crates/ledger-cli/tests/open_while_held.rs b/crates/ledger-cli/tests/open_while_held.rs index d1275c8..8bb5c28 100644 --- a/crates/ledger-cli/tests/open_while_held.rs +++ b/crates/ledger-cli/tests/open_while_held.rs @@ -1944,7 +1944,7 @@ fn the_books_command_takes_its_flags_and_asks_before_adopting() { args.push(a.to_string()); } let mut child = Command::new("script") - .args(script_args(&args.join(" "))) + .args(script_args(&shell_join(&args))) .env_clear() .env("PATH", "/usr/bin:/bin") .env("HOME", home.dir.path()) @@ -2960,3 +2960,20 @@ fn script_args(command: &str) -> Vec { }; args.iter().map(|a| a.to_string()).collect() } + +/// `words` as one shell command line, each word single-quoted (a macOS +/// root has a space in it: `Library/Application Support`), except a +/// word that is a shell operator a test means as one (`>`, `2>&1`, `|`). +fn shell_join(words: &[String]) -> String { + words + .iter() + .map(|w| { + if matches!(w.as_str(), ">" | ">>" | "<" | "|" | "2>&1") { + w.clone() + } else { + format!("'{}'", w.replace('\'', "'\\''")) + } + }) + .collect::>() + .join(" ") +} diff --git a/crates/ledger-cli/tests/setup.rs b/crates/ledger-cli/tests/setup.rs index 8e2dddb..e7f5868 100644 --- a/crates/ledger-cli/tests/setup.rs +++ b/crates/ledger-cli/tests/setup.rs @@ -176,10 +176,11 @@ fn script_args(command: &str) -> Vec { /// showed. fn at_terminal(args: &[String], lines: &[&str]) -> (i32, String) { use std::io::Write; - let command = std::iter::once(env!("CARGO_BIN_EXE_wanderbooks").to_string()) - .chain(args.iter().cloned()) - .collect::>() - .join(" "); + let command = shell_join( + &std::iter::once(env!("CARGO_BIN_EXE_wanderbooks").to_string()) + .chain(args.iter().cloned()) + .collect::>(), + ); let mut child = Command::new("script") .args(script_args(&command)) .env_remove("XDG_DATA_HOME") @@ -323,3 +324,20 @@ fn the_passphrase_is_typed_by_a_person_and_changed_only_with_the_one_in_force() let kept = std::fs::read_to_string(auth.join("owner.json")).unwrap(); assert!(!kept.contains("staple"), "the text itself was kept"); } + +/// `words` as one shell command line, each word single-quoted (a macOS +/// root has a space in it: `Library/Application Support`), except a +/// word that is a shell operator a test means as one (`>`, `2>&1`, `|`). +fn shell_join(words: &[String]) -> String { + words + .iter() + .map(|w| { + if matches!(w.as_str(), ">" | ">>" | "<" | "|" | "2>&1") { + w.clone() + } else { + format!("'{}'", w.replace('\'', "'\\''")) + } + }) + .collect::>() + .join(" ") +} From 3490ab6fa82d17a6cb229e418ff6a87a871c9702 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 19:25:20 -0600 Subject: [PATCH 2/8] ci(check): the update tests run alone first, with output (temporary) The staged-update test fails on the runner on two runs in a row (main a8ce2e7d, branch P11c), passes locally even on one slow core, and the gate's step log keeps only its last lines. A step of its own shows the message; it goes once the cause is fixed. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- .github/workflows/check.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 6f78332..94c5add 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -30,6 +30,11 @@ jobs: - uses: jdx/mise-action@v2 - name: GUI build dependencies run: sudo apt-get update && sudo apt-get install -y libxkbcommon-dev libgtk-3-dev + # Temporary (2026-10-30): one update test fails only on this runner + # (passes locally, pinned to one slow core too) and the gate's step + # log loses its message. Run that file first, alone, with output. + - name: The update tests, alone, with output (temporary) + run: cargo test -p ledger-cli --test update -- --nocapture --test-threads=1 - run: just check - name: What the build used if: always() From 134e1424cf41544a22607e32d7e8308a6daab4a6 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 19:40:02 -0600 Subject: [PATCH 3/8] ci(check): the CLI crate's tests run in their own step (temporary) Alone, the update tests passed on the runner; inside the whole run one fails and the step log loses its message. The CLI crate's suites run first in a step of their own so the message survives. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- .github/workflows/check.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 94c5add..9980691 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -31,10 +31,12 @@ jobs: - name: GUI build dependencies run: sudo apt-get update && sudo apt-get install -y libxkbcommon-dev libgtk-3-dev # Temporary (2026-10-30): one update test fails only on this runner - # (passes locally, pinned to one slow core too) and the gate's step - # log loses its message. Run that file first, alone, with output. - - name: The update tests, alone, with output (temporary) - run: cargo test -p ledger-cli --test update -- --nocapture --test-threads=1 + # and only inside the whole run (alone it passed here; locally it + # passes even on two slow cores), and the gate's step log loses its + # message. Run the CLI crate's suites first, in their own step, so + # the message survives. + - name: The CLI crate's tests, in their own step (temporary) + run: cargo test -p ledger-cli --no-fail-fast - run: just check - name: What the build used if: always() From a944d0f51b9579b6b95d4e2195b78252907bf993 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 20:06:46 -0600 Subject: [PATCH 4/8] test(viewer): the fake agent waits a whole second (macOS's bash 3.2 takes no fraction) The portable macOS job: `read -t 0.1` is "invalid timeout specification" in /bin/bash 3.2, so the fake agent never said ready. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- crates/ledger-gui/tests/dock.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ledger-gui/tests/dock.rs b/crates/ledger-gui/tests/dock.rs index 12738d0..067ed35 100644 --- a/crates/ledger-gui/tests/dock.rs +++ b/crates/ledger-gui/tests/dock.rs @@ -105,8 +105,10 @@ echo "fake-agent in $(basename "$(pwd)")" trap 'echo got-interrupt' INT trap 'echo "size $(stty size)"' WINCH echo ready +# A whole second: macOS's /bin/bash is 3.2, whose `read -t` takes no +# fraction (the portable job found it). while true; do - if read -r -t 0.1 line; then echo "you-said: $line"; fi + if read -r -t 1 line; then echo "you-said: $line"; fi done "#, ) From 888742166b82a3155efaa191384dc90a1bac9f5d Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 20:42:59 -0600 Subject: [PATCH 5/8] test(cli): the update tests wait for the host's asynchronous work, and compare programs by hash Two tests raced the host: one asserted the staged copy was applied the instant the books reopened, the other that a dropped tampered copy's folder was gone, when the new host may look at the feed again and stage a fresh, true copy in its place. Both wait for what they mean now (the apply; the tampered bytes gone). Every whole-program comparison is on the sha256, so a failure's message is a line, not two binaries (GitHub kept nine lines of a step's log on one such message). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- crates/ledger-cli/tests/update.rs | 83 +++++++++++++++++++++++-------- 1 file changed, 63 insertions(+), 20 deletions(-) diff --git a/crates/ledger-cli/tests/update.rs b/crates/ledger-cli/tests/update.rs index e6a2500..8410e57 100644 --- a/crates/ledger-cli/tests/update.rs +++ b/crates/ledger-cli/tests/update.rs @@ -24,6 +24,9 @@ fn platform() -> String { format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH) } +/// A program's hash: the tests compare whole programs by it, never by +/// their bytes, so a failure's message is a line, not the two binaries +/// (GitHub dropped a step's whole log on one such message). fn sha256(bytes: &[u8]) -> String { ledger_store::journal::sha256_hex(bytes) } @@ -371,7 +374,7 @@ fn an_update_replaces_the_program_keeps_the_old_one_and_rollback_brings_it_back( release.publish("0.0.1", &old, |_| {}); let older = home.run("update", Some(&release)); assert!(!older.status.success(), "{}", text(&older)); - assert_eq!(home.program(), old); + assert_eq!(sha256(&home.program()), sha256(&old)); assert!(!home.updates().join("previous").exists()); release.publish("9.0.0", &next, |_| {}); @@ -382,9 +385,16 @@ fn an_update_replaces_the_program_keeps_the_old_one_and_rollback_brings_it_back( let out = home.run("update", Some(&release)); assert!(out.status.success(), "{}", text(&out)); assert!(text(&out).contains("from 0.1.0 to 9.0.0"), "{}", text(&out)); - assert_eq!(home.program(), next, "the new program is in place"); + assert_eq!( + sha256(&home.program()), + sha256(&next), + "the new program is in place" + ); let kept = home.updates().join("previous"); - assert_eq!(std::fs::read(kept.join("wanderbooks")).unwrap(), old); + assert_eq!( + sha256(&std::fs::read(kept.join("wanderbooks")).unwrap()), + sha256(&old) + ); let meta: Value = serde_json::from_slice(&std::fs::read(kept.join("meta.json")).unwrap()).unwrap(); assert_eq!(meta["version"], "0.1.0"); @@ -397,7 +407,7 @@ fn an_update_replaces_the_program_keeps_the_old_one_and_rollback_brings_it_back( // Rollback: the old program is back, and nothing is kept to go back to. let out = home.run("rollback", None); assert!(out.status.success(), "{}", text(&out)); - assert_eq!(home.program(), old); + assert_eq!(sha256(&home.program()), sha256(&old)); assert!(!kept.exists()); let nothing = home.run("rollback", None); assert!(!nothing.status.success()); @@ -446,7 +456,7 @@ fn update_is_atomic() { assert!(out.status.success(), "{}", text(&out)); assert!(reads > 0); assert!(bad.is_empty(), "a reader saw a part of a program: {bad:?}"); - assert_eq!(home.program(), next); + assert_eq!(sha256(&home.program()), sha256(&next)); } #[test] @@ -459,7 +469,11 @@ fn failed_update_rolls_back() { let out = home.run("update", Some(&release)); assert!(!out.status.success(), "{}", text(&out)); assert!(text(&out).contains("didn't start"), "{}", text(&out)); - assert_eq!(home.program(), old, "the old program is back"); + assert_eq!( + sha256(&home.program()), + sha256(&old), + "the old program is back" + ); assert_eq!(names(home.exe.parent().unwrap()), ["wanderbooks"]); // And it still works. home.runnable(); @@ -500,7 +514,11 @@ fn unsigned_update_refused() { let out = home.run("update", Some(&release)); assert!(!out.status.success(), "{name}: {}", text(&out)); assert!(text(&out).contains("refused"), "{name}: {}", text(&out)); - assert_eq!(home.program(), old, "{name}: the program changed"); + assert_eq!( + sha256(&home.program()), + sha256(&old), + "{name}: the program changed" + ); assert!(!home.updates().join("previous").exists(), "{name}: kept"); assert!(!home.updates().join("9.0.0").exists(), "{name}: staged"); assert_eq!(names(home.exe.parent().unwrap()), ["wanderbooks"], "{name}"); @@ -550,7 +568,7 @@ fn package_managed_install_refuses_update() { text(&out) ); } - assert_eq!(home.program(), old); + assert_eq!(sha256(&home.program()), sha256(&old)); assert!(!home.updates().exists(), "nothing was made"); } @@ -578,7 +596,7 @@ fn rollback_refuses_after_a_book_consented_to_a_newer_format() { "{}", text(&out) ); - assert_eq!(home.program(), next, "the program stayed"); + assert_eq!(sha256(&home.program()), sha256(&next), "the program stayed"); assert!(home.updates().join("previous/wanderbooks").is_file()); // Declared what the kept version knows: it goes back. let mut meta: Value = serde_json::from_slice(&std::fs::read(&meta_path).unwrap()).unwrap(); @@ -749,7 +767,7 @@ fn update_under_writes_in(home: Home) -> (Home, Agent, Vec) { } let out = updating.join().unwrap(); assert!(out.status.success(), "{}", text(&out)); - assert_eq!(home.program(), next); + assert_eq!(sha256(&home.program()), sha256(&next)); // The host and the server run the program now on disk. let host = wait_host(&home); assert_ne!(host, host_before); @@ -797,9 +815,9 @@ fn a_replaced_appimage_restarts_the_books() { let host_before = wait_host(&home); let out = home.run("update", Some(&release)); assert!(out.status.success(), "{}", text(&out)); - assert_eq!(std::fs::read(&image).unwrap(), next); + assert_eq!(sha256(&std::fs::read(&image).unwrap()), sha256(&next)); // The mount's copy is untouched; only the image changed. - assert_ne!(std::fs::read(&home.exe).unwrap(), next); + assert_ne!(sha256(&std::fs::read(&home.exe).unwrap()), sha256(&next)); assert!( wait_for(60, || { home.pids("host").len() == 1 && home.pids("host")[0] != host_before @@ -876,7 +894,11 @@ fn the_host_stages_a_daily_update_and_the_next_start_applies_it() { wait_for(30, || staged_version(&home).as_deref() == Some("9.0.0")), "nothing was staged" ); - assert_eq!(home.program(), old, "the program was swapped while open"); + assert_eq!( + sha256(&home.program()), + sha256(&old), + "the program was swapped while open" + ); assert!(!home.updates().join("previous").exists()); std::thread::sleep(Duration::from_millis(1200)); assert_eq!(home.pids("host"), vec![host], "the host restarted"); @@ -888,8 +910,14 @@ fn the_host_stages_a_daily_update_and_the_next_start_applies_it() { assert!(out.status.success(), "{}", text(&out)); assert!(wait_for(20, || home.pids("host").is_empty())); let mut agent = Agent::start(&home, "acme"); - assert_eq!(home.program(), next, "the staged update wasn't applied"); - assert!(!home.updates().join("staged").exists()); + // The opener applies the staged copy before its host starts; the + // session's start returns as soon as the books answer, so give the + // apply its moment (the runner is slow). + assert!( + wait_for(10, || sha256(&home.program()) == sha256(&next)), + "the staged update wasn't applied" + ); + assert!(wait_for(10, || !home.updates().join("staged").exists())); assert!( home.updates() .join("previous") @@ -897,8 +925,8 @@ fn the_host_stages_a_daily_update_and_the_next_start_applies_it() { .is_file() ); assert_eq!( - std::fs::read(home.updates().join("previous/wanderbooks")).unwrap(), - old + sha256(&std::fs::read(home.updates().join("previous/wanderbooks")).unwrap()), + sha256(&old) ); let host = wait_host(&home); assert!( @@ -960,7 +988,7 @@ fn update_installs_the_copy_the_host_staged_without_downloading_again() { std::fs::remove_file(release.dir.path().join("wanderbooks-9.0.0")).unwrap(); let out = home.run("update", None); assert!(out.status.success(), "{}", text(&out)); - assert_eq!(home.program(), next); + assert_eq!(sha256(&home.program()), sha256(&next)); assert!(!home.updates().join("staged").exists()); } @@ -990,6 +1018,21 @@ fn a_staged_update_that_was_tampered_with_is_dropped_not_applied() { meta["sha256"] = json!(sha256(&bytes)); std::fs::write(staged.join("meta.json"), meta.to_string()).unwrap(); let _agent = Agent::start(&home, "acme"); - assert_eq!(home.program(), old, "a tampered update was applied"); - assert!(!staged.exists(), "and kept"); + assert_eq!( + sha256(&home.program()), + sha256(&old), + "a tampered update was applied" + ); + // Dropped: the tampered bytes are gone. The new host may look at the + // feed again and stage a fresh, true copy in their place; that is not + // the tampered one kept. + let tampered = sha256(&bytes); + assert!( + wait_for(10, || { + std::fs::read(staged.join("wanderbooks")) + .map(|b| sha256(&b) != tampered) + .unwrap_or(true) + }), + "the tampered copy was kept" + ); } From 584ec9390da7651bbe464ca1f0ef68065c5397d9 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 20:43:42 -0600 Subject: [PATCH 6/8] test(viewer): the book a viewer finds is compared as the system spells it (macOS: /private/tmp) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- crates/ledger-gui/tests/exports.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/ledger-gui/tests/exports.rs b/crates/ledger-gui/tests/exports.rs index 9d467c9..2110738 100644 --- a/crates/ledger-gui/tests/exports.rs +++ b/crates/ledger-gui/tests/exports.rs @@ -386,7 +386,12 @@ fn the_viewer_finds_its_book_in_the_root() { ) .unwrap(); } - let dir = |id: &str| root.book_dir(&ledger_store::root::BookId::parse(id).unwrap()); + // As the system spells it: `book_in_root` answers a canonical path + // (macOS writes /tmp as /private/tmp). + let dir = |id: &str| { + std::fs::canonicalize(root.book_dir(&ledger_store::root::BookId::parse(id).unwrap())) + .unwrap() + }; assert_eq!( ledger_gui::book_in_root(Some(&r), Some("zeta")).unwrap(), dir("zeta") From 1d3292678a7b76b2b95050da3b51de65f20dcaa4 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 21:02:35 -0600 Subject: [PATCH 7/8] ci(check): the temporary CLI-crate step goes; the update tests' races are fixed Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- .github/workflows/check.yml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 9980691..6f78332 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -30,13 +30,6 @@ jobs: - uses: jdx/mise-action@v2 - name: GUI build dependencies run: sudo apt-get update && sudo apt-get install -y libxkbcommon-dev libgtk-3-dev - # Temporary (2026-10-30): one update test fails only on this runner - # and only inside the whole run (alone it passed here; locally it - # passes even on two slow cores), and the gate's step log loses its - # message. Run the CLI crate's suites first, in their own step, so - # the message survives. - - name: The CLI crate's tests, in their own step (temporary) - run: cargo test -p ledger-cli --no-fail-fast - run: just check - name: What the build used if: always() From 8ba104a15aa835a941cbfd565889b36586bef3c4 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 21:55:10 -0600 Subject: [PATCH 8/8] test(store): the unknown-event example is one no version knows (P8 wrote period.attested) The gate on 88874216: `every_known_event_type_has_replay_and_projection_arms` still used `period.attested` as the type a later phase adds; P8 added it, so the line replayed instead of reading as newer. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- crates/ledger-store/src/format.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ledger-store/src/format.rs b/crates/ledger-store/src/format.rs index 5bca69e..e3224b0 100644 --- a/crates/ledger-store/src/format.rs +++ b/crates/ledger-store/src/format.rs @@ -719,8 +719,9 @@ mod tests { "{t}: no projection arm" ); } - // A type a later phase adds (plan/served.md 5.2, P8). - let l = line("period.attested", json!({})); + // A type no version knows yet (`period.attested` was the example + // until P8 wrote it). + let l = line("ledger.not_yet_written", json!({})); let mut ledger = Ledger::new(Currency::USD); assert!(is_newer(&replay_line_into_ledger( &mut ledger,