//! `wai lock`, `wai check` and `wai build` through the `wai` binary (`--features quickstart --test //! cli_project`; `--features quickstart,app` for an app's project): //! //! - a lock's bytes are the corpus's (`wai/project-conformance/`), the same in two directories at //! two times, from a project file with CRLF line ends and through a checkout with //! `core.autocrlf=true`, and hold no time, no path and no line ending; //! - a stale lock, a lock edited by hand and a member the reader does not know are each refused, //! by `wai lock --check`, `wai check` and `wai build`, and nothing is written; //! - `wai check` reports every finding, not the first, and what it could not run says `not checked` //! and never that it held, with the exit status saying so; //! - `wai build` gives the same tree from the same project and lock in two directories at two //! times, with the reference packager too, and its record names the digests of the project file //! and the lock; //! - `project_verify.py`, a second reader sharing no code with this crate, recomputes every digest //! and refuses what this binary refuses; //! - every file whose bytes a lock pins keeps them on a checkout that converts line endings. //! //! The tests with a package need `wai-pack`: `$WAI_PACK_BIN`, or beside this crate's binary. #![cfg(feature = "quickstart")] use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; // A program a test runs is written by a process of its own (ETXTBSY: support/program.rs). #[path = "support/program.rs"] mod program; fn wai_dir() -> PathBuf { plain(Path::new(env!("CARGO_MANIFEST_DIR")).join("..").canonicalize().unwrap()) } /// A path as a person writes it: on Windows, `canonicalize` gives a verbatim path (`\\?\C:\...`), /// which takes no `/` and which git will not clone into, so its prefix goes. fn plain(p: PathBuf) -> PathBuf { match p.to_str().and_then(|s| s.strip_prefix(r"\\?\")) { Some(rest) if !rest.starts_with("UNC\\") => PathBuf::from(rest), _ => p, } } fn corpus() -> PathBuf { wai_dir().join("project-conformance") } fn scratch(name: &str) -> PathBuf { let d = Path::new(env!("CARGO_TARGET_TMPDIR")).join("cli_project").join(name); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); plain(d.canonicalize().unwrap()) } /// A directory outside any checkout of the standard, so `wai` finds no corpus from it. fn scratch_outside(name: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("wai-cli-project-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); plain(d.canonicalize().unwrap()) } fn copy_dir(from: &Path, to: &Path) { std::fs::create_dir_all(to).unwrap(); for e in std::fs::read_dir(from).unwrap().flatten() { let t = to.join(e.file_name()); if e.path().is_dir() { copy_dir(&e.path(), &t); } else { std::fs::copy(e.path(), t).unwrap(); } } } fn wai_command(d: &Path) -> Command { let mut c = Command::new(env!("CARGO_BIN_EXE_wai")); c.current_dir(d).env_remove("WAI_SPEC_ROOT").env_remove("WAI_PACK_BIN").stdin(Stdio::null()); c } fn wai(d: &Path, args: &[&str]) -> Output { wai_command(d).args(args).output().expect("wai runs") } fn text(o: &Output) -> String { format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)) } /// The one envelope a `--json` run prints. fn envelope(d: &Path, args: &[&str]) -> (Option, Value) { let mut a = args.to_vec(); a.push("--json"); let o = wai(d, &a); let v: Value = serde_json::from_slice(&o.stdout).unwrap_or_else(|e| panic!("{args:?}: not one envelope ({e}): {}", text(&o))); assert_eq!(v["wai"], wai::catalogue::ENVELOPE, "{v}"); (o.status.code(), v) } fn sha256(b: &[u8]) -> String { Sha256::digest(b).iter().map(|x| format!("{x:02x}")).collect() } fn read(p: impl AsRef) -> Vec { std::fs::read(p.as_ref()).unwrap_or_else(|e| panic!("{}: {e}", p.as_ref().display())) } /// The packer, which the tests with a package need. fn packer() -> PathBuf { if let Some(p) = std::env::var_os("WAI_PACK_BIN") { return PathBuf::from(p); } let beside = Path::new(env!("CARGO_BIN_EXE_wai")).with_file_name(format!("wai-pack{}", std::env::consts::EXE_SUFFIX)); assert!(beside.is_file(), "these tests need wai-pack: build wai/wai-pack and set WAI_PACK_BIN"); beside } /// The findings of a report, as (check, status, code). fn findings(v: &Value) -> Vec<(String, String, String)> { v["data"]["checks"].as_array().unwrap_or_else(|| panic!("no checks: {v}")).iter().map(|c| (c["check"].as_str().unwrap().to_string(), c["status"].as_str().unwrap().to_string(), c["code"].as_str().unwrap_or("").to_string())).collect() } fn has(v: &Value, check: &str, status: &str, code: &str) -> bool { findings(v).iter().any(|(a, b, c)| a == check && b == status && c == code) } // ───────────────────────────── what the digests are, worked out here ───────────────────────────── /// ` \n` sorted by path as bytes, and its SHA-256. fn listing(mut lines: Vec<(String, String)>) -> String { lines.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes())); sha256(lines.iter().map(|(p, h)| format!("{h} {p}\n")).collect::().as_bytes()) } fn files_below(root: &Path, rel: &str, out: &mut Vec) { let d = if rel.is_empty() { root.to_path_buf() } else { root.join(rel) }; for e in std::fs::read_dir(d).unwrap().flatten() { let name = e.file_name().to_string_lossy().into_owned(); let p = if rel.is_empty() { name } else { format!("{rel}/{name}") }; if e.path().is_dir() { files_below(root, &p, out); } else { out.push(p); } } } fn template_digest(name: &str) -> String { let root = wai_dir().join("examples").join(name); let mut files = Vec::new(); files_below(&root, "", &mut files); listing(files.into_iter().filter(|p| !p.starts_with("test/")).map(|p| (p.clone(), sha256(&read(root.join(&p))))).collect()) } fn claim_corpus_digest() -> String { let line = format!("{} claim-conformance/vectors.json\n", sha256(&read(wai_dir().join("claim-conformance/vectors.json")))); format!("sha256:{}", sha256(line.as_bytes())) } /// A fixture's lock: the corpus's bytes, what moves with the toolchain filled in from the committed /// files. fn expected_lock(fixture: &str) -> Vec { String::from_utf8(read(corpus().join("fixture").join(format!("{fixture}.lock.expected")))) .unwrap() .replace("@VERSION@", env!("CARGO_PKG_VERSION")) .replace("@CATALOGUE@", &sha256(&read(wai_dir().join("catalogue/ops.json")))) .replace("@REGISTRY@", &sha256(&read(wai_dir().join("registry/capabilities.json")))) .replace("@TEMPLATE@", &template_digest("video-room")) .replace("@CLAIM@", &claim_corpus_digest()) .into_bytes() } /// A copy of a fixture project, locked. fn locked_plain(d: &Path) -> PathBuf { let p = d.join("plain"); copy_dir(&corpus().join("fixture/plain"), &p); let o = wai(&p, &["lock"]); assert!(o.status.success(), "{}", text(&o)); p } fn edit_project(dir: &Path, f: impl FnOnce(&mut Value)) { let mut v: Value = serde_json::from_slice(&read(dir.join("wai.json"))).unwrap(); f(&mut v); std::fs::write(dir.join("wai.json"), serde_json::to_vec_pretty(&v).unwrap()).unwrap(); } /// A tree's digest: each file's path and SHA-256. fn tree(dir: &Path) -> Vec<(String, String)> { let mut files = Vec::new(); files_below(dir, "", &mut files); files.sort(); files.into_iter().map(|p| (p.clone(), sha256(&read(dir.join(&p))))).collect() } // ───────────────────────────────────────── the lock's bytes ────────────────────────────────────── #[test] fn a_locks_bytes_are_the_corpus_through_the_binary() { let d = scratch("vector"); let p = locked_plain(&d); assert_eq!(String::from_utf8(read(p.join("wai.lock"))).unwrap(), String::from_utf8(expected_lock("plain")).unwrap()); let full = d.join("full"); copy_dir(&corpus().join("fixture/full"), &full); let pk = packer(); let (code, v) = envelope(&full, &["lock", "--packer", pk.to_str().unwrap(), "--root", wai_dir().to_str().unwrap()]); assert_eq!(code, Some(0), "{v}"); assert_eq!(String::from_utf8(read(full.join("wai.lock"))).unwrap(), String::from_utf8(expected_lock("full")).unwrap()); assert_eq!(v["data"]["sha256"], sha256(&expected_lock("full")), "the digest it reports is the file's"); assert_eq!(v["op"], "project.lock"); } /// The same inputs on two directories at two times, and two ways of naming the directory. #[test] fn the_same_inputs_give_the_same_bytes_in_two_directories_at_two_times() { let d = scratch("reproducible"); let (a, b) = (d.join("one"), d.join("over/there/another-name")); copy_dir(&corpus().join("fixture/plain"), &a); copy_dir(&corpus().join("fixture/plain"), &b); assert!(wai(&a, &["lock"]).status.success()); std::thread::sleep(std::time::Duration::from_millis(1100)); let o = wai_command(&d).args(["lock", "over/there/another-name"]).env("TZ", "Pacific/Apia").env("LANG", "C").env("LC_ALL", "C").output().unwrap(); assert!(o.status.success(), "{}", text(&o)); let (la, lb) = (read(a.join("wai.lock")), read(b.join("wai.lock"))); assert_eq!(la, lb); let t = String::from_utf8(la.clone()).unwrap(); assert!(!t.contains(d.to_str().unwrap()), "no path: {t}"); assert!(!la.iter().any(|c| *c == b'\n' || *c == b'\r'), "no line ending: {t}"); let v: Value = serde_json::from_str(&t).unwrap(); assert_eq!(wai::jcs::jcs(&v).unwrap(), t, "RFC 8785"); // A second run changes nothing, and says so. let (code, v) = envelope(&a, &["lock"]); assert_eq!((code, v["data"]["changed"].as_bool()), (Some(0), Some(false)), "{v}"); // Every template's project, named for two directories. let (code, list) = envelope(&d, &["create", "--list"]); assert_eq!(code, Some(0)); for t in list["data"].as_array().unwrap() { let name = t["template"].as_str().unwrap(); let (x, y) = (format!("t/{name}/first"), format!("t/{name}/second")); assert!(wai(&d, &["create", &x, "--template", name]).status.success(), "{name}"); assert!(wai(&d, &["create", &y, "--template", name]).status.success(), "{name}"); for p in [&x, &y] { let o = wai(&d.join(p), &["lock"]); assert!(o.status.success(), "{name}: {}", text(&o)); } let (lx, ly) = (read(d.join(&x).join("wai.lock")), read(d.join(&y).join("wai.lock"))); assert_eq!(lx, ly, "{name}"); let v: Value = serde_json::from_slice(&lx).unwrap(); assert_eq!(v["template"]["name"], name); assert_eq!(v["template"]["sha256"], template_tone_aware(name), "{name}: the digest of the template's files"); } } /// [`template_digest`], with the file `wai create` makes for a watch room. fn template_tone_aware(name: &str) -> String { let root = wai_dir().join("examples").join(name); let mut files = Vec::new(); files_below(&root, "", &mut files); let mut lines: Vec<(String, String)> = files.into_iter().filter(|p| !p.starts_with("test/")).map(|p| (p.clone(), sha256(&read(root.join(&p))))).collect(); if name == "watch-together" { // The tone `wai create` makes, by the digest the corpus pins and project_verify.py makes. let v: Value = serde_json::from_slice(&read(corpus().join("vectors.json"))).unwrap(); lines.push(("tone.wav".to_string(), v["tone"]["sha256"].as_str().unwrap().to_string())); } listing(lines) } /// What a Windows checkout does to a project (`core.autocrlf=true`): the project file and the policy /// turn CRLF, and the lock, which has no line ending, stays as it is and still holds. #[test] fn a_project_with_crlf_line_ends_locks_to_the_same_bytes() { let d = scratch("crlf"); let pk = packer(); let (pk, root) = (pk.to_str().unwrap(), wai_dir()); let root = root.to_str().unwrap(); let args = ["lock", "--packer", pk, "--root", root]; let lf = d.join("lf"); copy_dir(&corpus().join("fixture/full"), &lf); assert!(wai(&lf, &args).status.success()); let crlf = d.join("crlf"); copy_dir(&corpus().join("fixture/full"), &crlf); for f in ["wai.json", "policy.toml"] { let t = String::from_utf8(read(crlf.join(f))).unwrap(); std::fs::write(crlf.join(f), t.replace('\n', "\r\n")).unwrap(); } assert!(read(crlf.join("wai.json")).windows(2).any(|w| w == b"\r\n")); // The lock from the LF project holds for the CRLF one, and a fresh lock is the same bytes. std::fs::copy(lf.join("wai.lock"), crlf.join("wai.lock")).unwrap(); let o = wai(&crlf, &["lock", "--check", "--packer", pk, "--root", root]); assert!(o.status.success(), "{}", text(&o)); std::fs::remove_file(crlf.join("wai.lock")).unwrap(); assert!(wai(&crlf, &args).status.success()); assert_eq!(read(crlf.join("wai.lock")), read(lf.join("wai.lock"))); } #[test] fn a_checkout_that_converts_line_endings_leaves_the_lock_as_it_is() { let d = scratch("autocrlf"); let repo = locked_plain(&d); let git = |dir: &Path, args: &[&str]| { let o = Command::new("git").current_dir(dir).args(["-c", "user.name=t", "-c", "user.email=t@example.org", "-c", "core.autocrlf=false", "-c", "init.defaultBranch=main"]).args(args).output().expect("git runs"); assert!(o.status.success(), "git {args:?}: {}", text(&o)); }; // A parameter set is pinned by its bytes, so a project that converts text keeps it out. std::fs::write(repo.join(".gitattributes"), "priors/** -text\n").unwrap(); git(&repo, &["init", "-q"]); git(&repo, &["add", "-A"]); git(&repo, &["commit", "-qm", "the project"]); let clone = d.join("clone"); let o = Command::new("git").current_dir(&d).args(["-c", "core.autocrlf=true", "clone", "-q", "--config", "core.autocrlf=true", repo.to_str().unwrap(), clone.to_str().unwrap()]).output().unwrap(); assert!(o.status.success(), "{}", text(&o)); assert!(read(clone.join("wai.json")).windows(2).any(|w| w == b"\r\n"), "the checkout converted the project file"); assert_eq!(read(clone.join("wai.lock")), read(repo.join("wai.lock")), "the lock has no line ending to convert"); let o = wai(&clone, &["lock", "--check"]); assert!(o.status.success(), "{}", text(&o)); let (code, v) = envelope(&clone, &["lock"]); assert_eq!((code, v["data"]["changed"].as_bool()), (Some(0), Some(false)), "{v}"); // The project file's canonical digest does not see the conversion. assert!(wai(&repo, &["build"]).status.success()); assert!(wai(&clone, &["build"]).status.success()); let record = |p: &Path| -> Value { serde_json::from_slice(&read(p.join("dist/wai-build.json"))).unwrap() }; assert_eq!(record(&repo)["project"], record(&clone)["project"]); assert_eq!(record(&repo)["lock"], record(&clone)["lock"]); // And a parameter set the checkout does convert is not what was pinned: the lock says so. std::fs::remove_file(clone.join("priors/tokenizer.json")).unwrap(); std::fs::write(clone.join("priors/tokenizer.json"), String::from_utf8(read(repo.join("priors/tokenizer.json"))).unwrap().replace('\n', "\r\n")).unwrap(); let (code, v) = envelope(&clone, &["lock", "--check"]); assert_eq!(code, Some(1), "{v}"); assert!(has(&v, "lock.priors.tokenizer", "failed", "lock-stale"), "{v}"); } /// Every file whose bytes a lock pins keeps them on such a checkout: what `wai create` holds, the /// corpora a suite reads, the registry's own export and this corpus. #[test] fn every_file_a_lock_pins_keeps_its_bytes_on_a_checkout_that_converts_line_endings() { let root = wai_dir(); let mut files: Vec = vec!["LICENSE".into(), "registry/capabilities.json".into(), "catalogue/ops.json".into()]; for t in std::fs::read_dir(root.join("examples")).unwrap().flatten().filter(|e| e.path().is_dir()) { let name = t.file_name().to_string_lossy().into_owned(); let mut v = Vec::new(); files_below(&t.path(), "", &mut v); files.extend(v.into_iter().map(|p| format!("examples/{name}/{p}"))); } for dir in ["claim-conformance", "jwp-conformance", "signer-conformance", "conform-result-conformance", "score-conformance", "pack-conformance", "http-conformance", "live-conformance/native-sink", "project-conformance"] { let mut v = Vec::new(); files_below(&root.join(dir), "", &mut v); files.extend(v.into_iter().map(|p| format!("{dir}/{p}"))); } // On standard input: the paths are more than a command line holds on Windows. They are written // from a thread of their own while git's answer is read, or each would wait on the other once // a pipe is full. let mut c = Command::new("git").current_dir(&root).args(["check-attr", "--stdin", "text", "eol"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().expect("git runs"); let mut input = c.stdin.take().unwrap(); let paths: String = files.iter().map(|f| format!("{f}\n")).collect(); let writer = std::thread::spawn(move || { use std::io::Write; input.write_all(paths.as_bytes()) }); let o = c.wait_with_output().expect("git runs"); writer.join().unwrap().unwrap(); assert!(o.status.success(), "{}", text(&o)); let out = String::from_utf8(o.stdout).unwrap(); let attr = |line: &str| line.rsplit(": ").next().unwrap_or("").to_string(); let (mut text_of, mut eol_of) = (std::collections::BTreeMap::new(), std::collections::BTreeMap::new()); for l in out.lines() { let path = l.split(": ").next().unwrap().to_string(); if l.contains(": text: ") { text_of.insert(path, attr(l)); } else if l.contains(": eol: ") { eol_of.insert(path, attr(l)); } } assert_eq!(text_of.len(), files.len()); for f in &files { let (t, e) = (text_of[f].as_str(), eol_of[f].as_str()); assert!(t == "unset" || (t == "set" && e == "lf"), "{f}: text {t}, eol {e}: a checkout with core.autocrlf=true would change its bytes"); } } // ────────────────────────────────────── what `wai lock --check` refuses ────────────────────────── #[test] fn a_stale_lock_is_refused_and_nothing_is_built() { let d = scratch("stale"); let p = locked_plain(&d); let o = wai(&p, &["lock", "--check"]); assert!(o.status.success(), "{}", text(&o)); let before = read(p.join("wai.lock")); // A file it pins changed. std::fs::write(p.join("priors/tokenizer.json"), "{\"tokens\": [\"c\"]}\n").unwrap(); let (code, v) = envelope(&p, &["lock", "--check"]); assert_eq!((code, v["ok"].as_bool()), (Some(1), Some(false)), "{v}"); assert_eq!(v["error"]["code"], "lock-stale", "{v}"); assert!(has(&v, "lock.priors.tokenizer", "failed", "lock-stale"), "{v}"); assert!(has(&v, "lock.registry", "held", ""), "what still holds is named too: {v}"); assert_eq!(read(p.join("wai.lock")), before, "--check writes nothing"); // Every stale member says what to do: the envelope's fix, its error's hint, and the text. let checks = v["data"]["checks"].as_array().unwrap(); assert!(checks.iter().filter(|c| c["code"] == "lock-stale").all(|c| c["fix"] == "run wai lock"), "{v}"); assert!(checks.iter().filter(|c| c["status"] == "held").all(|c| c.get("fix") == Some(&Value::Null)), "a check that holds has no fix: {v}"); assert_eq!(v["error"]["hint"], "run wai lock", "{v}"); let o = wai(&p, &["lock", "--check"]); assert_eq!(o.status.code(), Some(1)); assert!(text(&o).contains("FAIL") && text(&o).contains("lock.priors.tokenizer"), "{}", text(&o)); // Each stale finding's own lines: what failed, then its fix. let out = String::from_utf8_lossy(&o.stdout).into_owned(); let lines: Vec<&str> = out.lines().collect(); let at = lines.iter().position(|l| l.trim_start().starts_with("FAIL") && l.contains("lock.priors.tokenizer:")).unwrap_or_else(|| panic!("{out}")); assert_eq!(lines.get(at + 2).map(|l| l.trim()), Some("fix: run wai lock"), "{out}"); // Named from elsewhere, the fix names the project. let (_, v) = envelope(&d, &["lock", "--check", "plain"]); assert!(findings_of(&v, "lock-stale").iter().all(|c| c["fix"] == "run wai lock plain"), "{v}"); // wai check and wai build say the same, and a build writes nothing. let (code, v) = envelope(&p, &["check"]); assert_eq!(code, Some(1), "{v}"); assert!(has(&v, "lock.priors.tokenizer", "failed", "lock-stale"), "{v}"); assert_eq!(v["error"]["hint"], "run wai lock", "{v}"); let (code, v) = envelope(&p, &["build"]); assert_eq!(code, Some(1), "{v}"); assert_eq!(v["error"]["code"], "lock-stale", "{v}"); assert_eq!(v["error"]["hint"], "run wai lock", "{v}"); assert!(findings_of(&v, "lock-stale").iter().all(|c| c["fix"] == "run wai lock"), "{v}"); assert!(!p.join("dist").exists(), "a refused build writes nothing"); let o = wai(&p, &["build"]); assert!(text(&o).contains("fix: run wai lock") && text(&o).contains("nothing was written"), "{}", text(&o)); // `wai lock` writes it again, and everything holds. let (code, v) = envelope(&p, &["lock"]); assert_eq!((code, v["data"]["changed"].as_bool()), (Some(0), Some(true)), "{v}"); assert!(wai(&p, &["lock", "--check"]).status.success()); // A member the project now states, one it no longer states, and another template. edit_project(&p, |v| v["priors"]["extra"] = json!("index.html")); let (code, v) = envelope(&p, &["lock", "--check"]); assert_eq!(code, Some(1)); assert!(has(&v, "lock.priors.extra", "failed", "lock-stale"), "{v}"); assert!(wai(&p, &["lock"]).status.success()); edit_project(&p, |v| { v["priors"].as_object_mut().unwrap().remove("extra"); }); let (code, v) = envelope(&p, &["lock", "--check"]); assert_eq!(code, Some(1)); assert!(has(&v, "lock.priors.extra", "failed", "lock-stale"), "{v}"); assert!(wai(&p, &["lock"]).status.success()); edit_project(&p, |v| v["template"] = json!("video-room")); let (code, v) = envelope(&p, &["lock", "--check"]); assert_eq!(code, Some(1)); assert!(has(&v, "lock.template", "failed", "lock-stale"), "{v}"); // No lock at all: not found, and no build; each says what to do. std::fs::remove_file(p.join("wai.lock")).unwrap(); let (code, v) = envelope(&p, &["lock", "--check"]); assert_eq!((code, v["error"]["code"].as_str(), v["error"]["hint"].as_str()), (Some(5), Some("lock-missing"), Some("run wai lock")), "{v}"); let (code, v) = envelope(&p, &["build"]); assert_eq!((code, v["error"]["code"].as_str(), v["error"]["hint"].as_str()), (Some(5), Some("lock-missing"), Some("run wai lock")), "{v}"); let (code, v) = envelope(&p, &["check"]); assert_eq!(code, Some(1), "{v}"); assert!(has(&v, "lock", "failed", "lock-missing"), "{v}"); assert!(findings_of(&v, "lock-missing").iter().all(|c| c["fix"] == "run wai lock"), "{v}"); assert_eq!(v["error"]["hint"], "run wai lock", "{v}"); } /// The findings of a report with this code. fn findings_of<'a>(v: &'a Value, code: &str) -> Vec<&'a Value> { let f: Vec<&Value> = v["data"]["checks"].as_array().unwrap_or_else(|| panic!("no checks: {v}")).iter().filter(|c| c["code"] == code).collect(); assert!(!f.is_empty(), "no {code}: {v}"); f } /// Each edit by hand, by what it changed, refused by all three, and nothing written. #[test] fn a_lock_edited_by_hand_is_refused() { let d = scratch("edited"); let p = locked_plain(&d); let good = String::from_utf8(read(p.join("wai.lock"))).unwrap(); let v: Value = serde_json::from_str(&good).unwrap(); let flipped = { let r = v["registry"]["sha256"].as_str().unwrap(); let other = match r.strip_prefix('0') { Some(rest) => format!("1{rest}"), None => format!("0{}", &r[1..]), }; good.replace(r, &other) }; let cases: Vec<(&str, String, &str, &str)> = vec![ ("a digest changed", flipped, "lock.registry", "lock-stale"), ("another version", good.replace(env!("CARGO_PKG_VERSION"), "0.0.1"), "lock.toolchain", "lock-stale"), ("a line feed added", format!("{good}\n"), "lock", "lock-not-canonical"), ("CR LF added", format!("{good}\r\n"), "lock", "lock-not-canonical"), ("indented", serde_json::to_string_pretty(&v).unwrap(), "lock", "lock-not-canonical"), ("a member it does not know", good.replacen('{', "{\"decoders\":{},", 1), "lock", "lock-unknown-member"), ("a member of a member it does not know", good.replace("\"registry\":{", "\"registry\":{\"a\":1,"), "lock", "lock-unknown-member"), ("a member named twice", good.replacen('{', "{\"wai\":1,", 1), "lock", "lock-repeated-member"), ("null for a member", good.replacen('{', "{\"policy\":null,", 1), "lock", "lock-member-invalid"), ("a later version", good.replace("\"wai\":1", "\"wai\":2"), "lock", "lock-version"), ("not JSON", "a lock".to_string(), "lock", "lock-not-json"), ]; for (what, bytes, check, code) in cases { std::fs::write(p.join("wai.lock"), &bytes).unwrap(); let (exit, v) = envelope(&p, &["lock", "--check"]); assert_eq!(exit, Some(1), "{what}: {v}"); assert!(has(&v, check, "failed", code), "{what}: {check} {code}: {v}"); // What to do: write it again, or, for a later version's lock, a later wai. let to_do = if code == "lock-version" { "upgrade wai" } else { "run wai lock" }; assert!(findings_of(&v, code).iter().all(|c| c["fix"] == to_do), "{what}: {v}"); assert_eq!(v["error"]["hint"], to_do, "{what}: {v}"); let (exit, c) = envelope(&p, &["check"]); assert_eq!(exit, Some(1), "{what}: {c}"); assert!(has(&c, check, "failed", code), "{what}: {c}"); let (exit, b) = envelope(&p, &["build"]); assert_eq!(exit, Some(1), "{what}: {b}"); assert!(!p.join("dist").exists(), "{what}: nothing is built"); assert_eq!(read(p.join("wai.lock")), bytes.as_bytes(), "{what}: nothing is written"); } // A lock another wai wrote says which, and what to do. std::fs::write(p.join("wai.lock"), good.replace(env!("CARGO_PKG_VERSION"), "0.0.1")).unwrap(); let (_, v) = envelope(&p, &["check"]); let c = findings_of(&v, "lock-stale").into_iter().find(|c| c["check"] == "lock.toolchain").unwrap_or_else(|| panic!("{v}")); assert_eq!(c["message"], format!("wai.lock was written by wai 0.0.1, and this is wai {}", env!("CARGO_PKG_VERSION")), "{v}"); assert_eq!(c["fix"], "run wai lock", "{v}"); let o = wai(&p, &["build"]); assert_eq!(o.status.code(), Some(1)); assert!(text(&o).contains("written by wai 0.0.1") && text(&o).contains("fix: run wai lock"), "{}", text(&o)); // A later version says what to do; `wai lock` will not write over one. std::fs::write(p.join("wai.lock"), good.replace("\"wai\":1", "\"wai\":2")).unwrap(); let (_, v) = envelope(&p, &["lock", "--check"]); assert!(v.to_string().contains("upgrade wai"), "{v}"); let (code, v) = envelope(&p, &["lock"]); assert_eq!((code, v["error"]["code"].as_str()), (Some(2), Some("lock-version")), "{v}"); assert_eq!(read(p.join("wai.lock")), good.replace("\"wai\":1", "\"wai\":2").as_bytes()); // Any other edit is `wai lock`'s to write over, back to the same bytes. std::fs::write(p.join("wai.lock"), format!("{good}\n")).unwrap(); assert!(wai(&p, &["lock"]).status.success()); assert_eq!(read(p.join("wai.lock")), good.as_bytes()); } #[test] fn a_member_the_project_files_reader_does_not_know_is_refused() { let d = scratch("unknown-project-member"); let p = locked_plain(&d); edit_project(&p, |v| { v["receipts"] = json!({}); v["requires"]["toolchain"] = json!(">=0.5"); }); let (code, v) = envelope(&p, &["lock"]); assert_eq!((code, v["error"]["code"].as_str()), (Some(2), Some("project-file-invalid")), "{v}"); let m = v["error"]["message"].as_str().unwrap(); assert!(m.contains("receipts: not a member") && m.contains("requires.toolchain: not a member"), "every one is named: {m}"); let (code, v) = envelope(&p, &["check"]); assert_eq!(code, Some(1), "{v}"); let problems = v["data"]["checks"].as_array().unwrap().iter().filter(|c| c["code"] == "project-file-invalid").count(); assert_eq!(problems, 2, "{v}"); let (code, v) = envelope(&p, &["build"]); assert_eq!(code, Some(1), "{v}"); assert!(!p.join("dist").exists()); // A file of a later version than this wai reads is refused as that, whatever else it holds. edit_project(&p, |v| v["wai"] = json!(3)); let (code, v) = envelope(&p, &["lock"]); assert_eq!(code, Some(2), "{v}"); assert!(v["error"]["message"].as_str().unwrap().contains("upgrade wai"), "{v}"); } // ───────────────────────────────────────────── wai check ───────────────────────────────────────── /// Every finding, of every kind, in one run, as JSON and as text. #[test] fn wai_check_reports_every_finding_not_the_first() { let d = scratch("every-finding"); let p = locked_plain(&d); let lock = String::from_utf8(read(p.join("wai.lock"))).unwrap(); let v: Value = serde_json::from_str(&lock).unwrap(); let r = v["registry"]["sha256"].as_str().unwrap(); std::fs::write(p.join("wai.lock"), lock.replace(r, &"0".repeat(64))).unwrap(); edit_project(&p, |v| { v["extra"] = json!(1); v["another"] = json!(2); v["app"] = json!("no-such.oui"); v["dev"]["site"] = json!("no-such-dir"); v["dev"]["watch"] = json!("../outside.wav"); v["requires"]["capabilities"] = json!(["wai.image.png", "wai.image.nothing"]); }); std::fs::remove_file(p.join("priors/tokenizer.json")).unwrap(); let (code, v) = envelope(&p, &["check"]); assert_eq!(code, Some(1), "{v}"); assert_eq!(v["error"]["code"], "check-failed", "{v}"); for (check, code) in [ ("wai.json", "project-file-invalid"), ("app", "file-missing"), ("dev.site", "file-missing"), ("dev.watch", "path-leaves-project"), ("priors.tokenizer", "file-missing"), ("requires.capabilities", "capability-not-registered"), ("lock.priors.tokenizer", "prior-missing"), ("lock.registry", "lock-stale"), ] { assert!(has(&v, check, "failed", code), "{check} {code}: {v}"); } let unknown = v["data"]["checks"].as_array().unwrap().iter().filter(|c| c["code"] == "project-file-invalid").map(|c| c["message"].as_str().unwrap()).collect::>(); assert!(unknown.iter().any(|m| m.contains("extra:")) && unknown.iter().any(|m| m.contains("another:")), "{unknown:?}"); assert!(has(&v, "requires.capabilities", "held", ""), "what holds is named beside what does not: {v}"); let failed = v["data"]["failed"].as_u64().unwrap(); assert!(failed >= 9, "{failed}: {v}"); assert_eq!(failed as usize, findings(&v).iter().filter(|(_, s, _)| s == "failed").count()); let o = wai(&p, &["check"]); assert_eq!(o.status.code(), Some(1)); let t = text(&o); for want in ["extra:", "another:", "no-such.oui", "no-such-dir", "outside.wav", "wai.image.nothing", "lock.registry"] { assert!(t.contains(want), "{want}: {t}"); } } #[test] fn a_project_that_holds_passes_and_there_is_nothing_to_check_without_a_project() { let d = scratch("holds"); let p = locked_plain(&d); let (code, v) = envelope(&p, &["check"]); assert_eq!((code, v["ok"].as_bool(), v["op"].as_str()), (Some(0), Some(true), Some("project.check")), "{v}"); assert_eq!((v["data"]["failed"].as_u64(), v["data"]["not_checked"].as_u64()), (Some(0), Some(0)), "{v}"); assert_eq!(v["data"]["lock"]["sha256"], sha256(&read(p.join("wai.lock")))); let o = wai(&p, &["check"]); assert!(o.status.success() && text(&o).contains("every check held"), "{}", text(&o)); for verb in ["check", "lock", "build"] { let (code, v) = envelope(&d, &[verb]); assert_eq!((code, v["error"]["code"].as_str()), (Some(5), Some("no-project")), "{verb}: {v}"); } assert_eq!(wai(&p, &["check", "--nonesuch"]).status.code(), Some(2)); assert_eq!(wai(&p, &["lock", "a", "b"]).status.code(), Some(2)); assert_eq!(wai(&p, &["build", "--key"]).status.code(), Some(2)); } /// `wai`, copied away from anything beside it, with nothing on its PATH: no packer to find. fn alone(d: &Path) -> PathBuf { let dir = d.join("alone"); std::fs::create_dir_all(&dir).unwrap(); let w = dir.join(format!("wai{}", std::env::consts::EXE_SUFFIX)); program::copy_program(Path::new(env!("CARGO_BIN_EXE_wai")), &w); w } fn run_alone(w: &Path, d: &Path, args: &[&str]) -> (Option, Value) { let o = Command::new(w).current_dir(d).args(args).arg("--json").env("PATH", "").env_remove("WAI_PACK_BIN").env_remove("WAI_SPEC_ROOT").stdin(Stdio::null()).output().unwrap(); let v: Value = serde_json::from_slice(&o.stdout).unwrap_or_else(|e| panic!("{args:?}: {e}: {}", text(&o))); (o.status.code(), v) } /// A check that could not run says so, is never counted as holding, and the exit status is never 0. #[test] fn what_could_not_run_says_not_checked_and_never_passes() { let d = scratch_outside("not-checked"); let w = alone(&d); let full = d.join("full"); copy_dir(&corpus().join("fixture/full"), &full); let pk = packer(); let (pk, root) = (pk.to_str().unwrap(), wai_dir()); let root = root.to_str().unwrap(); assert!(wai(&full, &["lock", "--packer", pk, "--root", root]).status.success()); // Here, no packer and no checkout: the policy and the corpus cannot be recomputed. No check // failed, and the exit is 5, not 0. for args in [&["check"][..], &["lock", "--check"]] { let (code, v) = run_alone(&w, &full, args); assert_eq!(code, Some(5), "{args:?}: {v}"); assert_eq!((v["ok"].as_bool(), v["error"]["code"].as_str()), (Some(false), Some("not-checked")), "{args:?}: {v}"); assert!(has(&v, "lock.policy", "not checked", "packer-not-found"), "{args:?}: {v}"); assert!(has(&v, "lock.conformance.verifier/claim", "not checked", "no-corpus"), "{args:?}: {v}"); assert!(!findings(&v).iter().any(|(c, s, _)| s == "held" && (c == "lock.policy" || c.starts_with("lock.conformance"))), "never counted as holding: {v}"); assert_eq!(v["data"]["failed"], 0, "{v}"); assert!(v["data"]["not_checked"].as_u64().unwrap() >= 2, "{v}"); } // With the two named, everything runs and holds: what was missing was only that. let (code, v) = run_alone(&w, &full, &["lock", "--check", "--packer", pk, "--root", root]); assert_eq!(code, Some(0), "{v}"); // A failure beside a check that did not run is a failure, and names both. std::fs::write(full.join("priors/tokenizer.json"), "changed").unwrap(); let (code, v) = run_alone(&w, &full, &["check"]); assert_eq!(code, Some(1), "{v}"); assert!(has(&v, "lock.priors.tokenizer", "failed", "lock-stale") && has(&v, "lock.policy", "not checked", "packer-not-found"), "{v}"); // The fix is a command that works as it is: it names what the check was given. let (_, v) = run_alone(&w, &full, &["check", "--packer", pk, "--root", root]); let fix = findings_of(&v, "lock-stale")[0]["fix"].as_str().unwrap().to_string(); assert!(fix.starts_with("run wai lock --root ") && fix.contains(" --packer "), "{fix}"); // `wai lock` will not write a lock another host would write differently. std::fs::remove_file(full.join("wai.lock")).unwrap(); let (code, v) = run_alone(&w, &full, &["lock"]); assert_eq!((code, v["error"]["code"].as_str()), (Some(5), Some("packer-not-found")), "{v}"); assert!(!full.join("wai.lock").exists(), "nothing written"); // A template this wai does not hold: its name is pinned and its digest is absent, not made up; // and that it could not be pinned is not a pass. let p = d.join("elsewhere"); copy_dir(&corpus().join("fixture/plain"), &p); edit_project(&p, |v| v["template"] = json!("from-another-release")); let (code, v) = run_alone(&w, &p, &["lock"]); assert_eq!(code, Some(0), "{v}"); let lock: Value = serde_json::from_slice(&read(p.join("wai.lock"))).unwrap(); assert_eq!(lock["template"], json!({"name": "from-another-release"}), "{lock}"); let (code, v) = run_alone(&w, &p, &["check"]); assert_eq!(code, Some(5), "{v}"); assert!(has(&v, "lock.template", "not checked", "template-not-held"), "{v}"); // A member with nothing to pin is absent: never null, never empty. let bare = d.join("bare"); std::fs::create_dir_all(&bare).unwrap(); std::fs::write(bare.join("wai.json"), "{}").unwrap(); assert_eq!(run_alone(&w, &bare, &["lock"]).0, Some(0)); let lock: Value = serde_json::from_slice(&read(bare.join("wai.lock"))).unwrap(); let keys: Vec<&str> = lock.as_object().unwrap().keys().map(String::as_str).collect(); assert_eq!(keys.iter().copied().collect::>(), ["$schema", "registry", "toolchain", "wai"].into_iter().collect(), "{lock}"); // An app, with or without `wai app check` in this build. let app = d.join("app"); std::fs::create_dir_all(&app).unwrap(); std::fs::write(app.join("wai.json"), r#"{"app":"app.oui"}"#).unwrap(); std::fs::write(app.join("app.oui"), "root = App([], \"x\")\n").unwrap(); assert!(wai(&app, &["lock"]).status.success()); let (code, v) = envelope(&app, &["check"]); if cfg!(feature = "app") { assert_eq!(code, Some(1), "{v}"); assert!(has(&v, "app", "failed", "app-check-failed"), "the app's own check ran: {v}"); } else { assert_eq!(code, Some(5), "{v}"); assert!(has(&v, "app", "not checked", "operation-not-in-build"), "{v}"); } let _ = std::fs::remove_dir_all(&d); } /// A project as `wai create` makes it passes `wai check` and `wai build` with no further step: it /// comes with its lock. #[test] fn a_created_project_passes_check_and_build_at_once() { let d = scratch("created"); let (code, list) = envelope(&d, &["create", "--list"]); assert_eq!(code, Some(0)); for t in list["data"].as_array().unwrap() { let name = t["template"].as_str().unwrap(); assert!(t["files"].as_array().unwrap().iter().any(|f| f == "wai.lock"), "{name}: --list names the lock it writes"); let o = wai(&d, &["create", name, "--template", name]); assert!(o.status.success() && text(&o).contains("wai.lock"), "{name}: {}", text(&o)); let p = d.join(name); let lock = read(p.join("wai.lock")); let (code, v) = envelope(&p, &["check"]); assert_eq!((code, v["data"]["failed"].as_u64(), v["data"]["not_checked"].as_u64()), (Some(0), Some(0), Some(0)), "{name}: {v}"); let (code, v) = envelope(&p, &["build"]); assert_eq!(code, Some(0), "{name}: {v}"); let r: Value = serde_json::from_slice(&read(p.join("dist/wai-build.json"))).unwrap(); assert_eq!(r["lock"]["sha256"], sha256(&lock), "{name}"); // The lock `wai create` wrote is the one `wai lock` writes. let (code, v) = envelope(&p, &["lock"]); assert_eq!((code, v["data"]["changed"].as_bool()), (Some(0), Some(false)), "{name}: {v}"); } // A dry run names the lock and writes nothing. let (code, v) = envelope(&d, &["create", "dry", "--template", "video-room", "--dry-run"]); assert_eq!(code, Some(0), "{v}"); assert!(v["plan"].as_array().unwrap().iter().any(|s| s["write"].as_str().is_some_and(|w| w.ends_with("wai.lock"))), "{v}"); assert!(!d.join("dry").exists()); } // ───────────────────────────────────────────── wai build ───────────────────────────────────────── /// A site with what a build ships and what it leaves out. fn site_project(d: &Path) { copy_dir(&corpus().join("fixture/plain"), d); std::fs::create_dir_all(d.join("assets/deep")).unwrap(); std::fs::write(d.join("assets/deep/picture.bin"), [0u8, 13, 10, 255]).unwrap(); std::fs::write(d.join("assets/page.js"), "export {};\n").unwrap(); std::fs::write(d.join("README.md"), "# a project\n").unwrap(); std::fs::write(d.join("AGENTS.md"), "# Working on this project\n").unwrap(); std::fs::create_dir_all(d.join("test")).unwrap(); std::fs::write(d.join("test/run.sh"), "true\n").unwrap(); std::fs::create_dir_all(d.join(".wai/dev")).unwrap(); std::fs::write(d.join(".wai/dev/room.key"), "a local key\n").unwrap(); std::fs::write(d.join("signing.hex"), format!("{}\n", "5a".repeat(32))).unwrap(); } #[test] fn a_build_is_the_same_tree_in_two_directories_at_two_times_and_names_what_it_came_from() { let d = scratch("build-twice"); let (a, b) = (d.join("one"), d.join("over/there/another-name")); site_project(&a); site_project(&b); assert!(wai(&a, &["lock"]).status.success()); assert!(wai(&b, &["lock"]).status.success()); assert_eq!(read(a.join("wai.lock")), read(b.join("wai.lock"))); let (code, va) = envelope(&a, &["build"]); assert_eq!(code, Some(0), "{va}"); std::thread::sleep(std::time::Duration::from_millis(1100)); let o = wai_command(&d).args(["build", "over/there/another-name", "--json"]).env("TZ", "Pacific/Apia").env("LANG", "C").env("LC_ALL", "C").output().unwrap(); assert!(o.status.success(), "{}", text(&o)); let vb: Value = serde_json::from_slice(&o.stdout).unwrap(); assert_eq!(tree(&a.join("dist")), tree(&b.join("dist")), "the same bytes"); let record_bytes = read(a.join("dist/wai-build.json")); assert_eq!(va["data"]["record_sha256"], sha256(&record_bytes)); assert_eq!(vb["data"]["record_sha256"], va["data"]["record_sha256"]); // The record names the two digests, worked out here. let text_of_project = String::from_utf8(read(a.join("wai.json"))).unwrap(); let project_digest = sha256(wai::jcs::jcs(&serde_json::from_str::(&text_of_project).unwrap()).unwrap().as_bytes()); let lock_digest = sha256(&read(a.join("wai.lock"))); let r: Value = serde_json::from_slice(&record_bytes).unwrap(); assert_eq!(wai::jcs::jcs(&r).unwrap().as_bytes(), &record_bytes[..], "RFC 8785"); assert_eq!(r["$schema"], "https://wai.transaction.science/schema/wai-build.json/1"); assert_eq!(r["project"]["sha256"], project_digest, "{r}"); assert_eq!(r["project"]["name"], "lock-fixture", "{r}"); assert_eq!(r["lock"]["sha256"], lock_digest, "{r}"); let lock: Value = serde_json::from_slice(&read(a.join("wai.lock"))).unwrap(); assert_eq!(r["toolchain"], lock["toolchain"], "{r}"); assert_eq!((va["data"]["project_sha256"].as_str(), va["data"]["lock_sha256"].as_str()), (Some(project_digest.as_str()), Some(lock_digest.as_str())), "{va}"); // Its files are every file of the tree but itself, each with its digest and size, sorted. let mut on_disk: Vec<(String, String)> = tree(&a.join("dist")).into_iter().filter(|(p, _)| p != "wai-build.json").collect(); on_disk.sort_by(|x, y| x.0.as_bytes().cmp(y.0.as_bytes())); let named: Vec<(String, String)> = r["files"].as_array().unwrap().iter().map(|f| (f["path"].as_str().unwrap().to_string(), f["sha256"].as_str().unwrap().to_string())).collect(); assert_eq!(named, on_disk); for f in r["files"].as_array().unwrap() { assert_eq!(f["bytes"].as_u64().unwrap(), read(a.join("dist").join(f["path"].as_str().unwrap())).len() as u64); } assert!(!record_bytes.windows(d.to_str().unwrap().len()).any(|w| w == d.to_str().unwrap().as_bytes()), "no path outside dist/"); assert!(!record_bytes.iter().any(|c| *c == b'\n' || *c == b'\r')); // What it holds and what it leaves out. let paths: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f["path"].as_str().unwrap()).collect(); assert_eq!(paths, ["assets/deep/picture.bin", "assets/page.js", "index.html", "priors/q4/a.bin", "priors/q4/b.json", "priors/tokenizer.json"]); assert_eq!(read(a.join("dist/assets/deep/picture.bin")), [0u8, 13, 10, 255], "byte for byte"); let left: Vec<&str> = va["data"]["left_out"].as_array().unwrap().iter().map(|l| l["path"].as_str().unwrap()).collect(); for want in ["signing.hex", "README.md", "AGENTS.md", "test/run.sh", "wai.json", "wai.lock"] { assert!(left.contains(&want), "{want}: {left:?}"); } assert!(va["data"]["left_out"].as_array().unwrap().iter().any(|l| l["path"] == "signing.hex" && l["why"].as_str().unwrap().contains("secret")), "{va}"); // Building again changes nothing, and leaves nothing beside the tree. let first = tree(&a.join("dist")); let (code, _) = envelope(&a, &["build"]); assert_eq!(code, Some(0)); assert_eq!(tree(&a.join("dist")), first); assert!(!std::fs::read_dir(&a).unwrap().flatten().any(|e| e.file_name().to_string_lossy().starts_with(".dist")), "no working tree left"); // A title in the project file changes the record, and only the project's digest in it. edit_project(&a, |v| v["title"] = json!("Another title")); let (code, _) = envelope(&a, &["build"]); assert_eq!(code, Some(0), "the title changes no pin, so the lock still holds"); let again: Value = serde_json::from_slice(&read(a.join("dist/wai-build.json"))).unwrap(); assert_ne!(again["project"], r["project"]); assert_eq!(again["lock"], r["lock"]); assert_eq!(again["files"], r["files"]); } #[test] fn a_build_refuses_what_it_should_and_a_dry_run_writes_nothing() { let d = scratch("build-refusals"); // Nothing to build. let bare = d.join("bare"); std::fs::create_dir_all(&bare).unwrap(); std::fs::write(bare.join("wai.json"), "{}").unwrap(); assert!(wai(&bare, &["lock"]).status.success()); // A project that states nothing for wai to build gets its record alone, which still names // the project and the lock it is. let (code, v) = envelope(&bare, &["build"]); assert_eq!((code, v["data"]["record_only"].as_bool(), v["data"]["files"].as_u64()), (Some(0), Some(true), Some(0)), "{v}"); assert_eq!(tree(&bare.join("dist")).into_iter().map(|(p, _)| p).collect::>(), ["wai-build.json"]); let r: Value = serde_json::from_slice(&read(bare.join("dist/wai-build.json"))).unwrap(); assert_eq!(r["lock"]["sha256"], sha256(&read(bare.join("wai.lock"))), "{r}"); let p = d.join("site"); site_project(&p); assert!(wai(&p, &["lock"]).status.success()); // A dry run writes nothing, and says what it would. let (code, v) = envelope(&p, &["build", "--dry-run"]); assert_eq!((code, v["dry_run"].as_bool()), (Some(0), Some(true)), "{v}"); assert!(v["plan"].as_array().unwrap().iter().any(|s| s["write"].as_str().is_some_and(|w| w.ends_with("index.html"))), "{v}"); assert!(!p.join("dist").exists() && !std::fs::read_dir(&p).unwrap().flatten().any(|e| e.file_name().to_string_lossy().starts_with(".dist"))); // A dist/ with no record is not its to replace without --yes. std::fs::create_dir_all(p.join("dist")).unwrap(); std::fs::write(p.join("dist/mine.txt"), "mine").unwrap(); let (code, v) = envelope(&p, &["build"]); assert_eq!((code, v["error"]["code"].as_str()), (Some(4), Some("confirmation-required")), "{v}"); assert_eq!(read(p.join("dist/mine.txt")), b"mine"); let (code, _) = envelope(&p, &["build", "--yes"]); assert_eq!(code, Some(0)); assert!(p.join("dist/wai-build.json").is_file() && !p.join("dist/mine.txt").exists()); // One with a record is its own, and needs no --yes. let (code, _) = envelope(&p, &["build"]); assert_eq!(code, Some(0)); // A file named as the record, in the site, is refused, by wai check too. std::fs::write(p.join("wai-build.json"), "{}").unwrap(); let (code, v) = envelope(&p, &["check"]); assert_eq!(code, Some(1), "{v}"); assert!(has(&v, "dev.site", "failed", "site-holds-record"), "{v}"); let (code, _) = envelope(&p, &["build"]); assert_eq!(code, Some(1)); std::fs::remove_file(p.join("wai-build.json")).unwrap(); // A site that is the output. edit_project(&p, |v| v["dev"]["site"] = json!("dist")); let (code, v) = envelope(&p, &["check"]); assert_eq!(code, Some(1), "{v}"); assert!(has(&v, "dev.site", "failed", "site-is-output"), "{v}"); edit_project(&p, |v| v["dev"]["site"] = json!(".")); // A link: the tree is files. #[cfg(unix)] { std::os::unix::fs::symlink("index.html", p.join("link.html")).unwrap(); let (code, v) = envelope(&p, &["check"]); assert_eq!(code, Some(1), "{v}"); assert!(has(&v, "dev.site", "failed", "site-not-files"), "{v}"); std::fs::remove_file(p.join("link.html")).unwrap(); } // A package needs its key, and its directory stays under dist/. edit_project(&p, |v| v["package"] = json!({"policy": "policy.toml", "out": "../elsewhere"})); std::fs::copy(corpus().join("fixture/full/policy.toml"), p.join("policy.toml")).unwrap(); let pk = packer(); let pk = pk.to_str().unwrap(); assert!(wai(&p, &["lock", "--packer", pk]).status.success()); let (code, v) = envelope(&p, &["check", "--packer", pk]); assert_eq!(code, Some(1), "{v}"); assert!(has(&v, "package.out", "failed", "path-leaves-project"), "{v}"); assert!(has(&v, "package.policy", "failed", "file-missing"), "the policy names a source that is not there: {v}"); } /// One project, as a template makes it with its title made and its package named. fn package_project(d: &Path, pk: &str) -> PathBuf { let made = d.join("made"); assert!(wai(d, &["create", made.to_str().unwrap(), "--template", "package-and-deliver"]).status.success()); let o = Command::new(pk).current_dir(&made).args(["fixture", "gen", "video-base-01", "-o", "video-base-01.y4m"]).output().unwrap(); assert!(o.status.success(), "{}", text(&o)); edit_project(&made, |v| v["package"] = json!({"policy": "policy.toml"})); made } /// A build verifies what it packed, and a package refused either way leaves the tree as it was. #[cfg(unix)] #[test] fn a_package_the_packer_refuses_leaves_the_tree_as_it_was() { let d = scratch("build-package-refused"); let pk = packer(); let pk = pk.to_str().unwrap(); let p = package_project(&d, pk); assert!(wai(&p, &["lock", "--packer", pk]).status.success()); let (code, v) = envelope(&p, &["build", "--packer", pk, "--key", "test-key.hex"]); assert_eq!(code, Some(0), "{v}"); let before = tree(&p.join("dist")); // The packer itself but for the one verb. for verb in ["verify", "pack"] { let stand_in = d.join(format!("refuses-{verb}")); program::write_program(&stand_in, &format!("#!/bin/sh\nif [ \"$1\" = {verb} ]; then echo refused >&2; exit 1; fi\nexec '{pk}' \"$@\"\n")); let (code, v) = envelope(&p, &["build", "--packer", stand_in.to_str().unwrap(), "--key", "test-key.hex"]); assert_eq!((code, v["error"]["code"].as_str()), (Some(1), Some("package-refused")), "{verb}: {v}"); assert_eq!(tree(&p.join("dist")), before, "{verb}: the tree as it was"); assert!(!std::fs::read_dir(&p).unwrap().flatten().any(|e| e.file_name().to_string_lossy().starts_with(".dist")), "{verb}: no working tree left"); } } /// The reference packager, through a template's own title: the package is the same bytes built /// twice, its receipt verifies, and a key's secret stays out of the tree. #[test] fn a_package_is_built_by_the_packager_and_is_the_same_bytes_built_twice() { let d = scratch("build-package"); let pk = packer(); let pk = pk.to_str().unwrap(); let made = package_project(&d, pk); let build = |dir: &Path| -> (PathBuf, Value) { let p = d.join(dir); copy_dir(&made, &p); let o = wai(&p, &["lock", "--packer", pk]); assert!(o.status.success(), "{}", text(&o)); let (code, c) = envelope(&p, &["check", "--packer", pk]); assert_eq!(code, Some(0), "{c}"); let (code, v) = envelope(&p, &["build", "--packer", pk, "--key", "test-key.hex"]); assert_eq!(code, Some(0), "{v}"); (p, v) }; let (a, va) = build(Path::new("first")); std::thread::sleep(std::time::Duration::from_millis(1100)); let (b, vb) = build(Path::new("over/there/another-name")); assert_eq!(tree(&a.join("dist")), tree(&b.join("dist")), "the same bytes"); assert_eq!(va["data"]["record_sha256"], vb["data"]["record_sha256"]); assert!(a.join("dist/package/package.receipt.json").is_file()); let o = Command::new(pk).args(["verify", a.join("dist/package").to_str().unwrap()]).output().unwrap(); assert!(o.status.success(), "{}", text(&o)); for gone in ["test-key.hex", "policy.toml", "video-base-01.y4m", "floor/v.obu"] { assert!(!a.join("dist").join(gone).exists(), "{gone} stays out of the tree"); } assert!(a.join("dist/index.html").is_file() && a.join("dist/player.js").is_file()); let lock: Value = serde_json::from_slice(&read(a.join("wai.lock"))).unwrap(); assert_eq!(lock["policy"]["schema"], "wai.pack.policy/1"); // No key: a usage error, and the tree as it was. let before = tree(&a.join("dist")); let (code, v) = envelope(&a, &["build", "--packer", pk]); assert_eq!(code, Some(2), "{v}"); // The policy changed: the lock is stale, and nothing is built. let policy = String::from_utf8(read(a.join("policy.toml"))).unwrap(); std::fs::write(a.join("policy.toml"), policy.replace("target_ms = 2000", "target_ms = 3000")).unwrap(); let (code, v) = envelope(&a, &["build", "--packer", pk, "--key", "test-key.hex"]); assert_eq!((code, v["error"]["code"].as_str()), (Some(1), Some("lock-stale")), "{v}"); assert!(has(&v, "lock.policy", "failed", "lock-stale"), "{v}"); assert_eq!(tree(&a.join("dist")), before); // A CRLF policy is the same policy: the canonical digest is of what it says. std::fs::write(a.join("policy.toml"), policy.replace('\n', "\r\n")).unwrap(); let (code, v) = envelope(&a, &["lock", "--check", "--packer", pk]); assert_eq!(code, Some(0), "{v}"); // A policy the packer refuses is refused, by name. std::fs::write(a.join("policy.toml"), "schema = \"nonesuch\"\n").unwrap(); let (code, v) = envelope(&a, &["check", "--packer", pk]); assert_eq!(code, Some(1), "{v}"); assert!(has(&v, "lock.policy", "failed", "policy-refused"), "{v}"); let (code, v) = envelope(&a, &["lock", "--packer", pk]); assert_eq!((code, v["error"]["code"].as_str()), (Some(1), Some("policy-refused")), "{v}"); } /// A check that could not run does not stop a build it has no bearing on, is named in its report, /// and is not in the tree: a host with no checkout of the standard builds the same bytes. #[test] fn what_could_not_be_checked_is_named_and_is_not_in_the_tree() { let d = scratch_outside("build-not-checked"); let w = alone(&d); let p = d.join("site"); site_project(&p); edit_project(&p, |v| v["conform"] = json!({"suites": ["verifier/claim"]})); let root = wai_dir(); let root = root.to_str().unwrap(); assert!(wai(&p, &["lock", "--root", root]).status.success()); let (code, v) = run_alone(&w, &p, &["build"]); assert_eq!(code, Some(0), "{v}"); let named = v["data"]["not_checked"].as_array().unwrap(); assert!(named.iter().any(|n| n["check"] == "lock.conformance.verifier/claim" && n["status"] == "not checked"), "{v}"); let without = tree(&p.join("dist")); let (code, v) = run_alone(&w, &p, &["build", "--root", root]); assert_eq!(code, Some(0), "{v}"); assert_eq!(v["data"]["not_checked"].as_array().unwrap().len(), 0, "{v}"); assert_eq!(tree(&p.join("dist")), without); // In text too. let o = Command::new(&w).current_dir(&p).args(["build"]).env("PATH", "").env_remove("WAI_PACK_BIN").env_remove("WAI_SPEC_ROOT").stdin(Stdio::null()).output().unwrap(); assert!(o.status.success() && text(&o).contains("not checked lock.conformance.verifier/claim"), "{}", text(&o)); let _ = std::fs::remove_dir_all(&d); } // ────────────────────────────────── the second reader, and the door ────────────────────────────── /// The interpreter: `$PYTHON`, or `python3`. fn python(args: &[&str]) -> Output { Command::new(std::env::var_os("PYTHON").unwrap_or_else(|| "python3".into())).arg("-I").arg(corpus().join("project_verify.py")).args(args).stdin(Stdio::null()).output().expect("python3 runs") } /// `project_verify.py`, standard library only, recomputes what `wai` wrote, and refuses each /// change `wai` refuses. #[test] fn a_second_reader_recomputes_every_digest() { let v = corpus().join("vectors.json"); let v = v.to_str().unwrap(); for mode in ["vectors", "selftest"] { let o = python(&[mode, v]); assert!(o.status.success(), "{mode}: {}", text(&o)); } let d = scratch("second-reader"); let (root, pk) = (wai_dir(), packer()); let (root, pk) = (root.to_str().unwrap(), pk.to_str().unwrap()); let holds = |p: &Path| { let o = python(&["project", p.to_str().unwrap(), "--wai", root]); assert!(o.status.success(), "{}: {}", p.display(), text(&o)); }; let refuses = |p: &Path, what: &str| { let o = python(&["project", p.to_str().unwrap(), "--wai", root]); assert_eq!(o.status.code(), Some(1), "{what}: {}", text(&o)); }; let plain = locked_plain(&d); holds(&plain); let full = d.join("full"); copy_dir(&corpus().join("fixture/full"), &full); assert!(wai(&full, &["lock", "--packer", pk, "--root", root]).status.success()); holds(&full); // Every suite `wai conform` holds, each corpus's digest recomputed from the files the suite reads. let (code, list) = envelope(&d, &["conformance", "list", "--root", root]); assert_eq!(code, Some(0), "{list}"); let suites: Vec = list["data"]["suites"].as_array().unwrap().iter().map(|s| format!("{}/{}", s["kind"].as_str().unwrap(), s["suite"].as_str().unwrap())).collect(); assert!(suites.len() > 4 && suites.contains(&"verifier/claim".to_string()), "{list}"); let every_suite = d.join("every-suite"); std::fs::create_dir_all(&every_suite).unwrap(); std::fs::write(every_suite.join("wai.json"), json!({"conform": {"suites": suites}}).to_string()).unwrap(); let o = wai(&every_suite, &["lock", "--root", root]); assert!(o.status.success(), "{}", text(&o)); holds(&every_suite); // Every template's digest, the watch room's made sound among them. for t in ["video-room", "watch-together", "package-and-deliver"] { let p = d.join(format!("t-{t}")); assert!(wai(&d, &["create", p.to_str().unwrap(), "--template", t]).status.success()); assert!(wai(&p, &["lock"]).status.success()); holds(&p); } // A built tree, and its record. let site = d.join("site"); site_project(&site); assert!(wai(&site, &["lock"]).status.success()); assert!(wai(&site, &["build"]).status.success()); holds(&site); let dist = site.join("dist"); let original = read(dist.join("index.html")); std::fs::write(dist.join("index.html"), b"changed").unwrap(); refuses(&site, "a file of the tree changed"); std::fs::write(dist.join("index.html"), &original).unwrap(); std::fs::write(dist.join("added.txt"), b"x").unwrap(); refuses(&site, "a file the record does not name"); std::fs::remove_file(dist.join("added.txt")).unwrap(); holds(&site); // Each edit of the lock this binary refuses, the second reader refuses too. let good = String::from_utf8(read(plain.join("wai.lock"))).unwrap(); let r = serde_json::from_str::(&good).unwrap()["registry"]["sha256"].as_str().unwrap().to_string(); for (what, bytes) in [ ("a digest", good.replace(&r, &"0".repeat(64))), ("a line feed", format!("{good}\n")), ("a member", good.replacen('{', "{\"decoders\":{},", 1)), ("a later version", good.replace("\"wai\":1", "\"wai\":2")), ] { std::fs::write(plain.join("wai.lock"), &bytes).unwrap(); assert_eq!(wai(&plain, &["lock", "--check"]).status.code(), Some(1), "{what}"); refuses(&plain, what); } // And a stale lock, when what it pins moved. std::fs::write(plain.join("wai.lock"), &good).unwrap(); holds(&plain); std::fs::write(plain.join("priors/tokenizer.json"), b"moved").unwrap(); assert_eq!(wai(&plain, &["lock", "--check"]).status.code(), Some(1)); refuses(&plain, "a parameter set changed"); } /// A tool of `wai mcp` on a project: `structuredContent`, or `isError`'s text. fn tool(project: &Path, name: &str, args: Value) -> Value { use std::io::{BufRead, BufReader, Write}; let mut c = Command::new(env!("CARGO_BIN_EXE_wai")) .args(["mcp", "--project", project.to_str().unwrap()]) .env_remove("WAI_SPEC_ROOT") .env_remove("WAI_PACK_BIN") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()) .spawn() .expect("wai mcp starts"); let mut stdin = c.stdin.take().unwrap(); let mut out = BufReader::new(c.stdout.take().unwrap()); let init = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}}}); let call = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": name, "arguments": args}}); writeln!(stdin, "{init}\n{}\n{call}", json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).unwrap(); let mut line = String::new(); out.read_line(&mut line).unwrap(); line.clear(); out.read_line(&mut line).unwrap(); drop(stdin); let _ = c.wait(); serde_json::from_str::(&line).unwrap()["result"].clone() } #[test] fn a_coding_agent_reaches_the_three_through_wai_mcp() { let d = scratch("mcp"); let p = d.join("plain"); copy_dir(&corpus().join("fixture/plain"), &p); let r = tool(&p, "project_lock", json!({})); assert_ne!(r["isError"], true, "{r}"); assert_eq!(r["structuredContent"]["sha256"], sha256(&read(p.join("wai.lock"))), "{r}"); let r = tool(&p, "project_check", json!({})); assert_eq!((r["structuredContent"]["failed"].as_u64(), r["structuredContent"]["not_checked"].as_u64()), (Some(0), Some(0)), "{r}"); let r = tool(&p, "project_build", json!({})); assert_eq!(r["structuredContent"]["record_sha256"], sha256(&read(p.join("dist/wai-build.json"))), "{r}"); // A stale lock refuses the tool, as it does the command. std::fs::write(p.join("priors/tokenizer.json"), b"moved").unwrap(); let before = tree(&p.join("dist")); let r = tool(&p, "project_build", json!({})); assert_eq!(r["isError"], true, "{r}"); assert!(r.to_string().contains("lock-stale"), "{r}"); assert_eq!(tree(&p.join("dist")), before); let r = tool(&p, "project_check", json!({})); assert_eq!(r["structuredContent"]["failed"].as_u64(), Some(1), "{r}"); // Outside the project directory, nothing. let r = tool(&p, "project_lock", json!({"dir": "../elsewhere"})); assert_eq!(r["isError"], true, "{r}"); } #[test] fn the_schemas_are_published_as_wai_jsons_is() { let release = String::from_utf8(read(wai_dir().join("../.github/workflows/wai-release.yml"))).unwrap(); assert!(release.contains("wai/schema/*.schema.json"), "the release waits for every schema to be served"); for (file, id) in [("wai-json-1", "wai.json/1"), ("wai-json-2", "wai.json/2"), ("wai-lock-1", "wai.lock/1"), ("wai-build-1", "wai-build.json/1")] { let s: Value = serde_json::from_slice(&read(wai_dir().join(format!("schema/{file}.schema.json")))).unwrap(); assert_eq!(s["$id"], format!("https://wai.transaction.science/schema/{id}")); assert_eq!(s["$schema"], "https://json-schema.org/draft/2020-12/schema"); } let readme = String::from_utf8(read(wai_dir().join("schema/README.md"))).unwrap(); assert!(readme.contains("wai-lock-1.schema.json") && readme.contains("wai-build-1.schema.json"), "{readme}"); } /// An app's project, as `wai create --from` writes it: `wai app check` runs inside `wai check`, a /// refused line stops a build, and the tree holds the page, its runtime and the program. #[cfg(feature = "app")] #[test] fn an_apps_project_is_checked_by_wai_app_check_and_builds() { const PROGRAM: &str = "root = App([show], \"Friday night\")\nshow = Show([player, chat, poll, people], \"Live from the studio\", 2)\nplayer = Player()\nchat = Chat(\"Talk\")\npoll = Poll(\"Which song next?\", [\"The first one\", \"The quiet one\"])\npeople = Participants()\n"; let d = scratch("app"); let rt = d.join("runtime"); let files = [("create/wai-app-app.js", "export {};\n"), ("create/pkg/wai_app_bg.wasm", "\0asm"), ("room/wai-app-room-client.js", "// the room client\n")]; let mut listed = Vec::new(); for (p, t) in files { let f = rt.join(p); std::fs::create_dir_all(f.parent().unwrap()).unwrap(); std::fs::write(&f, t).unwrap(); listed.push(json!({"path": p, "sha256": sha256(t.as_bytes())})); } std::fs::write(rt.join("create/wai-app-runtime.json"), json!({"files": listed}).to_string()).unwrap(); std::fs::write(d.join("show.oui"), PROGRAM).unwrap(); let p = d.join("show"); let o = wai(&d, &["create", "show", "--from", "show.oui", "--runtime", rt.to_str().unwrap()]); assert!(o.status.success(), "{}", text(&o)); // `wai create --from` wrote its lock: no further step. assert!(p.join("wai.lock").is_file()); let (code, v) = envelope(&p, &["check"]); assert_eq!(code, Some(0), "{v}"); assert!(findings(&v).iter().any(|(c, s, _)| c == "app" && s == "held"), "wai app check ran: {v}"); let (code, v) = envelope(&p, &["build"]); assert_eq!(code, Some(0), "{v}"); for f in ["app.oui", "index.html", "vendor.json"] { assert!(p.join("dist").join(f).is_file(), "{f}"); } assert!(p.join("dist/vendor").is_dir()); // A runtime file changed: wai app check's own failure, named, and no build over the tree. let before = tree(&p.join("dist")); let vendored = std::fs::read_dir(p.join("vendor/create")).unwrap().flatten().find(|e| e.path().is_file()).unwrap().path(); std::fs::write(&vendored, "changed").unwrap(); let (code, v) = envelope(&p, &["check"]); assert_eq!(code, Some(1), "{v}"); assert!(has(&v, "app", "failed", "app-check-failed"), "{v}"); let (code, _) = envelope(&p, &["build"]); assert_eq!(code, Some(1)); assert_eq!(tree(&p.join("dist")), before); }