diff --git a/.github/README.md b/.github/README.md new file mode 100644 index 0000000..fadf27d --- /dev/null +++ b/.github/README.md @@ -0,0 +1,5 @@ +# GitHub automation + +[Back to the repository README](../README.md). + +- [`workflows/`](workflows/) contains the maintained CI definition. diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..d443046 --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,6 @@ +# Workflows + +[Back to the repository README](../../README.md). + +`ci.yml` runs formatting, Clippy, unit and integration tests, package-content +checks, and a locked install smoke test for the `coatcheck` binary. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa1fa46..24917b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,4 +20,8 @@ jobs: - run: cargo fmt --check - run: cargo clippy --all-targets --all-features -- -D warnings - run: cargo test --all-targets --all-features - - run: cargo package + - run: cargo package --locked + - run: cargo package --list | grep -Fx Cargo.lock + - run: cargo package --list | grep -Fx templates/Coatcheck.toml + - run: cargo install --path . --locked --root target/install-smoke + - run: target/install-smoke/bin/coatcheck --help diff --git a/.gitignore b/.gitignore index 019647b..62495ba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target +/.coatcheck .DS_Store .env .env.* diff --git a/CHANGELOG.md b/CHANGELOG.md index 3312629..a13bab7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.2.0 + +- Add additive, Serde-compatible manifest-v1 and frame-v1 contracts. +- Add validated `Coatcheck.toml` host and test configuration. +- Ship an installable `coatcheck` binary with `init`, `doctor`, `dev`, and + `test` commands. +- Generate explicit Playwright and Axe cases from the live host manifest. +- Install project-local Bun dependencies and Playwright Chromium. +- Gate on semantic, browser-error, and accessibility checks. +- Write PNG, DOM, Axe, console, JSON, HTML, trace, and Markdown review + artifacts without pixel-baseline comparisons. + ## 0.1.0 - Define stories, typed args, and control kinds. diff --git a/Cargo.lock b/Cargo.lock index 6ce3a7e..e505635 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,1112 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "coatcheck" -version = "0.1.0" +version = "0.2.0" +dependencies = [ + "clap", + "serde", + "serde_json", + "tempfile", + "toml", + "ureq", + "url", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index f1f2236..923e9f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,10 @@ [package] name = "coatcheck" -version = "0.1.0" +version = "0.2.0" edition = "2024" license = "MIT" rust-version = "1.95" -description = "Story, prop, and scene contracts for Topcoat component workbenches." +description = "Contracts and a Playwright accessibility runner for Topcoat component workbenches." repository = "https://github.com/superposition/coatcheck" documentation = "https://docs.rs/coatcheck" readme = "README.md" @@ -12,12 +12,29 @@ keywords = ["topcoat", "storybook", "components", "testing", "ui"] categories = ["development-tools::testing", "web-programming"] publish = ["crates-io"] include = [ + "Cargo.lock", + "docs/**/*.md", + "src/**/*.md", "src/**/*.rs", + "templates/**/*", + "tests/**/*.md", + "tests/**/*.rs", "CHANGELOG.md", "LICENSE", "README.md", ] +[dependencies] +clap = { version = "4.5.51", features = ["derive"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" +toml = "0.9.8" +ureq = { version = "3.3.0", features = ["json"] } +url = { version = "2.5.7", features = ["serde"] } + +[dev-dependencies] +tempfile = "3.23.0" + [lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index 07546e6..e78accb 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,131 @@ # Coatcheck -Coatcheck is a component workbench platform for -[Topcoat](https://github.com/tokio-rs/topcoat). +Coatcheck is a component workbench contract and semantic browser-test runner +for [Topcoat](https://github.com/tokio-rs/topcoat). -It provides reusable contracts for Storybook-style Topcoat tooling: stories, -typed props, controls, scenes, catalog validation, viewports, and surfaces. A -Coatcheck application supplies its production components and exposes them in -an isolated canvas. +The library models stories, typed props, controls, scenes, manifests, and +isolated frames. The installable `coatcheck` binary turns a host manifest into +explicit Playwright and Axe tests. It produces screenshots for review, but it +does not store or compare pixel baselines. -Cold Front is the first reference application. Its components remain -game-specific; Coatcheck remains reusable. +## Install -## Status +After the 0.2 release is available from crates.io: + +```sh +cargo install coatcheck --locked +``` + +From a local checkout: + +```sh +cargo install --path . --locked +``` + +The crate package includes `Cargo.lock` and every runtime scaffold template, +so the locked install does not depend on repository-only files. + +## Start a project + +From the project that hosts the Topcoat workbench: + +```sh +coatcheck init --package-manager bun +``` + +`init` writes: + +- `Coatcheck.toml`; +- a private `package.json` when the project does not already have one; +- `coatcheck/example.spec.ts`; +- a `.coatcheck/` ignore entry; +- exact project-local `@playwright/test` and `@axe-core/playwright` + development dependencies; +- Playwright Chromium. + +It does not replace an existing JavaScript package manifest. Use +`--skip-install` when scaffolding offline. + +## Configure the host + +```toml +version = 1 +name = "Cold Front" + +[host] +command = ["topcoat", "dev"] +url = "http://127.0.0.1:3000" +health = "/health" +manifest = "/__coatcheck/manifest.json" +startup_timeout_seconds = 30 + +[test] +story_globs = ["coatcheck/**/*.spec.ts"] +artifacts = ".coatcheck/artifacts" +timeout_ms = 30000 +axe_impacts = ["serious", "critical"] +``` + +`host.command` is an argument array and is executed directly, without a shell. +The generated configuration uses `topcoat dev` so asset bundles and live reload +behave exactly as they do during application development. +This keeps quoting deterministic and makes the process boundary explicit. -Coatcheck is experimental and tracks Topcoat's early-stage development. The -`0.1` line establishes the public data model while the manager, story -discovery, CLI, and test runner are developed. +## Commands -## Define a catalog +```sh +coatcheck doctor +coatcheck doctor --fix +coatcheck dev +coatcheck test +coatcheck test --no-host +coatcheck test --headed +coatcheck test --headed --story route-option --slow-mo 150 +``` + +- `doctor` validates configuration, Bun, project-local Playwright and Axe, + Chromium, and reports whether the host is reachable. +- `doctor --fix` installs or repairs the JavaScript dependencies and Chromium. +- `dev` runs `host.command` in the foreground. +- `test` starts the host, waits for health, fetches the manifest, generates + explicit tests under `.coatcheck/generated`, runs generated and custom + specs, and stops the host. +- `test --no-host` uses an already-running host. + +The generated semantic gate checks navigation, frame root/readiness/error +markers, uncaught page and console errors, and the configured Axe impacts. +Each case writes a PNG, DOM, Axe JSON, and console JSON. Playwright also writes +HTML, JSON, and failure traces. See [the runner contract](docs/runner.md). + +## Host API + +Versioned JSON and frame types are additive to the 0.1 static catalog: + +```rust +use coatcheck::{ + Catalog, + contract::{FrameContractV1, ManifestV1}, +}; + +fn manifest(catalog: Catalog) -> ManifestV1 { + ManifestV1::from_catalog(catalog, FrameContractV1::default()) +} +``` + +The default host endpoints are: + +```text +GET /__coatcheck/manifest.json +GET /__coatcheck/frame?story=&scene= +``` + +The manifest owns the actual frame path, query names, and DOM selectors. +Detailed producer/consumer requirements are in +[the host contract](docs/contracts.md). + +## Static catalog API + +The allocation-free 0.1 API remains available: ```rust use coatcheck::{ArgType, Catalog, ControlKind, Story, StoryKind}; @@ -26,7 +134,7 @@ const LABEL: ArgType = ArgType { name: "label", type_name: "String", control: ControlKind::Text, - default_value: "Enter briefing", + default_value: "Continue", description: "Visible action copy.", options: &[], }; @@ -44,18 +152,25 @@ const STORIES: &[Story] = &[Story { const CATALOG: Catalog = Catalog::new("Example", STORIES); assert!(CATALOG.validate().is_ok()); -assert_eq!(CATALOG.story("action-link").unwrap().title, "Action link"); ``` -## Current guarantees +## Repository navigation + +Every tracked, human-maintained folder is linked here: + +- [`.github/`](.github/) — automation documentation. +- [`.github/workflows/`](.github/workflows/) — CI and locked-install smoke + checks. +- [`docs/`](docs/) — host and runner coordination contracts. +- [`src/`](src/) — library contracts, config, generation, and CLI. +- [`templates/`](templates/) — files embedded in the installable binary. +- [`tests/`](tests/) — process-level CLI integration tests. + +## Status -- Story IDs are unique. -- Prop names are unique within each story. -- Required story metadata is present. -- Choice controls declare at least one option. -- Catalog lookup does not require allocation. -- Story kinds distinguish playgrounds, named states, matrices, scenes, and docs. -- Default viewport, surface, and scene contracts are available. +Coatcheck is experimental and tracks Topcoat’s early-stage development. +Manifest and frame versions are explicit so future schemas can be added +without changing the 0.1 static story model. ## License diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..3da3c5e --- /dev/null +++ b/docs/README.md @@ -0,0 +1,8 @@ +# Documentation + +[Back to the repository README](../README.md). + +- [`contracts.md`](contracts.md) defines the manifest and frame producer/ + consumer boundary. +- [`runner.md`](runner.md) defines generation, semantic gates, custom specs, + and review artifacts. diff --git a/docs/contracts.md b/docs/contracts.md new file mode 100644 index 0000000..aa6a291 --- /dev/null +++ b/docs/contracts.md @@ -0,0 +1,51 @@ +# Host contracts + +Coatcheck 0.2 adds owned, Serde-compatible wire types without changing the +static 0.1 catalog API. + +## Manifest v1 + +The host serves `ManifestV1` as JSON at the `host.manifest` path from +`Coatcheck.toml` (default: `/__coatcheck/manifest.json`). + +Required coordination points: + +- `manifest.version` is `1`. +- `manifest.frame.version` is `1`. +- story and scene IDs are stable and unique. +- an empty `story.scenes` means every manifest scene. +- unknown JSON fields are accepted for additive metadata. +- the runner rejects unknown contract versions. + +Hosts can build the owned form with +`ManifestV1::from_catalog(catalog, FrameContractV1::default())`, then customize +scenes before serialization. + +## Frame v1 + +The default frame URL is: + +```text +/__coatcheck/frame?story=&scene= +``` + +The manifest carries the path, query names, and selectors, so a host may use +different values without changing the runner. + +The default DOM markers are: + +```html +
+ + +
+``` + +If rendering fails, the host adds an element matching +`[data-coatcheck-error]`. The ready marker means the component has settled +enough for semantic inspection; it does not promise that every animation has +ended. + +Coatcheck URLs story and scene query values before navigation. A host must +treat both values as untrusted input and return a visible error marker or a +non-success response for unknown IDs. diff --git a/docs/runner.md b/docs/runner.md new file mode 100644 index 0000000..d0d2215 --- /dev/null +++ b/docs/runner.md @@ -0,0 +1,29 @@ +# Runner + +`coatcheck test` is intentionally manifest-first: + +1. optionally start `host.command`; +2. wait for `host.health`; +3. fetch and validate `host.manifest`; +4. generate one explicit Playwright test per story/scene under + `.coatcheck/generated`; +5. run generated and project-local specs with Bun; +6. write semantic results and review artifacts. + +The generated gate checks successful navigation, the configured root and +ready markers, absence of the host error marker, uncaught page errors, browser +console errors, and configured Axe impacts. + +Every manifest case writes: + +- `review.png` for human review; +- `dom.html`; +- `axe.json`; +- `console.json`. + +The PNG is never compared with a pixel baseline. Pass/fail comes from semantic +and accessibility checks. Playwright traces are retained for failures. + +Custom Playwright specs are ordinary project files matching +`test.story_globs`. They use the project-local `@playwright/test` installed by +`coatcheck init --package-manager bun`. diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..19e449a --- /dev/null +++ b/src/README.md @@ -0,0 +1,9 @@ +# Source + +[Back to the repository README](../README.md). + +- `lib.rs` preserves the static catalog API and exports the 0.2 modules. +- `contract.rs` owns versioned manifest and frame wire types. +- `config.rs` parses and validates `Coatcheck.toml`. +- `runner.rs` fetches manifests and generates explicit Playwright cases. +- `main.rs` implements `init`, `doctor`, `dev`, and `test`. diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..c626121 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,297 @@ +//! Project configuration loaded from `Coatcheck.toml`. + +use std::error::Error; +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::contract::DEFAULT_MANIFEST_PATH; + +/// Current `Coatcheck.toml` schema version. +pub const CONFIG_VERSION: u16 = 1; + +/// Default project configuration file name. +pub const CONFIG_FILE: &str = "Coatcheck.toml"; + +/// Parsed version-1 project configuration. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Config { + /// Must equal [`CONFIG_VERSION`]. + pub version: u16, + /// Human-readable project name. + pub name: String, + /// Host process and endpoint settings. + pub host: HostConfig, + /// Playwright generation and gate settings. + #[serde(default)] + pub test: TestConfig, +} + +impl Config { + /// Loads and validates a configuration file. + /// + /// # Errors + /// + /// Returns a [`ConfigError`] when the file cannot be read, parsed, or + /// validated. + pub fn load(path: &Path) -> Result { + let source = fs::read_to_string(path).map_err(|source| ConfigError::Read { + path: path.to_path_buf(), + source, + })?; + let config: Self = toml::from_str(&source).map_err(ConfigError::Parse)?; + config.validate()?; + Ok(config) + } + + /// Validates command, URL, endpoint, and runner settings. + /// + /// # Errors + /// + /// Returns a [`ConfigError`] when a required value is missing or invalid. + pub fn validate(&self) -> Result<(), ConfigError> { + if self.version != CONFIG_VERSION { + return Err(ConfigError::UnsupportedVersion(self.version)); + } + if self.name.trim().is_empty() { + return Err(ConfigError::Invalid("name cannot be empty")); + } + if self.host.command.is_empty() + || self.host.command.iter().any(|part| part.trim().is_empty()) + { + return Err(ConfigError::Invalid( + "host.command must contain non-empty program arguments", + )); + } + + let base_url = Url::parse(&self.host.url) + .map_err(|_| ConfigError::Invalid("host.url must be an absolute HTTP(S) URL"))?; + if !matches!(base_url.scheme(), "http" | "https") || base_url.cannot_be_a_base() { + return Err(ConfigError::Invalid( + "host.url must be an absolute HTTP(S) URL", + )); + } + + for (name, value) in [ + ("host.health", self.host.health.as_str()), + ("host.manifest", self.host.manifest.as_str()), + ] { + if value.trim().is_empty() || !value.starts_with('/') { + return Err(ConfigError::Invalid(match name { + "host.health" => "host.health must start with /", + _ => "host.manifest must start with /", + })); + } + } + + if self.host.startup_timeout_seconds == 0 { + return Err(ConfigError::Invalid( + "host.startup_timeout_seconds must be greater than zero", + )); + } + if self.test.timeout_ms == 0 { + return Err(ConfigError::Invalid( + "test.timeout_ms must be greater than zero", + )); + } + if self + .test + .story_globs + .iter() + .any(|glob| glob.trim().is_empty()) + { + return Err(ConfigError::Invalid( + "test.story_globs cannot contain empty patterns", + )); + } + if self.test.artifacts.as_os_str().is_empty() { + return Err(ConfigError::Invalid("test.artifacts cannot be empty")); + } + if self.test.axe_impacts.is_empty() + || self.test.axe_impacts.iter().any(|impact| { + !matches!( + impact.as_str(), + "minor" | "moderate" | "serious" | "critical" + ) + }) + { + return Err(ConfigError::Invalid( + "test.axe_impacts must use minor, moderate, serious, or critical", + )); + } + + Ok(()) + } + + /// Resolves a host endpoint against the configured base URL. + /// + /// # Errors + /// + /// Returns a [`ConfigError`] when URL joining fails. + pub fn endpoint(&self, path: &str) -> Result { + let base = Url::parse(&self.host.url) + .map_err(|_| ConfigError::Invalid("host.url must be an absolute HTTP(S) URL"))?; + base.join(path) + .map_err(|_| ConfigError::Invalid("host endpoint path is invalid")) + } + + /// Resolves the artifact directory under a project root. + #[must_use] + pub fn artifacts_dir(&self, project_root: &Path) -> PathBuf { + project_root.join(&self.test.artifacts) + } +} + +/// Host process and endpoint configuration. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HostConfig { + /// Program followed by arguments, executed without a shell. + pub command: Vec, + /// Base URL served by the host. + pub url: String, + /// Health endpoint used before manifest fetching. + #[serde(default = "default_health")] + pub health: String, + /// Version-1 manifest endpoint. + #[serde(default = "default_manifest")] + pub manifest: String, + /// Maximum wait for the health endpoint. + #[serde(default = "default_startup_timeout")] + pub startup_timeout_seconds: u64, +} + +/// Playwright generation and semantic gate configuration. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct TestConfig { + /// Additional project-local Playwright spec globs. + pub story_globs: Vec, + /// Review artifact directory relative to the project. + pub artifacts: PathBuf, + /// Per-test timeout. + pub timeout_ms: u64, + /// Axe impacts that fail the semantic gate. + pub axe_impacts: Vec, +} + +impl Default for TestConfig { + fn default() -> Self { + Self { + story_globs: vec!["coatcheck/**/*.spec.ts".to_owned()], + artifacts: PathBuf::from(".coatcheck/artifacts"), + timeout_ms: 30_000, + axe_impacts: vec!["serious".to_owned(), "critical".to_owned()], + } + } +} + +fn default_health() -> String { + "/health".to_owned() +} + +fn default_manifest() -> String { + DEFAULT_MANIFEST_PATH.to_owned() +} + +const fn default_startup_timeout() -> u64 { + 30 +} + +/// Errors loading or validating `Coatcheck.toml`. +#[derive(Debug)] +pub enum ConfigError { + /// The configuration file could not be read. + Read { + /// Attempted path. + path: PathBuf, + /// Underlying I/O error. + source: std::io::Error, + }, + /// TOML parsing failed. + Parse(toml::de::Error), + /// The configuration schema version is unsupported. + UnsupportedVersion(u16), + /// A field failed semantic validation. + Invalid(&'static str), +} + +impl fmt::Display for ConfigError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Read { path, source } => { + write!(formatter, "could not read {}: {source}", path.display()) + } + Self::Parse(error) => write!(formatter, "invalid Coatcheck.toml: {error}"), + Self::UnsupportedVersion(version) => { + write!(formatter, "unsupported Coatcheck.toml version: {version}") + } + Self::Invalid(message) => formatter.write_str(message), + } + } +} + +impl Error for ConfigError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Read { source, .. } => Some(source), + Self::Parse(source) => Some(source), + Self::UnsupportedVersion(_) | Self::Invalid(_) => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::{Config, TestConfig}; + + #[test] + fn parses_a_cold_front_style_config() { + let config: Config = toml::from_str( + r#" +version = 1 +name = "Cold Front" + +[host] +command = ["cargo", "run", "-p", "coldfront-coatcheck"] +url = "http://127.0.0.1:3001" +health = "/health" +manifest = "/__coatcheck/manifest.json" + +[test] +story_globs = ["coatcheck/**/*.spec.ts"] +artifacts = ".coatcheck/artifacts" +timeout_ms = 30000 +axe_impacts = ["serious", "critical"] +"#, + ) + .unwrap(); + + assert_eq!(config.test, TestConfig::default()); + assert!(config.validate().is_ok()); + assert_eq!( + config.endpoint(&config.host.manifest).unwrap().as_str(), + "http://127.0.0.1:3001/__coatcheck/manifest.json" + ); + } + + #[test] + fn rejects_shell_strings_as_commands() { + let config: Config = toml::from_str( + r#" +version = 1 +name = "Example" +[host] +command = [] +url = "http://127.0.0.1:3000" +"#, + ) + .unwrap(); + + assert!(config.validate().is_err()); + } +} diff --git a/src/contract.rs b/src/contract.rs new file mode 100644 index 0000000..a96894a --- /dev/null +++ b/src/contract.rs @@ -0,0 +1,449 @@ +//! Versioned wire contracts shared by a Coatcheck host and runner. + +use std::collections::BTreeSet; +use std::error::Error; +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use crate::{Catalog, ControlKind, StoryKind}; + +/// Version 1 of the manifest contract. +pub const MANIFEST_VERSION_V1: u16 = 1; + +/// Version 1 of the frame contract. +pub const FRAME_VERSION_V1: u16 = 1; + +/// The default path at which a host serves its manifest. +pub const DEFAULT_MANIFEST_PATH: &str = "/__coatcheck/manifest.json"; + +/// The default path at which a host renders an isolated story frame. +pub const DEFAULT_FRAME_PATH: &str = "/__coatcheck/frame"; + +/// Version 1 of the JSON manifest returned by a Coatcheck host. +/// +/// Unknown JSON fields are intentionally accepted so producers can add +/// metadata without breaking a version-1 consumer. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ManifestV1 { + /// Must equal [`MANIFEST_VERSION_V1`]. + pub version: u16, + /// Human-readable catalog name. + pub name: String, + /// Contract used to construct and inspect isolated frame URLs. + pub frame: FrameContractV1, + /// Named rendering scenes available to stories. + #[serde(default)] + pub scenes: Vec, + /// Stories available to the runner. + #[serde(default)] + pub stories: Vec, +} + +impl ManifestV1 { + /// Creates a manifest from the allocation-free 0.1 catalog API. + #[must_use] + pub fn from_catalog(catalog: Catalog, frame: FrameContractV1) -> Self { + let stories = catalog.stories().iter().map(StoryV1::from_static).collect(); + + Self { + version: MANIFEST_VERSION_V1, + name: catalog.name().to_owned(), + frame, + scenes: vec![SceneV1::default()], + stories, + } + } + + /// Validates cross-record and frame invariants. + /// + /// # Errors + /// + /// Returns a [`ManifestError`] for unsupported versions, duplicate or + /// missing IDs, invalid dimensions, unknown scenes, or an invalid frame + /// contract. + pub fn validate(&self) -> Result<(), ManifestError> { + if self.version != MANIFEST_VERSION_V1 { + return Err(ManifestError::UnsupportedManifestVersion(self.version)); + } + if self.name.trim().is_empty() { + return Err(ManifestError::MissingName); + } + self.frame.validate()?; + + let mut scene_ids = BTreeSet::new(); + for scene in &self.scenes { + if scene.id.trim().is_empty() { + return Err(ManifestError::MissingSceneId); + } + if !scene_ids.insert(scene.id.as_str()) { + return Err(ManifestError::DuplicateSceneId(scene.id.clone())); + } + if scene.viewport.width == 0 || scene.viewport.height == 0 { + return Err(ManifestError::InvalidViewport(scene.id.clone())); + } + } + + let mut story_ids = BTreeSet::new(); + for story in &self.stories { + if story.id.trim().is_empty() { + return Err(ManifestError::MissingStoryId); + } + if !story_ids.insert(story.id.as_str()) { + return Err(ManifestError::DuplicateStoryId(story.id.clone())); + } + if story.title.trim().is_empty() { + return Err(ManifestError::MissingStoryTitle(story.id.clone())); + } + if story.source.trim().is_empty() { + return Err(ManifestError::MissingStorySource(story.id.clone())); + } + + let mut arg_names = BTreeSet::new(); + for arg in &story.args { + if !arg_names.insert(arg.name.as_str()) { + return Err(ManifestError::DuplicateArg { + story: story.id.clone(), + arg: arg.name.clone(), + }); + } + } + + for scene in &story.scenes { + if !scene_ids.contains(scene.as_str()) { + return Err(ManifestError::UnknownScene { + story: story.id.clone(), + scene: scene.clone(), + }); + } + } + } + + Ok(()) + } + + /// Expands the catalog into deterministic story/scene cases. + #[must_use] + pub fn cases(&self) -> Vec> { + self.stories + .iter() + .flat_map(|story| { + let scene_ids: Vec<&str> = if story.scenes.is_empty() { + self.scenes.iter().map(|scene| scene.id.as_str()).collect() + } else { + story.scenes.iter().map(String::as_str).collect() + }; + + scene_ids.into_iter().map(move |scene_id| CaseV1 { + story, + scene: self.scenes.iter().find(|scene| scene.id == scene_id), + }) + }) + .collect() + } +} + +/// DOM and URL conventions for version-1 isolated frames. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FrameContractV1 { + /// Must equal [`FRAME_VERSION_V1`]. + pub version: u16, + /// Path or absolute URL for the isolated frame. + pub path: String, + /// Query parameter containing the story ID. + pub story_query: String, + /// Query parameter containing the optional scene ID. + pub scene_query: String, + /// Selector for the isolated component root. + pub root_selector: String, + /// Selector that appears when rendering has settled. + pub ready_selector: String, + /// Selector that appears when the host reports a rendering error. + pub error_selector: String, +} + +impl Default for FrameContractV1 { + fn default() -> Self { + Self { + version: FRAME_VERSION_V1, + path: DEFAULT_FRAME_PATH.to_owned(), + story_query: "story".to_owned(), + scene_query: "scene".to_owned(), + root_selector: "[data-coatcheck-frame]".to_owned(), + ready_selector: "[data-coatcheck-ready]".to_owned(), + error_selector: "[data-coatcheck-error]".to_owned(), + } + } +} + +impl FrameContractV1 { + fn validate(&self) -> Result<(), ManifestError> { + if self.version != FRAME_VERSION_V1 { + return Err(ManifestError::UnsupportedFrameVersion(self.version)); + } + + for (field, value) in [ + ("path", self.path.as_str()), + ("story_query", self.story_query.as_str()), + ("scene_query", self.scene_query.as_str()), + ("root_selector", self.root_selector.as_str()), + ("ready_selector", self.ready_selector.as_str()), + ("error_selector", self.error_selector.as_str()), + ] { + if value.trim().is_empty() { + return Err(ManifestError::MissingFrameField(field)); + } + } + + Ok(()) + } +} + +/// One story in a version-1 manifest. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct StoryV1 { + /// Stable URL-safe story ID. + pub id: String, + /// Human-readable story title. + pub title: String, + /// Atomic or product hierarchy group. + pub tier: String, + /// Stable story kind label. + pub kind: String, + /// What the story proves. + #[serde(default)] + pub description: String, + /// Human-maintained source path. + pub source: String, + /// Editable and documented arguments. + #[serde(default)] + pub args: Vec, + /// Scene IDs to test. Empty means every manifest scene. + #[serde(default)] + pub scenes: Vec, +} + +impl StoryV1 { + fn from_static(story: &crate::Story) -> Self { + Self { + id: story.id.to_owned(), + title: story.title.to_owned(), + tier: story.tier.to_owned(), + kind: story.kind.label().to_owned(), + description: story.description.to_owned(), + source: story.source.to_owned(), + args: story + .args + .iter() + .map(|arg| ArgV1 { + name: arg.name.to_owned(), + type_name: arg.type_name.to_owned(), + control: arg.control.label().to_owned(), + default_value: arg.default_value.to_owned(), + description: arg.description.to_owned(), + options: arg.options.iter().map(ToString::to_string).collect(), + }) + .collect(), + scenes: Vec::new(), + } + } +} + +/// One owned argument record in a version-1 manifest. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ArgV1 { + /// Stable argument name. + pub name: String, + /// Human-readable Rust type. + pub type_name: String, + /// Stable [`ControlKind::label`] value. + pub control: String, + /// Serialized default value. + pub default_value: String, + /// Short behavior description. + #[serde(default)] + pub description: String, + /// Allowed choice values. + #[serde(default)] + pub options: Vec, +} + +/// One named viewport and surface combination. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SceneV1 { + /// Stable scene ID. + pub id: String, + /// Human-readable scene label. + pub label: String, + /// Browser viewport for the case. + pub viewport: ViewportV1, + /// Consumer-defined surface token. + pub surface: String, +} + +impl Default for SceneV1 { + fn default() -> Self { + Self { + id: "default".to_owned(), + label: "Default".to_owned(), + viewport: ViewportV1 { + width: 1280, + height: 900, + }, + surface: "canvas".to_owned(), + } + } +} + +/// A browser viewport in CSS pixels. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ViewportV1 { + /// Width in CSS pixels. + pub width: u16, + /// Height in CSS pixels. + pub height: u16, +} + +/// One expanded manifest case. +#[derive(Clone, Copy, Debug)] +pub struct CaseV1<'a> { + /// Story under test. + pub story: &'a StoryV1, + /// Scene under test, or `None` when the manifest declares no scenes. + pub scene: Option<&'a SceneV1>, +} + +/// Validation errors for a version-1 manifest. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ManifestError { + /// The manifest version is not supported. + UnsupportedManifestVersion(u16), + /// The frame version is not supported. + UnsupportedFrameVersion(u16), + /// The catalog has no name. + MissingName, + /// A required frame field is empty. + MissingFrameField(&'static str), + /// A scene has no ID. + MissingSceneId, + /// Two scenes share an ID. + DuplicateSceneId(String), + /// A scene has a zero viewport dimension. + InvalidViewport(String), + /// A story has no ID. + MissingStoryId, + /// Two stories share an ID. + DuplicateStoryId(String), + /// A story has no title. + MissingStoryTitle(String), + /// A story has no source. + MissingStorySource(String), + /// Two arguments in one story share a name. + DuplicateArg { + /// Story containing the duplicate. + story: String, + /// Duplicate argument name. + arg: String, + }, + /// A story references an unknown scene. + UnknownScene { + /// Story containing the reference. + story: String, + /// Unknown scene ID. + scene: String, + }, +} + +impl fmt::Display for ManifestError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedManifestVersion(version) => { + write!(formatter, "unsupported manifest version: {version}") + } + Self::UnsupportedFrameVersion(version) => { + write!(formatter, "unsupported frame version: {version}") + } + Self::MissingName => formatter.write_str("manifest name cannot be empty"), + Self::MissingFrameField(field) => write!(formatter, "frame field {field} is empty"), + Self::MissingSceneId => formatter.write_str("scene ID cannot be empty"), + Self::DuplicateSceneId(id) => write!(formatter, "duplicate scene ID: {id}"), + Self::InvalidViewport(id) => write!(formatter, "scene {id} has an invalid viewport"), + Self::MissingStoryId => formatter.write_str("story ID cannot be empty"), + Self::DuplicateStoryId(id) => write!(formatter, "duplicate story ID: {id}"), + Self::MissingStoryTitle(id) => write!(formatter, "story {id} has no title"), + Self::MissingStorySource(id) => write!(formatter, "story {id} has no source"), + Self::DuplicateArg { story, arg } => { + write!(formatter, "duplicate argument {arg} in story {story}") + } + Self::UnknownScene { story, scene } => { + write!(formatter, "story {story} references unknown scene {scene}") + } + } + } +} + +impl Error for ManifestError {} + +const _: fn(StoryKind) -> &'static str = StoryKind::label; +const _: fn(ControlKind) -> &'static str = ControlKind::label; + +#[cfg(test)] +mod tests { + use super::{FrameContractV1, ManifestError, ManifestV1, SceneV1, StoryV1, ViewportV1}; + + fn manifest() -> ManifestV1 { + ManifestV1 { + version: 1, + name: "Example".to_owned(), + frame: FrameContractV1::default(), + scenes: vec![SceneV1 { + id: "mobile".to_owned(), + label: "Mobile".to_owned(), + viewport: ViewportV1 { + width: 393, + height: 852, + }, + surface: "canvas".to_owned(), + }], + stories: vec![StoryV1 { + id: "button".to_owned(), + title: "Button".to_owned(), + tier: "Atoms".to_owned(), + kind: "playground".to_owned(), + description: String::new(), + source: "src/button.rs".to_owned(), + args: Vec::new(), + scenes: vec!["mobile".to_owned()], + }], + } + } + + #[test] + fn round_trips_json_and_expands_cases() { + let json = serde_json::to_string(&manifest()).unwrap(); + let decoded: ManifestV1 = serde_json::from_str(&json).unwrap(); + + assert_eq!(decoded.validate(), Ok(())); + let cases = decoded.cases(); + assert_eq!(cases.len(), 1); + assert_eq!(cases[0].story.id, "button"); + assert_eq!( + cases[0].scene.map(|scene| scene.id.as_str()), + Some("mobile") + ); + } + + #[test] + fn rejects_unknown_scenes() { + let mut invalid = manifest(); + invalid.stories[0].scenes = vec!["desktop".to_owned()]; + + assert_eq!( + invalid.validate(), + Err(ManifestError::UnknownScene { + story: "button".to_owned(), + scene: "desktop".to_owned(), + }) + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 0f86bda..8380d3d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,10 @@ #![doc = include_str!("../README.md")] #![forbid(unsafe_code)] +pub mod config; +pub mod contract; +pub mod runner; + use std::collections::BTreeSet; use std::error::Error; use std::fmt; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..ec7cc3e --- /dev/null +++ b/src/main.rs @@ -0,0 +1,516 @@ +#![forbid(unsafe_code)] + +use std::error::Error; +use std::ffi::OsStr; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +use clap::{Parser, Subcommand, ValueEnum}; +use coatcheck::config::{CONFIG_FILE, Config}; +use coatcheck::runner::{fetch_manifest, finish_review, generate}; + +const PLAYWRIGHT_PACKAGE: &str = "@playwright/test@1.61.1"; +const AXE_PACKAGE: &str = "@axe-core/playwright@4.12.1"; +const CONFIG_TEMPLATE: &str = include_str!("../templates/Coatcheck.toml"); +const PACKAGE_TEMPLATE: &str = include_str!("../templates/package.json"); +const CUSTOM_SPEC_TEMPLATE: &str = include_str!("../templates/custom.spec.ts"); + +#[derive(Debug, Parser)] +#[command( + name = "coatcheck", + version, + about = "Semantic component checks for Topcoat workbenches" +)] +struct Cli { + /// Project directory containing Coatcheck.toml. + #[arg(long, global = true, default_value = ".")] + project_root: PathBuf, + + #[command(subcommand)] + command: Commands, +} + +#[derive(Debug, Subcommand)] +enum Commands { + /// Scaffold Coatcheck.toml and the project-local Bun/Playwright runner. + Init { + /// JavaScript package manager used for the runner. + #[arg(long, value_enum, default_value_t = PackageManager::Bun)] + package_manager: PackageManager, + /// Replace Coatcheck.toml if it already exists. + #[arg(long)] + force: bool, + /// Write files without installing JavaScript dependencies or Chromium. + #[arg(long)] + skip_install: bool, + }, + /// Check configuration, Bun, Playwright, Axe, Chromium, and host reachability. + Doctor { + /// Install missing project-local runner dependencies and Chromium. + #[arg(long)] + fix: bool, + }, + /// Run the configured host command in the foreground. + Dev, + /// Fetch the host manifest, generate explicit cases, and run Playwright. + Test { + /// Use an already-running host instead of starting host.command. + #[arg(long)] + no_host: bool, + /// Show Chromium while tests execute. + #[arg(long)] + headed: bool, + /// Run one exact story ID from the manifest. + #[arg(long)] + story: Option, + /// Slow browser operations for visual inspection, in milliseconds. + #[arg(long)] + slow_mo: Option, + }, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +enum PackageManager { + Bun, +} + +fn main() { + if let Err(error) = run() { + eprintln!("coatcheck: {error}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), Box> { + let cli = Cli::parse(); + let project_root = absolute_project_root(&cli.project_root)?; + + match cli.command { + Commands::Init { + package_manager, + force, + skip_install, + } => init(&project_root, package_manager, force, skip_install), + Commands::Doctor { fix } => doctor(&project_root, fix), + Commands::Dev => dev(&project_root), + Commands::Test { + no_host, + headed, + story, + slow_mo, + } => test(&project_root, no_host, headed, story.as_deref(), slow_mo), + } +} + +fn init( + project_root: &Path, + package_manager: PackageManager, + force: bool, + skip_install: bool, +) -> Result<(), Box> { + let config_path = project_root.join(CONFIG_FILE); + if config_path.exists() && !force { + return Err(message(format!( + "{} already exists; pass --force to replace it", + config_path.display() + ))); + } + + let project_name = project_root + .file_name() + .and_then(OsStr::to_str) + .unwrap_or("coatcheck-project"); + let encoded_name = serde_json::to_string(project_name)?; + let rendered_config = CONFIG_TEMPLATE.replace("{{project_name}}", &encoded_name); + fs::write(&config_path, rendered_config)?; + write_if_missing(&project_root.join("package.json"), PACKAGE_TEMPLATE)?; + write_if_missing( + &project_root.join("coatcheck/example.spec.ts"), + CUSTOM_SPEC_TEMPLATE, + )?; + ensure_gitignore(project_root)?; + + println!("created {}", config_path.display()); + println!("created project-local Coatcheck spec scaffold"); + + if skip_install { + println!("skipped Bun and Chromium installation"); + } else { + install_runner(project_root, package_manager)?; + } + + Ok(()) +} + +fn doctor(project_root: &Path, fix: bool) -> Result<(), Box> { + let config = load_config(project_root)?; + println!("ok {}", project_root.join(CONFIG_FILE).display()); + + if fix { + write_if_missing(&project_root.join("package.json"), PACKAGE_TEMPLATE)?; + install_runner(project_root, PackageManager::Bun)?; + } + + check_command(project_root, "bun", &["--version"], "Bun")?; + check_command( + project_root, + "bun", + &[ + "-e", + "await import('@playwright/test'); await import('@axe-core/playwright')", + ], + "project-local Playwright and Axe", + )?; + + let chromium_path = command_stdout( + project_root, + "bun", + &[ + "-e", + "import { chromium } from '@playwright/test'; console.log(chromium.executablePath())", + ], + )?; + let chromium_path = PathBuf::from(chromium_path.trim()); + if !chromium_path.is_file() { + return Err(message(format!( + "Chromium is not installed at {}; run `coatcheck doctor --fix`", + chromium_path.display() + ))); + } + println!("ok Chromium {}", chromium_path.display()); + + let health_url = config.endpoint(&config.host.health)?; + match ureq::get(health_url.as_str()).call() { + Ok(response) => println!("ok host health {} {}", response.status(), health_url), + Err(error) => { + println!("warn host is not reachable at {health_url}: {error} (run `coatcheck dev`)"); + } + } + + println!("doctor passed"); + Ok(()) +} + +fn dev(project_root: &Path) -> Result<(), Box> { + let config = load_config(project_root)?; + println!("running {}", display_command(&config.host.command)); + let status = host_command(project_root, &config)? + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status()?; + require_success(status, "host command") +} + +fn test( + project_root: &Path, + no_host: bool, + headed: bool, + story: Option<&str>, + slow_mo: Option, +) -> Result<(), Box> { + let config = load_config(project_root)?; + check_command( + project_root, + "bun", + &[ + "-e", + "await import('@playwright/test'); await import('@axe-core/playwright')", + ], + "project-local Playwright and Axe", + )?; + + let health_url = config.endpoint(&config.host.health)?; + let host_is_ready = ureq::get(health_url.as_str()).call().is_ok(); + let mut host = if no_host || host_is_ready { + if host_is_ready { + println!("reusing host at {health_url}"); + } + None + } else { + println!("starting {}", display_command(&config.host.command)); + Some(HostGuard::spawn(project_root, &config)?) + }; + + wait_for_health(&config)?; + let manifest_url = config.endpoint(&config.host.manifest)?; + let mut manifest = fetch_manifest(&manifest_url)?; + if let Some(story) = story { + manifest.stories.retain(|candidate| candidate.id == story); + if manifest.stories.is_empty() { + return Err(message(format!("story not found in manifest: {story}"))); + } + } + let generated = generate(project_root, &config, &manifest)?; + println!( + "generated {} manifest cases in {}", + generated.case_count, + generated.directory.display() + ); + + let mut command = Command::new("bunx"); + command + .current_dir(project_root) + .arg("playwright") + .arg("test") + .arg("--config") + .arg(&generated.config) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + if headed { + command.env("COATCHECK_HEADED", "1"); + } + if let Some(slow_mo) = slow_mo { + command.env("COATCHECK_SLOW_MO", slow_mo.to_string()); + } + let status = command.status()?; + let success = status.success(); + finish_review(&generated, &manifest, success)?; + + if let Some(host) = host.as_mut() { + host.stop(); + } + + if success { + println!("semantic gates passed"); + println!("review artifacts: {}", generated.review.display()); + Ok(()) + } else { + Err(message(format!( + "semantic gates failed; review {}", + generated.review.display() + ))) + } +} + +fn load_config(project_root: &Path) -> Result> { + Ok(Config::load(&project_root.join(CONFIG_FILE))?) +} + +fn install_runner( + project_root: &Path, + package_manager: PackageManager, +) -> Result<(), Box> { + match package_manager { + PackageManager::Bun => { + println!("installing {PLAYWRIGHT_PACKAGE} and {AXE_PACKAGE}"); + let status = Command::new("bun") + .current_dir(project_root) + .args(["add", "--dev", "--exact", PLAYWRIGHT_PACKAGE, AXE_PACKAGE]) + .status()?; + require_success(status, "Bun dependency installation")?; + + println!("installing Playwright Chromium"); + let status = Command::new("bunx") + .current_dir(project_root) + .args(["playwright", "install", "chromium"]) + .status()?; + require_success(status, "Chromium installation")?; + } + } + Ok(()) +} + +fn wait_for_health(config: &Config) -> Result<(), Box> { + let health_url = config.endpoint(&config.host.health)?; + let deadline = Instant::now() + Duration::from_secs(config.host.startup_timeout_seconds); + let mut last_error = String::new(); + + while Instant::now() < deadline { + match ureq::get(health_url.as_str()).call() { + Ok(_) => { + println!("host ready at {health_url}"); + return Ok(()); + } + Err(error) => last_error = error.to_string(), + } + thread::sleep(Duration::from_millis(200)); + } + + Err(message(format!( + "host did not become ready at {health_url}: {last_error}" + ))) +} + +fn host_command(project_root: &Path, config: &Config) -> Result> { + let (program, args) = config + .host + .command + .split_first() + .ok_or_else(|| message("host.command is empty"))?; + let mut command = Command::new(program); + command.current_dir(project_root).args(args); + Ok(command) +} + +fn check_command( + project_root: &Path, + program: &str, + args: &[&str], + label: &str, +) -> Result<(), Box> { + let status = Command::new(program) + .current_dir(project_root) + .args(args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map_err(|error| message(format!("{label} is unavailable: {error}")))?; + if !status.success() { + return Err(message(format!( + "{label} check failed; run `coatcheck doctor --fix`" + ))); + } + println!("ok {label}"); + Ok(()) +} + +fn command_stdout( + project_root: &Path, + program: &str, + args: &[&str], +) -> Result> { + let output = Command::new(program) + .current_dir(project_root) + .args(args) + .output()?; + if !output.status.success() { + return Err(message(format!( + "{program} command failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + Ok(String::from_utf8(output.stdout)?) +} + +fn require_success(status: ExitStatus, label: &str) -> Result<(), Box> { + if status.success() { + Ok(()) + } else { + Err(message(format!("{label} exited with {status}"))) + } +} + +fn write_if_missing(path: &Path, contents: &str) -> Result<(), Box> { + if path.exists() { + return Ok(()); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, contents)?; + println!("created {}", path.display()); + Ok(()) +} + +fn ensure_gitignore(project_root: &Path) -> Result<(), Box> { + let path = project_root.join(".gitignore"); + let mut contents = fs::read_to_string(&path).unwrap_or_default(); + if contents.lines().any(|line| line.trim() == ".coatcheck/") { + return Ok(()); + } + if !contents.is_empty() && !contents.ends_with('\n') { + contents.push('\n'); + } + contents.push_str(".coatcheck/\n"); + fs::write(path, contents)?; + Ok(()) +} + +fn absolute_project_root(path: &Path) -> Result> { + let path = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir()?.join(path) + }; + if !path.is_dir() { + return Err(message(format!( + "project root does not exist: {}", + path.display() + ))); + } + Ok(path.canonicalize()?) +} + +fn display_command(command: &[String]) -> String { + command + .iter() + .map(|part| { + if part.contains(char::is_whitespace) { + format!("{part:?}") + } else { + part.clone() + } + }) + .collect::>() + .join(" ") +} + +fn message(message: impl Into) -> Box { + Box::new(io::Error::other(message.into())) +} + +struct HostGuard { + child: Child, + stopped: bool, +} + +impl HostGuard { + fn spawn(project_root: &Path, config: &Config) -> Result> { + let mut command = host_command(project_root, config)?; + command + .stdin(Stdio::null()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + Ok(Self { + child: command.spawn()?, + stopped: false, + }) + } + + fn stop(&mut self) { + if self.stopped { + return; + } + self.stopped = true; + + #[cfg(unix)] + { + let group = format!("-{}", self.child.id()); + let _ = Command::new("kill") + .args(["-TERM", group.as_str()]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } + #[cfg(not(unix))] + { + let _ = self.child.kill(); + } + + for _ in 0..10 { + if matches!(self.child.try_wait(), Ok(Some(_))) { + return; + } + thread::sleep(Duration::from_millis(50)); + } + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +impl Drop for HostGuard { + fn drop(&mut self) { + self.stop(); + } +} diff --git a/src/runner.rs b/src/runner.rs new file mode 100644 index 0000000..2109083 --- /dev/null +++ b/src/runner.rs @@ -0,0 +1,550 @@ +//! Deterministic Playwright generation from a version-1 host manifest. + +use std::error::Error; +use std::fmt; +use std::fmt::Write as _; +use std::fs; +use std::path::{Path, PathBuf}; + +use url::Url; + +use crate::config::Config; +use crate::contract::{CaseV1, ManifestError, ManifestV1}; + +/// Generated files used by `coatcheck test`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GeneratedRunner { + /// Generated directory under `.coatcheck`. + pub directory: PathBuf, + /// Playwright configuration file. + pub config: PathBuf, + /// Explicit tests generated from the fetched manifest. + pub manifest_spec: PathBuf, + /// Human-readable review index. + pub review: PathBuf, + /// Number of generated manifest cases. + pub case_count: usize, +} + +/// Fetches and validates a manifest from a running Coatcheck host. +/// +/// # Errors +/// +/// Returns a [`RunnerError`] for transport, response, JSON, or contract +/// failures. +pub fn fetch_manifest(url: &Url) -> Result { + let mut response = ureq::get(url.as_str()) + .call() + .map_err(|source| RunnerError::Fetch { + url: url.clone(), + source: Box::new(source), + })?; + let manifest: ManifestV1 = + response + .body_mut() + .read_json() + .map_err(|source| RunnerError::Fetch { + url: url.clone(), + source: Box::new(source), + })?; + manifest.validate().map_err(RunnerError::Manifest)?; + Ok(manifest) +} + +/// Generates explicit Playwright cases and configuration under +/// `.coatcheck/generated`. +/// +/// # Errors +/// +/// Returns a [`RunnerError`] when the manifest has no cases, a frame URL +/// cannot be constructed, or generated files cannot be written. +pub fn generate( + project_root: &Path, + config: &Config, + manifest: &ManifestV1, +) -> Result { + manifest.validate().map_err(RunnerError::Manifest)?; + let cases = manifest.cases(); + if cases.is_empty() { + return Err(RunnerError::NoCases); + } + + let directory = project_root.join(".coatcheck/generated"); + fs::create_dir_all(&directory).map_err(|source| RunnerError::Write { + path: directory.clone(), + source, + })?; + + let config_path = directory.join("playwright.config.ts"); + let spec_path = directory.join("manifest.spec.ts"); + let review_path = config.artifacts_dir(project_root).join("review.md"); + + let playwright = render_playwright_config(project_root, config, &spec_path)?; + let spec = render_manifest_spec(config, manifest, &cases)?; + write(&config_path, &playwright)?; + write(&spec_path, &spec)?; + write_review(&review_path, manifest, &cases, None)?; + + Ok(GeneratedRunner { + directory, + config: config_path, + manifest_spec: spec_path, + review: review_path, + case_count: cases.len(), + }) +} + +/// Rewrites the review index with the final Playwright exit status. +/// +/// # Errors +/// +/// Returns a [`RunnerError`] when the review file cannot be written. +pub fn finish_review( + generated: &GeneratedRunner, + manifest: &ManifestV1, + success: bool, +) -> Result<(), RunnerError> { + write_review( + &generated.review, + manifest, + &manifest.cases(), + Some(success), + ) +} + +fn render_playwright_config( + project_root: &Path, + config: &Config, + generated_spec: &Path, +) -> Result { + let generated_relative = generated_spec + .strip_prefix(project_root) + .map_err(|_| RunnerError::InvalidPath(generated_spec.to_path_buf()))?; + let artifacts = config.artifacts_dir(project_root); + let matches = std::iter::once(path_string(generated_relative)) + .chain(config.test.story_globs.iter().cloned()) + .collect::>(); + + let root_json = json(&path_string(project_root))?; + let matches_json = json(&matches)?; + let artifacts_json = json(&path_string(&artifacts))?; + + Ok(format!( + r#"import {{ defineConfig }} from "@playwright/test"; + +export default defineConfig({{ + testDir: {root_json}, + testMatch: {matches_json}, + timeout: {timeout}, + fullyParallel: false, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [ + ["list"], + ["html", {{ outputFolder: {artifacts_json} + "/playwright-report", open: "never" }}], + ["json", {{ outputFile: {artifacts_json} + "/results.json" }}], + ], + outputDir: {artifacts_json} + "/test-results", + use: {{ + browserName: "chromium", + headless: process.env.COATCHECK_HEADED !== "1", + launchOptions: {{ + slowMo: Number(process.env.COATCHECK_SLOW_MO ?? 0), + }}, + trace: "retain-on-failure", + screenshot: "off", + video: "off", + }}, +}}); +"#, + timeout = config.test.timeout_ms, + )) +} + +#[allow(clippy::too_many_lines)] +fn render_manifest_spec( + config: &Config, + manifest: &ManifestV1, + cases: &[CaseV1<'_>], +) -> Result { + let mut output = String::from( + r#"import fs from "node:fs/promises"; +import path from "node:path"; +import AxeBuilder from "@axe-core/playwright"; +import { expect, test } from "@playwright/test"; + +"#, + ); + + let artifacts = path_string(&config.test.artifacts); + writeln!(output, "const artifactsRoot = {};", json(&artifacts)?).unwrap(); + writeln!( + output, + "const axeImpacts = new Set({});", + json(&config.test.axe_impacts)? + ) + .unwrap(); + writeln!( + output, + "const rootSelector = {};", + json(&manifest.frame.root_selector)? + ) + .unwrap(); + writeln!( + output, + "const readySelector = {};", + json(&manifest.frame.ready_selector)? + ) + .unwrap(); + writeln!( + output, + "const errorSelector = {};\n", + json(&manifest.frame.error_selector)? + ) + .unwrap(); + + output.push_str( + r"type Case = { + title: string; + url: string; + artifact: string; + viewport: { width: number; height: number } | null; +}; + +const cases: Case[] = [ +", + ); + + let base = Url::parse(&config.host.url).map_err(|_| RunnerError::InvalidHostUrl)?; + for case in cases { + let frame_url = frame_url(&base, manifest, case)?; + let scene_id = case.scene.map_or("default", |scene| scene.id.as_str()); + let title = format!("{} [{}]", case.story.title, scene_id); + let artifact = format!("{}--{}", slug(&case.story.id), slug(scene_id)); + let viewport = case.scene.map(|scene| { + serde_json::json!({ + "width": scene.viewport.width, + "height": scene.viewport.height, + }) + }); + + writeln!( + output, + " {{ title: {}, url: {}, artifact: {}, viewport: {} }},", + json(&title)?, + json(frame_url.as_str())?, + json(&artifact)?, + serde_json::to_string(&viewport).map_err(RunnerError::Json)?, + ) + .unwrap(); + } + + output.push_str( + r#"]; + +for (const item of cases) { + test(item.title, async ({ page }) => { + if (item.viewport) { + await page.setViewportSize(item.viewport); + } + + const consoleErrors: string[] = []; + const pageErrors: string[] = []; + page.on("console", (message) => { + if (message.type() === "error") consoleErrors.push(message.text()); + }); + page.on("pageerror", (error) => pageErrors.push(error.message)); + + let navigationError: string | null = null; + let status: number | null = null; + try { + const response = await page.goto(item.url, { waitUntil: "domcontentloaded" }); + status = response?.status() ?? null; + if (!response?.ok()) navigationError = `frame returned ${status ?? "no response"}`; + } catch (error) { + navigationError = String(error); + } + + let readyError: string | null = null; + try { + await page.locator(readySelector).first().waitFor({ state: "attached" }); + } catch (error) { + readyError = String(error); + } + + const rootCount = await page.locator(rootSelector).count(); + const hostErrorCount = await page.locator(errorSelector).count(); + const hasHorizontalOverflow = await page.evaluate( + () => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1, + ); + let axeError: string | null = null; + let screenshotError: string | null = null; + let axe = { violations: [] as Array<{ id: string; impact?: string | null; help: string; nodes: unknown[] }> }; + try { + axe = await new AxeBuilder({ page }).analyze(); + } catch (error) { + axeError = String(error); + } + const blockingAxe = axe.violations.filter( + (violation) => violation.impact && axeImpacts.has(violation.impact), + ); + + const artifactDir = path.join(artifactsRoot, "cases", item.artifact); + await fs.mkdir(artifactDir, { recursive: true }); + await page + .screenshot({ path: path.join(artifactDir, "review.png"), fullPage: true }) + .catch((error) => { screenshotError = String(error); }); + await Promise.all([ + fs.writeFile(path.join(artifactDir, "axe.json"), JSON.stringify(axe, null, 2)), + fs.writeFile( + path.join(artifactDir, "console.json"), + JSON.stringify( + { consoleErrors, pageErrors, navigationError, readyError, axeError, screenshotError, status }, + null, + 2, + ), + ), + fs.writeFile(path.join(artifactDir, "dom.html"), await page.content()), + ]); + + expect.soft(navigationError, "frame navigation").toBeNull(); + expect.soft(readyError, "frame readiness marker").toBeNull(); + expect.soft(rootCount, "frame root marker").toBeGreaterThan(0); + expect.soft(hostErrorCount, "host error marker").toBe(0); + expect.soft(hasHorizontalOverflow, "horizontal viewport overflow").toBe(false); + expect.soft(pageErrors, "uncaught page errors").toEqual([]); + expect.soft(consoleErrors, "browser console errors").toEqual([]); + expect.soft(axeError, "axe execution").toBeNull(); + expect.soft(screenshotError, "review screenshot capture").toBeNull(); + expect.soft( + blockingAxe.map(({ id, impact, help }) => ({ id, impact, help })), + "configured axe impact gate", + ).toEqual([]); + }); +} +"#, + ); + + Ok(output) +} + +fn frame_url(base: &Url, manifest: &ManifestV1, case: &CaseV1<'_>) -> Result { + let mut url = match Url::parse(&manifest.frame.path) { + Ok(url) => url, + Err(url::ParseError::RelativeUrlWithoutBase) => base + .join(&manifest.frame.path) + .map_err(|_| RunnerError::InvalidFrameUrl(manifest.frame.path.clone()))?, + Err(_) => return Err(RunnerError::InvalidFrameUrl(manifest.frame.path.clone())), + }; + { + let mut query = url.query_pairs_mut(); + query.append_pair(&manifest.frame.story_query, &case.story.id); + if let Some(scene) = case.scene { + query.append_pair(&manifest.frame.scene_query, &scene.id); + } + } + Ok(url) +} + +fn write_review( + path: &Path, + manifest: &ManifestV1, + cases: &[CaseV1<'_>], + success: Option, +) -> Result<(), RunnerError> { + let mut review = format!( + "# Coatcheck review\n\nCatalog: **{}**\n\nSemantic result: **{}**\n\n", + manifest.name, + match success { + Some(true) => "passed", + Some(false) => "failed", + None => "pending", + } + ); + review.push_str( + "The test gate uses navigation, frame markers, browser errors, and Axe. PNG files are review artifacts only; Coatcheck does not compare pixel baselines.\n\n## Cases\n\n", + ); + + for case in cases { + let scene_id = case.scene.map_or("default", |scene| scene.id.as_str()); + let artifact = format!("{}--{}", slug(&case.story.id), slug(scene_id)); + writeln!( + review, + "- `{}` / `{}`: [image](cases/{artifact}/review.png), [DOM](cases/{artifact}/dom.html), [Axe](cases/{artifact}/axe.json), [console](cases/{artifact}/console.json)", + case.story.id, scene_id + ) + .unwrap(); + } + + review.push_str( + "\n## Runner output\n\n- [Playwright HTML report](playwright-report/index.html)\n- [Machine-readable results](results.json)\n", + ); + write(path, &review) +} + +fn write(path: &Path, contents: &str) -> Result<(), RunnerError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|source| RunnerError::Write { + path: parent.to_path_buf(), + source, + })?; + } + fs::write(path, contents).map_err(|source| RunnerError::Write { + path: path.to_path_buf(), + source, + }) +} + +fn json(value: &(impl serde::Serialize + ?Sized)) -> Result { + serde_json::to_string(value).map_err(RunnerError::Json) +} + +fn path_string(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn slug(value: &str) -> String { + let slug = value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') { + character.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::(); + slug.trim_matches('-').to_owned() +} + +/// Errors fetching a manifest or generating a runner. +#[derive(Debug)] +pub enum RunnerError { + /// The host manifest could not be fetched. + Fetch { + /// Manifest URL. + url: Url, + /// Transport or response error. + source: Box, + }, + /// Manifest validation failed. + Manifest(ManifestError), + /// A JSON value could not be serialized. + Json(serde_json::Error), + /// The configured host URL is invalid. + InvalidHostUrl, + /// The manifest frame URL is invalid. + InvalidFrameUrl(String), + /// A generated path is outside the project. + InvalidPath(PathBuf), + /// The manifest expands to no runnable cases. + NoCases, + /// A generated artifact could not be written. + Write { + /// Attempted path. + path: PathBuf, + /// Underlying I/O error. + source: std::io::Error, + }, +} + +impl fmt::Display for RunnerError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Fetch { url, source } => write!(formatter, "could not fetch {url}: {source}"), + Self::Manifest(error) => write!(formatter, "invalid host manifest: {error}"), + Self::Json(error) => write!(formatter, "could not encode runner data: {error}"), + Self::InvalidHostUrl => formatter.write_str("host.url is invalid"), + Self::InvalidFrameUrl(url) => write!(formatter, "frame URL is invalid: {url}"), + Self::InvalidPath(path) => { + write!(formatter, "{} is outside the project root", path.display()) + } + Self::NoCases => formatter.write_str("host manifest contains no runnable cases"), + Self::Write { path, source } => { + write!(formatter, "could not write {}: {source}", path.display()) + } + } + } +} + +impl Error for RunnerError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Fetch { source, .. } => Some(source), + Self::Manifest(source) => Some(source), + Self::Json(source) => Some(source), + Self::Write { source, .. } => Some(source), + Self::InvalidHostUrl + | Self::InvalidFrameUrl(_) + | Self::InvalidPath(_) + | Self::NoCases => None, + } + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use crate::config::Config; + use crate::contract::{FrameContractV1, ManifestV1, SceneV1, StoryV1, ViewportV1}; + + use super::generate; + + #[test] + fn generates_explicit_cases_and_semantic_gates() { + let root = tempfile::tempdir().unwrap(); + let config: Config = toml::from_str( + r#" +version = 1 +name = "Example" +[host] +command = ["cargo", "run"] +url = "http://127.0.0.1:3000" +[test] +story_globs = ["coatcheck/**/*.spec.ts"] +artifacts = ".coatcheck/artifacts" +timeout_ms = 30000 +axe_impacts = ["critical"] +"#, + ) + .unwrap(); + let manifest = ManifestV1 { + version: 1, + name: "Example".to_owned(), + frame: FrameContractV1::default(), + scenes: vec![SceneV1 { + id: "mobile".to_owned(), + label: "Mobile".to_owned(), + viewport: ViewportV1 { + width: 393, + height: 852, + }, + surface: "canvas".to_owned(), + }], + stories: vec![StoryV1 { + id: "button".to_owned(), + title: "Button".to_owned(), + tier: "Atoms".to_owned(), + kind: "state".to_owned(), + description: String::new(), + source: "src/button.rs".to_owned(), + args: Vec::new(), + scenes: Vec::new(), + }], + }; + + let generated = generate(root.path(), &config, &manifest).unwrap(); + let spec = std::fs::read_to_string(generated.manifest_spec).unwrap(); + let playwright = std::fs::read_to_string(generated.config).unwrap(); + + assert_eq!(generated.case_count, 1); + assert!(spec.contains("button--mobile")); + assert!(spec.contains("new AxeBuilder")); + assert!(spec.contains("review.png")); + assert!(!spec.contains("toHaveScreenshot")); + assert!(playwright.contains("coatcheck/**/*.spec.ts")); + assert!(Path::new(&generated.review).exists()); + } +} diff --git a/templates/Coatcheck.toml b/templates/Coatcheck.toml new file mode 100644 index 0000000..9341157 --- /dev/null +++ b/templates/Coatcheck.toml @@ -0,0 +1,15 @@ +version = 1 +name = {{project_name}} + +[host] +command = ["topcoat", "dev"] +url = "http://127.0.0.1:3000" +health = "/health" +manifest = "/__coatcheck/manifest.json" +startup_timeout_seconds = 30 + +[test] +story_globs = ["coatcheck/**/*.spec.ts"] +artifacts = ".coatcheck/artifacts" +timeout_ms = 30000 +axe_impacts = ["serious", "critical"] diff --git a/templates/README.md b/templates/README.md new file mode 100644 index 0000000..cce0a77 --- /dev/null +++ b/templates/README.md @@ -0,0 +1,10 @@ +# Embedded templates + +[Back to the repository README](../README.md). + +These files are compiled into the installable binary and included in the +crate package: + +- `Coatcheck.toml` is the default host/test configuration. +- `package.json` is used only when the consumer has no package manifest. +- `custom.spec.ts` is the project-local Playwright example. diff --git a/templates/custom.spec.ts b/templates/custom.spec.ts new file mode 100644 index 0000000..8431a81 --- /dev/null +++ b/templates/custom.spec.ts @@ -0,0 +1,6 @@ +import { expect, test } from "@playwright/test"; + +test("custom project check", async ({ page }) => { + await page.goto("http://127.0.0.1:3000"); + await expect(page.locator("body")).toBeVisible(); +}); diff --git a/templates/package.json b/templates/package.json new file mode 100644 index 0000000..5c185ce --- /dev/null +++ b/templates/package.json @@ -0,0 +1,5 @@ +{ + "name": "coatcheck-runner", + "private": true, + "devDependencies": {} +} diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..b31ac0f --- /dev/null +++ b/tests/README.md @@ -0,0 +1,7 @@ +# Integration tests + +[Back to the repository README](../README.md). + +`cli.rs` exercises process-level scaffolding and a manifest-first test run +with local HTTP and Bun/Playwright command fakes. Browser execution is covered +separately by the manual real-Chromium smoke described in release evidence. diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..c822c13 --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,216 @@ +use std::fs; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::path::Path; +use std::process::{Command, Output}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread; +use std::time::Duration; + +fn coatcheck(root: &Path) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_coatcheck")); + command.args(["--project-root", root.to_str().unwrap()]); + command +} + +fn run(mut command: Command) -> Output { + command.output().unwrap() +} + +#[test] +fn init_scaffolds_a_rerunnable_project_boundary() { + let project = tempfile::tempdir().unwrap(); + + let mut first = coatcheck(project.path()); + first.args(["init", "--package-manager", "bun", "--skip-install"]); + let first = run(first); + assert!( + first.status.success(), + "{}", + String::from_utf8_lossy(&first.stderr) + ); + + let config = fs::read_to_string(project.path().join("Coatcheck.toml")).unwrap(); + assert!(config.contains("version = 1")); + assert!(config.contains("name = ")); + let parsed: coatcheck::config::Config = toml::from_str(&config).unwrap(); + assert!(!parsed.name.is_empty()); + assert!(project.path().join("package.json").is_file()); + assert!(project.path().join("coatcheck/example.spec.ts").is_file()); + assert!( + fs::read_to_string(project.path().join(".gitignore")) + .unwrap() + .contains(".coatcheck/") + ); + + let mut second = coatcheck(project.path()); + second.args(["init", "--skip-install"]); + let second = run(second); + assert!(!second.status.success()); + assert!(String::from_utf8_lossy(&second.stderr).contains("already exists")); +} + +#[cfg(unix)] +#[test] +fn test_fetches_manifest_generates_cases_and_invokes_custom_specs() { + use std::os::unix::fs::PermissionsExt; + + let project = tempfile::tempdir().unwrap(); + let (url, stop, server) = fixture_server(); + let config = format!( + r#" +version = 1 +name = "Fixture" + +[host] +command = ["true"] +url = "{url}" +health = "/health" +manifest = "/__coatcheck/manifest.json" +startup_timeout_seconds = 5 + +[test] +story_globs = ["coatcheck/**/*.spec.ts"] +artifacts = ".coatcheck/artifacts" +timeout_ms = 30000 +axe_impacts = ["serious", "critical"] +"# + ); + fs::write(project.path().join("Coatcheck.toml"), config).unwrap(); + + let fake_bin = project.path().join("fake-bin"); + fs::create_dir_all(&fake_bin).unwrap(); + for name in ["bun", "bunx"] { + let path = fake_bin.join(name); + fs::write( + &path, + if name == "bunx" { + "#!/bin/sh\nprintf '%s\\n' \"$*\" > \"$COATCHECK_FAKE_BUNX_MARKER\"\n" + } else { + "#!/bin/sh\nexit 0\n" + }, + ) + .unwrap(); + let mut permissions = fs::metadata(&path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).unwrap(); + } + + let marker = project.path().join("bunx-invocation.txt"); + let path = format!("{}:{}", fake_bin.display(), std::env::var("PATH").unwrap()); + let mut command = coatcheck(project.path()); + command + .args(["test", "--no-host"]) + .env("PATH", path) + .env("COATCHECK_FAKE_BUNX_MARKER", &marker); + let output = run(command); + + stop.store(true, Ordering::Relaxed); + server.join().unwrap(); + + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let generated = + fs::read_to_string(project.path().join(".coatcheck/generated/manifest.spec.ts")).unwrap(); + let playwright = fs::read_to_string( + project + .path() + .join(".coatcheck/generated/playwright.config.ts"), + ) + .unwrap(); + let review = fs::read_to_string(project.path().join(".coatcheck/artifacts/review.md")).unwrap(); + + assert!(generated.contains("Button [mobile]")); + assert!(generated.contains("button--mobile")); + assert!(playwright.contains("coatcheck/**/*.spec.ts")); + assert!( + fs::read_to_string(marker) + .unwrap() + .contains("playwright test --config") + ); + assert!(review.contains("Semantic result: **passed**")); + assert!(review.contains("PNG files are review artifacts only")); +} + +fn fixture_server() -> (String, Arc, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let address = listener.local_addr().unwrap(); + let stop = Arc::new(AtomicBool::new(false)); + let thread_stop = Arc::clone(&stop); + + let server = thread::spawn(move || { + while !thread_stop.load(Ordering::Relaxed) { + match listener.accept() { + Ok((mut stream, _)) => { + let mut request = [0_u8; 4096]; + let read = stream.read(&mut request).unwrap_or_default(); + let request = String::from_utf8_lossy(&request[..read]); + let path = request + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or("/"); + let body = match path { + "/health" => "ok".to_owned(), + "/__coatcheck/manifest.json" => manifest_json(), + _ => "not found".to_owned(), + }; + let status = if path == "/health" || path == "/__coatcheck/manifest.json" { + "200 OK" + } else { + "404 Not Found" + }; + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(response.as_bytes()); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("fixture server failed: {error}"), + } + } + }); + + (format!("http://{address}"), stop, server) +} + +fn manifest_json() -> String { + serde_json::json!({ + "version": 1, + "name": "Fixture", + "frame": { + "version": 1, + "path": "/__coatcheck/frame", + "story_query": "story", + "scene_query": "scene", + "root_selector": "[data-coatcheck-frame]", + "ready_selector": "[data-coatcheck-ready]", + "error_selector": "[data-coatcheck-error]" + }, + "scenes": [{ + "id": "mobile", + "label": "Mobile", + "viewport": { "width": 393, "height": 852 }, + "surface": "canvas" + }], + "stories": [{ + "id": "button", + "title": "Button", + "tier": "Atoms", + "kind": "playground", + "description": "Primary action", + "source": "src/button.rs", + "args": [], + "scenes": ["mobile"] + }] + }) + .to_string() +}