D-27: a terminal panel along the bottom of the viewer (alacritty_terminal over a PTY) runs Claude Code (default) or Codex in the book's books home, resizable, collapsible, the choice remembered. The agent is an ordinary child process the owner describes (agent_start); the viewer stays read-only: the agent writes through the server like any other. A missing agent is offered through its vendor's installer (a command to copy), never bundled; no folder is marked trusted (Q-7, recommended answer: let Codex ask). Windows is not offered until its ConPTY checks are recorded. P11a found that onboarding with no terminal was false: the viewer opened only an existing book. With no books and an owner, wanderbooks gui now shows a first-run page: the private folder, the owner passphrase (typed in the viewer, optional), the first books, an agent, then the books open. connect and New books... make the books home. Tests: dock.rs, welcome.rs, books_home.rs (host and cli). Docs: testing.md record and For P12, architecture, parity, gui.md, README. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
324 lines
10 KiB
Rust
324 lines
10 KiB
Rust
//! The first-run page (welcome.rs, plan P11b): with no books yet, the viewer
|
|
//! sets up the private folder, asks for the owner passphrase, makes the first
|
|
//! books, connects an agent and opens the books, each through the owner and
|
|
//! none needing a terminal.
|
|
|
|
use egui_kittest::Harness;
|
|
use egui_kittest::kittest::Queryable;
|
|
use ledger_core::Currency;
|
|
use ledger_gui::App;
|
|
use ledger_gui::owner::{Agent, FirstRun, Owner, RestorePlan};
|
|
use ledger_store::root::{BookId, Root};
|
|
use std::path::PathBuf;
|
|
use std::sync::{Arc, Mutex, OnceLock};
|
|
use std::time::Duration;
|
|
|
|
/// An owner over a real data root in a scratch folder, that records what it
|
|
/// was asked (the first run's steps), and refuses what it should.
|
|
#[derive(Default)]
|
|
struct Fake {
|
|
place: Mutex<Option<(PathBuf, PathBuf)>>,
|
|
asked: Mutex<Vec<String>>,
|
|
}
|
|
|
|
impl Fake {
|
|
fn reset(&self, home: PathBuf, root: PathBuf) {
|
|
*self.place.lock().unwrap() = Some((home, root));
|
|
self.asked.lock().unwrap().clear();
|
|
}
|
|
fn said(&self, what: String) {
|
|
self.asked.lock().unwrap().push(what);
|
|
}
|
|
fn root_path(&self) -> (PathBuf, PathBuf) {
|
|
self.place.lock().unwrap().clone().unwrap()
|
|
}
|
|
}
|
|
|
|
impl Owner for Fake {
|
|
fn close_books(&self) -> Result<String, String> {
|
|
Err("not here".into())
|
|
}
|
|
fn new_books(&self, id: &str, currency: &str) -> Result<String, String> {
|
|
self.said(format!("new {id} {currency}"));
|
|
let (_, root) = self.root_path();
|
|
let root = Root::find(Some(&root)).map_err(|e| e.to_string())?;
|
|
let id = BookId::parse(id).map_err(|e| e.to_string())?;
|
|
root.create_book(&id, Currency::new(currency).map_err(|e| e.to_string())?)
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(format!("Made new books \"{id}\" ({currency})."))
|
|
}
|
|
fn back_up_now(&self, _: &str) -> Result<Vec<String>, String> {
|
|
Err("not here".into())
|
|
}
|
|
fn plan_restore(&self, _: &str, _: Option<String>, _: bool) -> Result<RestorePlan, String> {
|
|
Err("not here".into())
|
|
}
|
|
fn restore(&self, _: &RestorePlan) -> Result<Vec<String>, String> {
|
|
Err("not here".into())
|
|
}
|
|
fn connect(&self, agent: Agent, book: &str) -> Result<Vec<String>, String> {
|
|
self.said(format!("connect {agent:?} {book}"));
|
|
match agent {
|
|
Agent::Claude => Ok(vec!["Claude Code reaches the books.".into()]),
|
|
Agent::Codex => Err("Codex isn't installed here".into()),
|
|
}
|
|
}
|
|
fn upgrade_format(&self, _: &str, _: &str) -> Result<Vec<String>, String> {
|
|
Err("not here".into())
|
|
}
|
|
fn first_run(&self) -> FirstRun {
|
|
let (_, root) = self.root_path();
|
|
let Ok(found) = Root::find(Some(&root)) else {
|
|
return FirstRun::default();
|
|
};
|
|
FirstRun {
|
|
root: true,
|
|
books: found
|
|
.list_books()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|b| b.as_str().to_string())
|
|
.collect(),
|
|
passphrase_set: ledger_store::passphrase::is_set(&found.auth_dir().unwrap()).unwrap(),
|
|
}
|
|
}
|
|
fn set_up(&self) -> Result<Vec<String>, String> {
|
|
self.said("set up".into());
|
|
let (home, root) = self.root_path();
|
|
Root::setup(&home, Some(&root), None).map_err(|e| e.to_string())?;
|
|
Ok(vec!["Made the books' private folder.".into()])
|
|
}
|
|
fn set_passphrase(&self, new: &str) -> Result<(), String> {
|
|
self.said(format!("passphrase of {} characters", new.chars().count()));
|
|
let (_, root) = self.root_path();
|
|
let found = Root::find(Some(&root)).map_err(|e| e.to_string())?;
|
|
ledger_store::passphrase::set(&found.auth_dir().unwrap(), new, None)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
fn hold(&self, book: &str) {
|
|
self.said(format!("hold {book}"));
|
|
}
|
|
}
|
|
|
|
/// One test at a time: they share the one owner.
|
|
fn alone() -> std::sync::MutexGuard<'static, ()> {
|
|
static ONE: Mutex<()> = Mutex::new(());
|
|
ONE.lock().unwrap_or_else(|e| e.into_inner())
|
|
}
|
|
|
|
fn fake() -> Arc<Fake> {
|
|
static FAKE: OnceLock<Arc<Fake>> = OnceLock::new();
|
|
FAKE.get_or_init(|| {
|
|
let f = Arc::new(Fake::default());
|
|
ledger_gui::owner::set_owner(f.clone());
|
|
f
|
|
})
|
|
.clone()
|
|
}
|
|
|
|
fn harness(root: PathBuf, state: FirstRun) -> Harness<'static, App> {
|
|
let mut h = Harness::builder()
|
|
.with_size(egui::vec2(1000.0, 1100.0))
|
|
.build_eframe(move |cc| {
|
|
App::first_run(
|
|
cc,
|
|
Some(root.clone()),
|
|
chrono::NaiveDate::from_ymd_opt(2026, 10, 15),
|
|
state.clone(),
|
|
)
|
|
});
|
|
h.run_steps(3);
|
|
h
|
|
}
|
|
|
|
/// Run until `f` holds (a step's worker answered).
|
|
fn until(h: &mut Harness<'static, App>, what: &str, f: impl Fn(&Harness<'static, App>) -> bool) {
|
|
for _ in 0..300 {
|
|
h.run_steps(2);
|
|
if f(h) {
|
|
return;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
shot(h, "welcome-stuck");
|
|
panic!("never: {what}");
|
|
}
|
|
|
|
fn shot(h: &mut Harness<'static, App>, name: &str) {
|
|
if let Ok(out) = std::env::var("SHOT_DIR") {
|
|
let out = PathBuf::from(out);
|
|
std::fs::create_dir_all(&out).unwrap();
|
|
h.render()
|
|
.unwrap()
|
|
.save(out.join(format!("{name}.png")))
|
|
.unwrap();
|
|
}
|
|
}
|
|
|
|
fn scratch() -> (tempfile::TempDir, PathBuf, PathBuf) {
|
|
let dir = tempfile::Builder::new()
|
|
.prefix("wb-gui-welcome-")
|
|
.tempdir()
|
|
.unwrap();
|
|
let home = dir.path().join("home");
|
|
std::fs::create_dir_all(&home).unwrap();
|
|
let root = dir.path().join("data");
|
|
(dir, home, root)
|
|
}
|
|
|
|
#[test]
|
|
fn first_run_onboards_with_no_terminal() {
|
|
let _alone = alone();
|
|
let f = fake();
|
|
let (_dir, home, root) = scratch();
|
|
f.reset(home, root.clone());
|
|
let mut h = harness(root.clone(), FirstRun::default());
|
|
shot(&mut h, "welcome-1");
|
|
|
|
// 1. The private folder: set up from the page.
|
|
h.get_by_label("Set it up").click();
|
|
until(&mut h, "the folder is made", |h| {
|
|
h.query_by_label_contains("Made. Only your account")
|
|
.is_some()
|
|
});
|
|
assert!(h.state().welcome().unwrap().state().root);
|
|
|
|
// 2. The passphrase: typed twice, handed to the owner, gone from the page.
|
|
assert_eq!(
|
|
h.query_all_by_role(egui::accesskit::Role::PasswordInput)
|
|
.count(),
|
|
2,
|
|
"two password fields"
|
|
);
|
|
for i in 0..2 {
|
|
h.get_all_by_role(egui::accesskit::Role::PasswordInput)
|
|
.nth(i)
|
|
.unwrap()
|
|
.focus();
|
|
h.run_steps(2);
|
|
h.get_all_by_role(egui::accesskit::Role::PasswordInput)
|
|
.nth(i)
|
|
.unwrap()
|
|
.type_text("a long enough phrase");
|
|
h.run_steps(2);
|
|
}
|
|
shot(&mut h, "welcome-2");
|
|
h.get_by_label("Set passphrase").click();
|
|
until(&mut h, "the passphrase is set", |h| {
|
|
h.state().welcome().unwrap().state().passphrase_set
|
|
});
|
|
|
|
// 3. The first books.
|
|
h.get_all_by_role(egui::accesskit::Role::TextInput)
|
|
.next()
|
|
.unwrap()
|
|
.focus();
|
|
h.run_steps(2);
|
|
h.get_all_by_role(egui::accesskit::Role::TextInput)
|
|
.next()
|
|
.unwrap()
|
|
.type_text("acme");
|
|
h.run_steps(2);
|
|
h.get_by_label("Make the books").click();
|
|
until(&mut h, "the books are made", |h| {
|
|
h.state().welcome().unwrap().state().books == ["acme"]
|
|
});
|
|
shot(&mut h, "welcome-3");
|
|
|
|
// 4. An agent: Claude Code connects; Codex isn't installed, and says so.
|
|
h.get_by_label("Connect Codex").click();
|
|
until(&mut h, "Codex is refused", |h| {
|
|
h.query_all_by_label_contains("Codex isn't installed here")
|
|
.next()
|
|
.is_some()
|
|
});
|
|
h.get_by_label("Connect Claude Code").click();
|
|
until(&mut h, "Claude Code is connected", |h| {
|
|
h.query_all_by_label_contains("Claude Code reaches the books")
|
|
.next()
|
|
.is_some()
|
|
});
|
|
shot(&mut h, "welcome-4");
|
|
|
|
// 5. Open the books: the viewer shows them.
|
|
h.get_by_label_contains("Open the books").click();
|
|
until(&mut h, "the books are shown", |h| {
|
|
h.query_by_label_contains("Needs attention").is_some()
|
|
});
|
|
assert!(h.state().welcome().is_none(), "the page is gone");
|
|
shot(&mut h, "welcome-5");
|
|
|
|
// What the owner was asked, in order, and nothing else.
|
|
let asked = f.asked.lock().unwrap().clone();
|
|
assert_eq!(
|
|
asked,
|
|
[
|
|
"set up",
|
|
"passphrase of 20 characters",
|
|
"new acme USD",
|
|
"hold acme",
|
|
"connect Codex acme",
|
|
"connect Claude acme",
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn first_run_starts_at_the_first_step_missing_and_asks_nothing_done_again() {
|
|
let _alone = alone();
|
|
let f = fake();
|
|
let (_dir, home, root) = scratch();
|
|
f.reset(home.clone(), root.clone());
|
|
// Done at a terminal already: the folder and the passphrase.
|
|
f.set_up().unwrap();
|
|
f.set_passphrase("a long enough phrase").unwrap();
|
|
f.asked.lock().unwrap().clear();
|
|
let state = f.first_run();
|
|
assert_eq!(
|
|
state,
|
|
FirstRun {
|
|
root: true,
|
|
books: vec![],
|
|
passphrase_set: true
|
|
}
|
|
);
|
|
let h = harness(root, state);
|
|
// No folder or passphrase to ask for; the books are.
|
|
assert!(h.query_by_label("Set it up").is_none());
|
|
assert!(h.query_by_label("Set passphrase").is_none());
|
|
h.get_by_label("Make the books");
|
|
assert!(f.asked.lock().unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_passphrase_too_short_or_not_repeated_is_not_sent_and_can_wait() {
|
|
let _alone = alone();
|
|
let f = fake();
|
|
let (_dir, home, root) = scratch();
|
|
f.reset(home, root.clone());
|
|
f.set_up().unwrap();
|
|
f.asked.lock().unwrap().clear();
|
|
let mut h = harness(root, f.first_run());
|
|
h.get_all_by_role(egui::accesskit::Role::PasswordInput)
|
|
.next()
|
|
.unwrap()
|
|
.focus();
|
|
h.run_steps(2);
|
|
h.get_all_by_role(egui::accesskit::Role::PasswordInput)
|
|
.next()
|
|
.unwrap()
|
|
.type_text("short");
|
|
h.run_steps(3);
|
|
h.get_by_label_contains("characters or more, please");
|
|
// Too short: the button does nothing.
|
|
h.get_by_label("Set passphrase").click();
|
|
h.run_steps(3);
|
|
assert!(f.asked.lock().unwrap().is_empty());
|
|
// Not now: the books are asked for next, and nothing was sent.
|
|
h.get_by_label("Not now").click();
|
|
h.run_steps(3);
|
|
h.get_by_label("Make the books");
|
|
assert!(f.asked.lock().unwrap().is_empty());
|
|
assert!(!f.first_run().passphrase_set);
|
|
}
|