xtask/src/trials.rs
//! `cargo xtask trials [--out <dir>]` — **the 14 buy trials on the ONE box's own program** (A15,
//! v2.0 P1): `crates/monetize-admin/tests/site_buy_trials.rs` (feature `trials`) over
//! `monetize_admin::linked()` with the SIGNED guests. It replaces `~/scripts/trial-buy.sh`'s
//! container lane.
//!
//! What it does, each cargo build with its own `CARGO_TARGET_DIR` per workspace
//! (`<target>/trials-<workspace>`):
//!
//! 1. the Stripe, UpCloud and Cloudflare guests in the sibling `../monetize-impl`, and its
//! `fake_stripe_serve` (`monetize-pay-impl --features stripe,testing`);
//! 2. the Gunnar guest (`../gunnar/crates/gunnar-monetize-guest`, its own workspace) and the
//! Holger guest (`../holger -p holger-monetize-guest`, after Holger's own `cargo xtask
//! siblings` switched its sibling patches on);
//! 3. copies them into `<out>/guests` and `<out>/bin`, then runs the trials in
//! `crates/monetize-admin` with `MONETIZE_TRIAL_GUESTS`, `MONETIZE_FAKE_STRIPE` and
//! `MONETIZE_TRIAL_OUT`;
//! 4. runs the MONEY FLOW on the same program and inputs: `crates/monetize-admin/tests/money_flow.rs`
//! (feature `trials`), the 18 cases monetize-impl's money-flow crate held (v2.0 P2 12.2, A28 M4);
//! 5. runs the PLAN CHANGES on the same program and inputs (A4-FACTS):
//! `crates/monetize-admin/tests/plan_changes.rs`, a machine taken away, a standing disk grown
//! and a twin added at the UpCloud mock, paid at the fake Stripe, each a `plan-change/<case>` row;
//! 6. writes ONE receipt, `receipt.txt` and `receipt.json`: a row per trial and site and a
//! `money-flow/<case>` row per money-flow case, the commit of every tree (`git rev-parse`, else `TRIALS_COMMIT_<TREE>` for a box with no
//! `.git`). A row the test never wrote is a FAIL row, by name.
//!
//! Exit 0 only when all 14 trial rows, all 18 money-flow rows and all 3 plan-change rows passed. Mock only: test keys, loopback, `*@mock.invalid`.
use std::path::{Path, PathBuf};
use std::process::Command;
/// The trials, in order: (the verdict's name, what it buys).
pub const TRIALS: &[(&str, &str)] = &[
("a-free-account", "a free account (the 0 kr card check)"),
("b-paid-plan", "a paid plan"),
("c-private-visitor", "a private instance, as a visitor"),
("d-private-signed-in", "a private instance, signed in"),
("e-grow-account", "grow the account"),
("f-grow-instance", "grow the instance"),
("g-owner-walk", "the owner's walk: an account plan, then a private instance signed in, its ready mail, the Accounts page (T-OWNERFLOW)"),
];
/// **The money flow's cases** (A28 M4: moved from monetize-impl `crates/money-flow`, each named
/// as it was there), each a `money-flow/<case>` row; the test writes `money-flow-<case>.json`.
pub const MONEY_FLOW: &[&str] = &[
"a_paid_gunnar_rs_signup_becomes_a_verified_entitlement_the_estate_pulls_and_an_active_account",
"a_free_account_buys_a_private_gunnar_that_joins_and_whose_tenants_are_booked_as_at_acme1",
"a_paid_data_step_sends_grow_go_ahead_and_grow_set_is_capped_at_paid_and_never_shrinks",
"a_sold_estates_box_accepts_the_grow_go_ahead_order_through_gunnars_own_gate",
"renewal_extends_unpaid_expiry_suspends_and_a_refund_withdraws_the_entitlement_by_order",
"a_gunnar_estate_refuses_an_order_or_a_fact_signed_with_holgers_product_key",
"after_a_recharge_restate_entitlements_carries_the_real_snapshot_from_the_one_ledger",
"a_new_visitor_offered_a_plan_and_a_private_gunnar_buys_the_private_first_on_a_minted_account",
"the_same_account_signed_in_buys_a_second_private_gunnar_and_a_plan_all_on_one_account",
"the_same_email_signing_up_again_is_refused_with_a_sign_in_offer_and_no_second_account",
"the_account_menu_lists_the_plan_and_both_private_gunnars_of_that_account_only",
"a_renewal_from_the_account_queues_a_signed_order_only_the_renewed_estate_pulls",
"an_upgrade_from_the_account_sends_a_signed_grow_go_ahead_only_to_that_estate",
"a_resend_sign_in_link_is_a_signed_order_the_estates_main_mints_and_mails",
"a_duplicate_webhook_and_re_driven_orders_change_nothing",
"a_lost_front_is_restored_from_its_daily_backup_by_its_buyer_or_the_operator_and_keeps_its_keys",
"the_operators_test_button_buys_a_real_gunnar_rs_account_on_the_test_key_with_nobody_at_a_page",
"two_sites_on_one_box_each_make_bill_and_deliver_on_their_own_estate",
];
/// **The plan changes' cases** (A4-FACTS: a plan change that takes machines away, grows a
/// standing machine's disk, adds a twin), each a `plan-change/<case>` row; the test
/// (`crates/monetize-admin/tests/plan_changes.rs`) writes `plan-change-<case>.json`.
pub const PLAN_CHANGES: &[&str] = &[
"a_twin_is_added_to_a_gunnar_shaped_estate_laid_at_upcloud_and_paid_at_stripe",
"a_node_taken_away_is_drained_and_given_back_at_upcloud_and_billed_no_more",
"a_standing_nodes_disk_grows_at_upcloud_paid_at_stripe_and_never_shrinks",
];
/// The sites each trial runs on (the products' words).
pub const SITES: &[&str] = &["gunnar", "holger"];
/// The trees whose commits the receipt names: (name, path from monetize's root).
pub const TREES: &[(&str, &str)] = &[("monetize", "."), ("monetize-impl", "../monetize-impl"), ("gunnar", "../gunnar"), ("holger", "../holger"), ("edda", "../edda"), ("Skidbladnir", "../Skidbladnir")];
fn cargo() -> String {
std::env::var("CARGO").unwrap_or_else(|_| "cargo".into())
}
/// Parse `[--out <dir>]`.
pub fn parse(args: &[String]) -> Result<Option<PathBuf>, String> {
match args {
[] => Ok(None),
[flag, dir] if flag == "--out" => Ok(Some(PathBuf::from(dir))),
rest => Err(format!("unknown arguments {rest:?}; `cargo xtask trials [--out <dir>]`")),
}
}
/// The out dir by default: `<target>/trials`.
pub fn default_out(repo_root: &Path) -> PathBuf {
crate::guests::target_dir(repo_root).join("trials")
}
/// The commit of `tree`: `git rev-parse --short=12 HEAD`, else `TRIALS_COMMIT_<TREE>`, else `unknown`.
pub fn commit(name: &str, dir: &Path) -> String {
let var = format!("TRIALS_COMMIT_{}", name.to_ascii_uppercase().replace('-', "_"));
if let Ok(c) = std::env::var(&var) {
if !c.trim().is_empty() {
return c.trim().to_string();
}
}
Command::new("git").args(["rev-parse", "--short=12", "HEAD"]).current_dir(dir).output().ok().filter(|o| o.status.success()).map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_else(|| "unknown".into())
}
/// One cargo call in `dir` with its own target dir; a failure names the step.
fn build(step: &str, dir: &Path, target: &Path, args: &[&str]) -> Result<(), String> {
println!("── trials: {step} (in {}, target {})", dir.display(), target.display());
let status = Command::new(cargo()).args(args).current_dir(dir).env("CARGO_TARGET_DIR", target).status().map_err(|e| format!("{step}: running cargo: {e}"))?;
if !status.success() {
return Err(format!("{step}: cargo {} failed: {status}", args.join(" ")));
}
Ok(())
}
fn copy(from: &Path, to: &Path) -> Result<(), String> {
if !from.is_file() {
return Err(format!("{} is missing after its build", from.display()));
}
std::fs::copy(from, to).map(|_| ()).map_err(|e| format!("{} -> {}: {e}", from.display(), to.display()))
}
/// One row of the receipt.
#[derive(Debug, Clone, PartialEq)]
pub struct Row {
pub trial: String,
pub site: String,
pub pass: bool,
pub secs: f64,
pub detail: String,
}
/// Pure: **the rows**, from the verdicts the test wrote (`<trial>-<site>.json`, by name): every
/// trial on every site, a missing verdict a FAIL row that says so.
pub fn rows(verdicts: &dyn Fn(&str) -> Option<String>) -> Vec<Row> {
let mut out = Vec::new();
for (trial, _) in TRIALS {
for site in SITES {
let name = format!("{trial}-{site}");
let row = match verdicts(&name) {
Some(text) => {
let field = |k: &str| json_field(&text, k);
Row { trial: trial.to_string(), site: site.to_string(), pass: field("pass").as_deref() == Some("true"), secs: field("secs").and_then(|s| s.parse().ok()).unwrap_or(0.0), detail: field("detail").unwrap_or_default() }
}
None => Row { trial: trial.to_string(), site: site.to_string(), pass: false, secs: 0.0, detail: format!("no verdict: {name}.json was not written (the test did not run, or it crashed before its end)") },
};
out.push(row);
}
}
out
}
/// Pure: **the money flow's rows**, from the verdicts the test wrote (`money-flow-<case>.json`):
/// every case a `money-flow/<case>` row on Gunnar's site, a missing verdict a FAIL row by name.
pub fn money_flow_rows(verdicts: &dyn Fn(&str) -> Option<String>) -> Vec<Row> {
MONEY_FLOW
.iter()
.map(|case| {
let name = format!("money-flow-{case}");
let trial = format!("money-flow/{case}");
match verdicts(&name) {
Some(text) => {
let field = |k: &str| json_field(&text, k);
Row { trial, site: "gunnar".into(), pass: field("pass").as_deref() == Some("true"), secs: field("secs").and_then(|s| s.parse().ok()).unwrap_or(0.0), detail: field("detail").unwrap_or_default() }
}
None => Row { trial, site: "gunnar".into(), pass: false, secs: 0.0, detail: format!("no verdict: {name}.json was not written (the case did not run, or it crashed before its end)") },
}
})
.collect()
}
/// Pure: **the plan changes' rows** (A4-FACTS), from the verdicts the test wrote
/// (`plan-change-<case>.json`): every case a `plan-change/<case>` row on Gunnar's site (the
/// estate is sold on gunnar's account), a missing verdict a FAIL row by name.
pub fn plan_change_rows(verdicts: &dyn Fn(&str) -> Option<String>) -> Vec<Row> {
PLAN_CHANGES
.iter()
.map(|case| {
let name = format!("plan-change-{case}");
let trial = format!("plan-change/{case}");
match verdicts(&name) {
Some(text) => {
let field = |k: &str| json_field(&text, k);
Row { trial, site: "gunnar".into(), pass: field("pass").as_deref() == Some("true"), secs: field("secs").and_then(|s| s.parse().ok()).unwrap_or(0.0), detail: field("detail").unwrap_or_default() }
}
None => Row { trial, site: "gunnar".into(), pass: false, secs: 0.0, detail: format!("no verdict: {name}.json was not written (the case did not run, or it crashed before its end)") },
}
})
.collect()
}
/// A top-level scalar of a flat JSON object, as text (strings unescaped). This crate keeps no
/// JSON dependency; the verdicts are the test's own flat objects.
pub fn json_field(text: &str, key: &str) -> Option<String> {
let at = text.find(&format!("\"{key}\""))? + key.len() + 2;
let rest = text[at..].trim_start().strip_prefix(':')?.trim_start();
if let Some(s) = rest.strip_prefix('"') {
let mut out = String::new();
let mut chars = s.chars();
while let Some(c) = chars.next() {
match c {
'"' => return Some(out),
'\\' => match chars.next()? {
'n' => out.push('\n'),
't' => out.push('\t'),
'u' => {
let h: String = chars.by_ref().take(4).collect();
out.push(u32::from_str_radix(&h, 16).ok().and_then(char::from_u32).unwrap_or('?'));
}
other => out.push(other),
},
c => out.push(c),
}
}
None
} else {
Some(rest.split([',', '}', '\n']).next()?.trim().to_string())
}
}
fn esc(s: &str) -> String {
let mut o = String::new();
for c in s.chars() {
match c {
'"' => o.push_str("\\\""),
'\\' => o.push_str("\\\\"),
'\n' => o.push_str("\\n"),
c if (c as u32) < 0x20 => o.push_str(&format!("\\u{:04x}", c as u32)),
c => o.push(c),
}
}
o
}
/// Pure: **the receipt**, as text and as JSON.
pub fn receipt(rows: &[Row], trees: &[(String, String)], at: &str) -> (String, String) {
let passed = rows.iter().filter(|r| r.pass).count();
let mut txt = format!("buy trials and the money flow on monetize_admin::linked() with the signed guests — {at}\n");
for (n, c) in trees {
txt.push_str(&format!(" {n:<14} {c}\n"));
}
let w = rows.iter().map(|r| r.trial.len()).max().unwrap_or(0).max(22);
txt.push_str(&format!("\n{:<w$} {:<7} {:<5} {:>7} detail\n", "trial", "site", "", "secs"));
for r in rows {
txt.push_str(&format!("{:<w$} {:<7} {:<5} {:>7.1} {}\n", r.trial, r.site, if r.pass { "PASS" } else { "FAIL" }, r.secs, r.detail.lines().next().unwrap_or("")));
}
txt.push_str(&format!("\n{passed} of {} passed\n", rows.len()));
let tree_json: Vec<String> = trees.iter().map(|(n, c)| format!("\"{}\": \"{}\"", esc(n), esc(c))).collect();
let row_json: Vec<String> = rows.iter().map(|r| format!(" {{\"trial\": \"{}\", \"site\": \"{}\", \"pass\": {}, \"secs\": {:.1}, \"detail\": \"{}\"}}", esc(&r.trial), esc(&r.site), r.pass, r.secs, esc(&r.detail))).collect();
let json = format!("{{\n \"what\": \"buy trials on monetize_admin::linked()\",\n \"at\": \"{}\",\n \"trees\": {{{}}},\n \"passed\": {passed},\n \"total\": {},\n \"rows\": [\n{}\n ]\n}}\n", esc(at), tree_json.join(", "), rows.len(), row_json.join(",\n"));
(txt, json)
}
fn utc_now() -> String {
Command::new("date").args(["-u", "+%Y-%m-%dT%H:%M:%SZ"]).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default()
}
/// The cargo runs that build the Gunnar and Holger guests for the trials: build-guests' steps,
/// each guest into its own trials target (`trials-gunnar-guest`, `trials-holger`).
fn guest_steps(repo_root: &Path, tgt: &dyn Fn(&str) -> PathBuf) -> Vec<(PathBuf, PathBuf, Vec<String>)> {
let mut steps = crate::guests::gunnar_guest_steps(repo_root, &tgt("gunnar-guest"));
steps.extend(crate::guests::holger_guest_steps(repo_root, &tgt("holger")));
steps
}
/// **The verb.** Answers the receipt's path; `Err` when a build failed or a trial did not pass.
pub fn run(repo_root: &Path, out: Option<PathBuf>) -> Result<PathBuf, String> {
let out = out.unwrap_or_else(|| default_out(repo_root));
let target = crate::guests::target_dir(repo_root);
let tgt = |ws: &str| target.join(format!("trials-{ws}"));
let impl_dir = repo_root.join("../monetize-impl");
let gunnar = repo_root.join("../gunnar/crates/gunnar-monetize-guest");
let holger = repo_root.join("../holger");
for (what, d) in [("../monetize-impl", &impl_dir), ("../gunnar/crates/gunnar-monetize-guest", &gunnar), ("../holger", &holger)] {
if !d.is_dir() {
return Err(format!("the sibling {what} is not checked out beside monetize ({})", d.display()));
}
}
let w = crate::guests::TARGET;
build("the Stripe, UpCloud and Cloudflare guests", &impl_dir, &tgt("monetize-impl"), &["build", "--locked", "--release", "--target", w, "-p", "monetize-guest-stripe", "-p", "monetize-guest-upcloud", "-p", "monetize-guest-dns-cloudflare"])?;
build("the fake Stripe (fake_stripe_serve)", &impl_dir, &tgt("monetize-impl"), &["build", "--locked", "-p", "monetize-pay-impl", "--features", "stripe,testing", "--example", "fake_stripe_serve"])?;
// The two product guests, with build-guests' own tested steps: Gunnar's `--locked` in its
// own workspace, Holger's in its estate workspace (each carries its sibling patches).
for (dir, t, args) in guest_steps(repo_root, &tgt) {
let args: Vec<&str> = args.iter().map(String::as_str).collect();
build(&format!("cargo {}", args.join(" ")), &dir, &t, &args)?;
}
let guests = out.join("guests");
let bin = out.join("bin");
let verdicts = out.join("verdicts");
let _ = std::fs::remove_dir_all(&verdicts);
for d in [&guests, &bin, &verdicts] {
std::fs::create_dir_all(d).map_err(|e| format!("{}: {e}", d.display()))?;
}
let rel = |t: &Path, krate: &str| t.join(w).join("release").join(format!("{}.wasm", krate.replace('-', "_")));
copy(&rel(&tgt("monetize-impl"), "monetize-guest-stripe"), &guests.join("stripe.wasm"))?;
copy(&rel(&tgt("monetize-impl"), "monetize-guest-upcloud"), &guests.join("upcloud.wasm"))?;
copy(&rel(&tgt("monetize-impl"), "monetize-guest-dns-cloudflare"), &guests.join("cloudflare.wasm"))?;
copy(&rel(&tgt("gunnar-guest"), "gunnar-monetize-guest"), &guests.join("gunnar.wasm"))?;
copy(&rel(&tgt("holger"), "holger-monetize-guest"), &guests.join("holger.wasm"))?;
copy(&tgt("monetize-impl").join("debug/examples/fake_stripe_serve"), &bin.join("fake_stripe_serve"))?;
println!("── trials: the 14 trials in crates/monetize-admin (feature trials)");
let status = Command::new(cargo())
.args(["test", "--locked", "--features", "trials", "--test", "site_buy_trials", "--", "--test-threads", "4"])
.current_dir(repo_root.join("crates/monetize-admin"))
.env("CARGO_TARGET_DIR", tgt("monetize-admin"))
.env("MONETIZE_TRIAL_GUESTS", &guests)
.env("MONETIZE_FAKE_STRIPE", bin.join("fake_stripe_serve"))
.env("MONETIZE_TRIAL_OUT", &verdicts)
.status()
.map_err(|e| format!("running the trials: {e}"))?;
println!("── trials: the money flow's 18 cases in crates/monetize-admin (feature trials)");
let flow = Command::new(cargo())
.args(["test", "--locked", "--features", "trials", "--test", "money_flow", "--", "--test-threads", "4"])
.current_dir(repo_root.join("crates/monetize-admin"))
.env("CARGO_TARGET_DIR", tgt("monetize-admin"))
.env("MONETIZE_TRIAL_GUESTS", &guests)
.env("MONETIZE_FAKE_STRIPE", bin.join("fake_stripe_serve"))
.env("MONETIZE_TRIAL_OUT", &verdicts)
.status()
.map_err(|e| format!("running the money flow: {e}"))?;
println!("── trials: the plan changes' {} cases in crates/monetize-admin (feature trials)", PLAN_CHANGES.len());
let changes = Command::new(cargo())
.args(["test", "--locked", "--features", "trials", "--test", "plan_changes", "--", "--test-threads", "3"])
.current_dir(repo_root.join("crates/monetize-admin"))
.env("CARGO_TARGET_DIR", tgt("monetize-admin"))
.env("MONETIZE_TRIAL_GUESTS", &guests)
.env("MONETIZE_FAKE_STRIPE", bin.join("fake_stripe_serve"))
.env("MONETIZE_TRIAL_OUT", &verdicts)
.status()
.map_err(|e| format!("running the plan changes: {e}"))?;
let read = |name: &str| std::fs::read_to_string(verdicts.join(format!("{name}.json"))).ok();
let mut rows = rows(&read);
rows.extend(money_flow_rows(&read));
rows.extend(plan_change_rows(&read));
let trees: Vec<(String, String)> = TREES.iter().map(|(n, p)| (n.to_string(), commit(n, &repo_root.join(p)))).collect();
let (txt, json) = receipt(&rows, &trees, &utc_now());
std::fs::write(out.join("receipt.txt"), &txt).map_err(|e| e.to_string())?;
std::fs::write(out.join("receipt.json"), &json).map_err(|e| e.to_string())?;
println!("\n{txt}");
let receipt = out.join("receipt.txt");
if !status.success() || !flow.success() || !changes.success() || rows.iter().any(|r| !r.pass) {
return Err(format!("the trials FAILED ({} of {} passed, cargo {status}); see {}", rows.iter().filter(|r| r.pass).count(), rows.len(), receipt.display()));
}
Ok(receipt)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_out_dir_may_be_named_and_nothing_else_is_taken() {
assert_eq!(parse(&[]).unwrap(), None);
assert_eq!(parse(&["--out".into(), "/tmp/t".into()]).unwrap(), Some(PathBuf::from("/tmp/t")));
assert!(parse(&["--fast".into()]).is_err());
}
#[test]
fn every_trial_on_every_site_is_a_row_and_a_missing_verdict_fails_by_name() {
let one = r#"{"name": "a-free-account-gunnar", "pass": true, "detail": "0 kr card check \"cs_1\"", "secs": 4.2}"#;
let rows = rows(&|n| (n == "a-free-account-gunnar").then(|| one.to_string()));
assert_eq!(rows.len(), 14);
assert!(rows[0].pass && rows[0].secs == 4.2 && rows[0].detail == "0 kr card check \"cs_1\"", "{:?}", rows[0]);
assert!(!rows[1].pass && rows[1].detail.contains("a-free-account-holger.json was not written"), "{:?}", rows[1]);
let (txt, json) = receipt(&rows, &[("monetize".into(), "abc".into())], "now");
assert!(txt.contains("1 of 14 passed") && txt.contains("monetize abc"), "{txt}");
assert!(json.contains("\"passed\": 1") && json.contains("\\\"cs_1\\\""), "{json}");
}
/// ★ A28 M4: the money flow's 18 cases are rows of the same receipt, each by name; a case
/// the test never wrote FAILS by name, and the receipt counts all 32.
#[test]
fn every_money_flow_case_is_a_row_and_a_missing_one_fails_by_name() {
assert_eq!(MONEY_FLOW.len(), 18, "the 18 cases monetize-impl's money-flow held");
let one = r#"{"name": "money-flow-a_duplicate_webhook_and_re_driven_orders_change_nothing", "pass": true, "detail": "twice is once", "secs": 1.5}"#;
let flow = money_flow_rows(&|n| (n == "money-flow-a_duplicate_webhook_and_re_driven_orders_change_nothing").then(|| one.to_string()));
assert_eq!(flow.len(), 18);
let ok = flow.iter().find(|r| r.trial == "money-flow/a_duplicate_webhook_and_re_driven_orders_change_nothing").unwrap();
assert!(ok.pass && ok.secs == 1.5 && ok.site == "gunnar", "{ok:?}");
let missing = flow.iter().find(|r| r.trial == "money-flow/two_sites_on_one_box_each_make_bill_and_deliver_on_their_own_estate").unwrap();
assert!(!missing.pass && missing.detail.contains("money-flow-two_sites_on_one_box_each_make_bill_and_deliver_on_their_own_estate.json was not written"), "{missing:?}");
let mut all = rows(&|_| None);
all.extend(flow);
let (txt, _) = receipt(&all, &[], "now");
assert!(txt.contains("1 of 32 passed"), "{txt}");
assert!(txt.lines().any(|l| l.starts_with("money-flow/a_duplicate_webhook_and_re_driven_orders_change_nothing ") && l.contains(" PASS ")), "{txt}");
assert!(txt.lines().any(|l| l.starts_with("money-flow/two_sites_on_one_box_each_make_bill_and_deliver_on_their_own_estate ") && l.contains(" FAIL ")), "{txt}");
}
/// ★ A4-FACTS: the plan changes' cases are rows of the same receipt, each by name; a case the
/// test never wrote FAILS by name, and the receipt counts all 35.
#[test]
fn every_plan_change_case_is_a_row_and_a_missing_one_fails_by_name() {
assert_eq!(PLAN_CHANGES.len(), 3);
let one = r#"{"name": "plan-change-a_node_taken_away_is_drained_and_given_back_at_upcloud_and_billed_no_more", "pass": true, "detail": "node-2 gone", "secs": 9.5}"#;
let changes = plan_change_rows(&|n| (n == "plan-change-a_node_taken_away_is_drained_and_given_back_at_upcloud_and_billed_no_more").then(|| one.to_string()));
assert_eq!(changes.len(), 3);
let ok = changes.iter().find(|r| r.trial == "plan-change/a_node_taken_away_is_drained_and_given_back_at_upcloud_and_billed_no_more").unwrap();
assert!(ok.pass && ok.secs == 9.5 && ok.site == "gunnar", "{ok:?}");
let missing = changes.iter().find(|r| r.trial == "plan-change/a_twin_is_added_to_a_gunnar_shaped_estate_laid_at_upcloud_and_paid_at_stripe").unwrap();
assert!(!missing.pass && missing.detail.contains("plan-change-a_twin_is_added_to_a_gunnar_shaped_estate_laid_at_upcloud_and_paid_at_stripe.json was not written"), "{missing:?}");
let mut all = rows(&|_| None);
all.extend(money_flow_rows(&|_| None));
all.extend(changes);
let (txt, _) = receipt(&all, &[], "now");
assert!(txt.contains("1 of 35 passed"), "{txt}");
}
#[test]
fn the_trials_build_the_product_guests_with_the_same_steps_as_build_guests() {
// The trials built the Gunnar guest `--locked` without gunnar's sibling patches and the
// Holger guest at holger's root after H1 moved it into estate/; both went red on every
// estate checkout (2026-10-25). They now take build-guests' tested steps.
let steps = guest_steps(Path::new("/c/monetize"), &|ws: &str| PathBuf::from(format!("/t/trials-{ws}")));
let args: Vec<String> = steps.iter().map(|(_, _, a)| a.join(" ")).collect();
assert_eq!(args, ["build --locked --release --target wasm32-unknown-unknown", "build --manifest-path estate/Cargo.toml --release --target wasm32-unknown-unknown -p holger-monetize-guest"], "{steps:?}");
assert_eq!(steps[0].1, PathBuf::from("/t/trials-gunnar-guest"), "the Gunnar guest lands where the trials copy it from");
assert_eq!(steps[1].1, PathBuf::from("/t/trials-holger"), "the Holger guest lands where the trials copy it from");
}
}