open-standards/wai/wai-rs/tests/cli_project.rs
Transaction Science 1d92a45337
All checks were successful
wai-app and Create / wai-app · does this change touch it (push) Successful in 15s
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (push) Successful in 11s
wai key · the real keychain / does this change touch it (push) Successful in 16s
joule-moq-remote host agent / joule-moq-remote · does this change touch it (push) Successful in 20s
joule-moq-room room service / joule-moq-room · does this change touch it (push) Successful in 22s
wai-audit collector and player integration / does this change touch it (push) Successful in 19s
wai-sink-swift native sink package / does this change touch it (push) Successful in 25s
wai-quantum studio and desktop / does this change touch it (push) Successful in 32s
WAI Studio / does this change touch it (push) Successful in 21s
wai-sink-android native sink library / does this change touch it (push) Successful in 59s
wai-quantum for JavaScript / does this change touch it (push) Successful in 1m0s
wai-fleet service / does this change touch it (push) Successful in 1m5s
joule-moq-web browser client / joule-moq-web · does this change touch it (push) Successful in 1m10s
wai-ledger service / does this change touch it (push) Successful in 1m1s
wai key · Windows / does this change touch it (push) Successful in 1m2s
WAI operation catalogue / does this change touch it (push) Successful in 1m7s
wai-app and Create / wai-app · tests, clippy, the line language's verifier (linux) (push) Successful in 55s
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (push) Successful in 1m44s
WAI Studio / the catalogue's actions, the unit tests and the core · linux (push) Successful in 1m20s
wai-audit collector and player integration / collector · linux (push) Successful in 2m6s
wai key · the real keychain / the real keychain · macos (push) Successful in 48s
joule-moq-remote host agent / joule-moq-remote, every target (linux-arm64) (push) Successful in 3m5s
wai-ledger service / service · linux (push) Successful in 2m2s
joule-moq-remote host agent / joule-moq-remote, every target (macos) (push) Successful in 1m4s
wai-audit collector and player integration / collector, player integration and the browser end to end · macos (push) Successful in 1m23s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Successful in 5m42s
wai-sink-android native sink library / the JNI layer and the platform-free half on the host JVM · linux-arm64 (push) Successful in 4m30s
wai-fleet service / service · linux (push) Successful in 3m14s
joule-moq-room room service / joule-moq-room, every target (push) Successful in 6m14s
wai-quantum studio and desktop / studio · browser (push) Successful in 6m12s
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (push) Successful in 6m48s
wai-quantum for JavaScript / javascript · linux (push) Successful in 5m25s
wai-quantum studio and desktop / desktop app · linux (push) Successful in 9m2s
wai-quantum studio and desktop / desktop app · macos (push) Successful in 4m49s
WAI operation catalogue / the catalogue and every door · linux (push) Successful in 7m52s
wai-app and Create / wai-app · Create, browser end-to-end (macos) (push) Successful in 10m44s
wai-ledger service / service · macos (push) Successful in 56s
wai-fleet service / service · macos (push) Successful in 1m29s
WAI Studio / Studio in Chromium and Firefox · macos (push) Successful in 8m18s
WAI Studio / Studio in WebKit · linux (push) Successful in 12m35s
wai-sink-android native sink library / every ABI, the AAR, and the instrumented tests in a headless emulator · macos (push) Successful in 7m12s
wai-sink-swift native sink package / libwai slices, desktop tests, simulator tests · macos (push) Successful in 16m13s
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (push) Successful in 16m3s
wai-quantum studio and desktop / desktop app · windows (push) Successful in 10m17s
wai key · Windows / wai key and the toolchain's tests · windows (push) Successful in 2m32s
wai-rs tests: every program a test runs is written by a process of its own
dx::target's a_kept_key_goes_to_the_keychain_on_standard_input_alone failed on main (CI run
27147, e7c7b209b): "the keychain tool did not start: .../security: Text file busy (os error
26)". It wrote its stand-in `security` from the test process and the keychain ran it at once. A
fork copies the forking process's descriptors into its child until that child's exec, so a spawn
another test's thread made while the file was open for writing still held it open when the
script ran, and Linux refused to run it. Renaming the file into place would not help: the child
holds the same file.

tests/support/program.rs is now the one way a test makes a program it runs: write_program (`sh`
writes the text and sets mode 755) and copy_program (`cp`). That process has exited before either
returns, so the test process never holds a descriptor for the file. The binary's unit tests
include it (src/bin/wai.rs, cfg(test)) and so do the integration tests. Every test that wrote or
copied a program in its own process and then ran it now uses it:
- unit: dx::target's keychain test (the failure), dx::dev's stale-stack programs, dx::doctor's
  automation-package browser; key.rs's and doctor.rs's own sh writers, which already wrote
  this way, now share it;
- integration: cli_project (the refusing packer, and the copy of wai run alone), cli_doctor
  (program(), the automation browser, the copy of wai when a hard link fails), cli_ops (the
  stack programs, the corpus generator), cli_conform_relay (the interop client, the corpus
  tools), cli_target (the old room service), cli_studio (the copy of fake-stack.py), and
  cli_conform's fake_python, which already wrote this way.
The scripts' bytes and what each test asserts are unchanged.

No retry is added. doctor::spawn already retries a spawn that fails with ETXTBSY (20 more tries,
10 ms apart) for the programs wai doctor and wai dev run, which a person can replace on disk
while they start; a_program_still_open_for_writing_is_tried_again holds it. The keychain's only
tool outside tests is /usr/bin/security on macOS, which nothing writes, so a retry there would be
code only a test reaches, and the tests no longer cause the error.

Measured on the Framework (Linux x86_64, 32 cores, shared), the release unit-test binary of wai
(--no-default-features --features quickstart, 172 tests), 8 copies at a time with --test-threads
32, each in its own TMPDIR, both trees at the same time:
  origin/main c4aa4e5d0: 8 of 3000 runs failed, each this test with ETXTBSY (5 of 1000 earlier)
  this change:           0 of 3000 (0 of 1000 earlier)
The binary's 172 tests and cli_doctor, cli_key, cli_ops, cli_project, cli_studio, cli_target,
cli_toolchain, cli_conform and cli_conform_relay pass on Linux and on macOS, the real keychain
test among them on macOS. clippy --all-targets (default features on macOS; full,key_discovery on
both) finds nothing new: the same findings before and after, none in a line changed here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:50:40 -05:00

1230 lines
66 KiB
Rust

//! `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<i32>, 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<Path>) -> Vec<u8> {
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 ─────────────────────────────
/// `<hex> <path>\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::<String>().as_bytes())
}
fn files_below(root: &Path, rel: &str, out: &mut Vec<String>) {
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<u8> {
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", "[email protected]", "-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<String> = 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::<Vec<_>>();
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<i32>, 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::<std::collections::BTreeSet<_>>(), ["$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::<Value>(&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::<Vec<_>>(), ["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<String> = 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::<Value>(&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::<Value>(&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);
}