fixtures: add a demo app for integration tests
Assisted-by: Claude Opus 5.5 via Claude Code
diff --git a/Cargo.lock b/Cargo.lock
index 1f2fc6b..0a24174 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -62,7 +62,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -73,7 +73,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -91,12 +91,70 @@ dependencies = [
"wait-timeout",
]
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+[[package]]
+name = "axum"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
+dependencies = [
+ "axum-core",
+ "bytes",
+ "form_urlencoded",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde_core",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
[[package]]
name = "backtrace"
version = "0.3.76"
@@ -106,7 +164,7 @@ dependencies = [
"addr2line",
"cfg-if",
"libc",
- "miniz_oxide",
+ "miniz_oxide 0.8.9",
"object",
"rustc-demangle",
"windows-link",
@@ -121,6 +179,12 @@ dependencies = [
"backtrace",
]
+[[package]]
+name = "base64"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
+
[[package]]
name = "bitflags"
version = "2.13.2"
@@ -221,6 +285,50 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+[[package]]
+name = "cookie"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
+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.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+
[[package]]
name = "difflib"
version = "0.4.0"
@@ -238,12 +346,27 @@ dependencies = [
"syn 3.0.6",
]
+[[package]]
+name = "document-features"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
+dependencies = [
+ "litrs",
+]
+
[[package]]
name = "either"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
[[package]]
name = "erased-serde"
version = "0.4.10"
@@ -262,7 +385,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -277,6 +400,17 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
+[[package]]
+name = "flate2"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide 0.9.1",
+ "zlib-rs",
+]
+
[[package]]
name = "float-cmp"
version = "0.10.0"
@@ -286,6 +420,15 @@ dependencies = [
"num-traits",
]
+[[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 = "fs-err"
version = "3.3.1"
@@ -295,6 +438,50 @@ dependencies = [
"autocfg",
]
+[[package]]
+name = "futures-channel"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[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"
@@ -312,12 +499,98 @@ version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
+[[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.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "hyper"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
+dependencies = [
+ "bytes",
+ "http",
+ "http-body",
+ "hyper",
+ "pin-project-lite",
+ "tokio",
+ "tower-service",
+]
+
[[package]]
name = "icu_collections"
version = "2.3.0"
@@ -366,6 +639,47 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8"
+[[package]]
+name = "icu_normalizer"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
+
+[[package]]
+name = "icu_properties"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
+
[[package]]
name = "icu_provider"
version = "2.3.1"
@@ -405,6 +719,37 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad"
+[[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.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
[[package]]
name = "is_ci"
version = "1.2.0"
@@ -447,6 +792,12 @@ version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
+[[package]]
+name = "litrs"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
+
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -490,6 +841,12 @@ dependencies = [
"regex-automata",
]
+[[package]]
+name = "matchit"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+
[[package]]
name = "memchr"
version = "2.8.3"
@@ -526,6 +883,12 @@ dependencies = [
"syn 2.0.119",
]
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -535,6 +898,16 @@ dependencies = [
"adler2",
]
+[[package]]
+name = "miniz_oxide"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
[[package]]
name = "mio"
version = "1.2.3"
@@ -543,7 +916,7 @@ checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
dependencies = [
"libc",
"wasi",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -590,9 +963,15 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
- "windows-sys",
+ "windows-sys 0.61.2",
]
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -659,6 +1038,21 @@ dependencies = [
"tracing-subscriber",
]
+[[package]]
+name = "parallax-fixture"
+version = "0.1.0"
+dependencies = [
+ "axum",
+ "fs-err",
+ "miette",
+ "serde",
+ "serde_json",
+ "tempfile",
+ "thiserror",
+ "tokio",
+ "ureq",
+]
+
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -682,6 +1076,12 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -705,6 +1105,12 @@ dependencies = [
"zerovec",
]
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
[[package]]
name = "predicates"
version = "3.1.4"
@@ -797,6 +1203,20 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+[[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 = "rustc-demangle"
version = "0.1.28"
@@ -819,9 +1239,50 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
- "windows-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
+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.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -868,6 +1329,19 @@ dependencies = [
"syn 3.0.6",
]
+[[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_path_to_error"
version = "0.1.20"
@@ -879,6 +1353,18 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
[[package]]
name = "sharded-slab"
version = "0.1.7"
@@ -904,6 +1390,18 @@ dependencies = [
"libc",
]
+[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
[[package]]
name = "smallvec"
version = "1.16.2"
@@ -917,7 +1415,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -932,6 +1430,12 @@ 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 = "supports-color"
version = "3.0.2"
@@ -975,6 +1479,12 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+
[[package]]
name = "synstructure"
version = "0.14.0"
@@ -993,10 +1503,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
- "getrandom",
+ "getrandom 0.4.3",
"once_cell",
"rustix",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -1006,7 +1516,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
dependencies = [
"rustix",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -1054,6 +1564,36 @@ dependencies = [
"cfg-if",
]
+[[package]]
+name = "time"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
+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.4"
@@ -1079,7 +1619,7 @@ dependencies = [
"signal-hook-registry",
"socket2",
"tokio-macros",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -1093,12 +1633,41 @@ dependencies = [
"syn 3.0.6",
]
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
+ "log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@@ -1178,6 +1747,62 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "ureq"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
+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.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be"
+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",
+]
+
+[[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"
@@ -1196,6 +1821,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
[[package]]
name = "wait-timeout"
version = "0.2.1"
@@ -1211,6 +1842,15 @@ 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 = "which"
version = "8.0.6"
@@ -1226,6 +1866,15 @@ 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"
@@ -1235,6 +1884,70 @@ 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 = "writeable"
version = "0.6.4"
@@ -1285,6 +1998,12 @@ dependencies = [
"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.5"
@@ -1292,6 +2011,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
dependencies = [
"displaydoc",
+ "yoke",
+ "zerofrom",
"zerovec",
]
@@ -1317,3 +2038,15 @@ dependencies = [
"quote",
"syn 3.0.6",
]
+
+[[package]]
+name = "zlib-rs"
+version = "0.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
+
+[[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 42ec092..785f3b3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -30,7 +30,7 @@ tempfile = "*"
workspace = true
[workspace]
-members = ["."]
+members = [".", "fixtures/app"]
[workspace.lints.clippy]
self_named_module_files = "warn"
diff --git a/fixtures/app/Cargo.toml b/fixtures/app/Cargo.toml
new file mode 100644
index 0000000..b3685cc
--- /dev/null
+++ b/fixtures/app/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "parallax-fixture"
+version = "0.1.0"
+edition = "2024"
+publish = false
+
+[dependencies]
+axum = "*"
+fs-err = "*"
+miette = { version = "*", features = ["fancy"] }
+serde = { version = "*", features = ["derive"] }
+serde_json = "*"
+thiserror = "*"
+tokio = { version = "*", features = ["full"] }
+
+[dev-dependencies]
+tempfile = "*"
+ureq = { version = "*", features = ["json"] }
+
+[lints]
+workspace = true
diff --git a/fixtures/app/parallax.fnl b/fixtures/app/parallax.fnl
new file mode 100644
index 0000000..c63989d
--- /dev/null
+++ b/fixtures/app/parallax.fnl
@@ -0,0 +1,23 @@
+;; Demo config for the fixture app. Run from anywhere in the workspace:
+;; parallax run --config fixtures/app/parallax.fnl
+{:name "parallax-fixture"
+ :variants {:desktop-light {:viewport [1280 900]}
+ :desktop-dark {:viewport [1280 900] :scheme :dark}
+ :mobile-light {:viewport [375 812]}
+ :mobile-dark {:viewport [375 812] :scheme :dark}}
+ :server (fn []
+ {:cmd ["cargo" "run" "--quiet" "--package" "parallax-fixture"]
+ :timeout 120})
+ :flow (fn [{: goto : wait-for : click : snap : post : app}]
+ (goto "/")
+ (wait-for ".dashboard")
+ (snap :dashboard)
+ (click ".complete-btn")
+ (wait-for ".flash-bar")
+ (snap :dashboard-after-complete)
+ (post "/__fixtures/items" {:title "Added by the flow" :tag "parallax"})
+ (goto "/")
+ (snap :dashboard-with-fixture-item)
+ (goto (.. "/items/" (. (app) :featured_item)))
+ (wait-for ".item-detail")
+ (snap :item-detail {:selector ".item-detail"}))}
diff --git a/fixtures/app/src/lib.rs b/fixtures/app/src/lib.rs
new file mode 100644
index 0000000..bce6c48
--- /dev/null
+++ b/fixtures/app/src/lib.rs
@@ -0,0 +1,445 @@
+//! A small task tracker that parallax's integration tests and demos run
+//! against. It speaks the handshake from the design doc and keeps its
+//! state in memory, so every process starts from the same seed.
+
+use std::fmt::Write as _;
+use std::io;
+use std::net::Ipv4Addr;
+use std::path::Path;
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use axum::Json;
+use axum::Router;
+use axum::extract::Path as UrlPath;
+use axum::extract::Query;
+use axum::extract::State;
+use axum::http::StatusCode;
+use axum::response::Html;
+use axum::response::IntoResponse;
+use axum::response::Redirect;
+use axum::response::Response;
+use axum::routing::get;
+use axum::routing::post;
+use serde::Deserialize;
+use serde::Serialize;
+use serde_json::json;
+use tokio::net::TcpListener;
+use tokio::sync::Mutex;
+
+#[derive(Debug, thiserror::Error, miette::Diagnostic)]
+pub enum Error {
+ #[error("could not bind a local port")]
+ Bind(#[source] io::Error),
+
+ #[error("could not write the handshake")]
+ #[diagnostic(help("PARALLAX_HANDSHAKE must name a file in a writable directory"))]
+ Handshake(#[source] io::Error),
+
+ #[error("the server stopped")]
+ Serve(#[source] io::Error),
+}
+
+#[derive(Clone, Debug, Serialize)]
+pub struct Item {
+ pub id: u32,
+ pub title: String,
+ pub tag: String,
+ pub notes: String,
+ pub done: bool,
+}
+
+/// The id of the seeded item the handshake points flows at.
+const FEATURED: u32 = 2;
+
+fn seed() -> Vec<Item> {
+ let item = |id, title: &str, tag: &str, notes: &str, done| Item {
+ id,
+ title: title.to_owned(),
+ tag: tag.to_owned(),
+ notes: notes.to_owned(),
+ done,
+ };
+ vec![
+ item(
+ 1,
+ "Water the ferns",
+ "home",
+ "The big one by the window drinks twice as much.",
+ false,
+ ),
+ item(
+ 2,
+ "Sharpen the kitchen knives",
+ "home",
+ "Whetstone is under the sink. Coarse side first, then fine, then strop on the old belt.",
+ false,
+ ),
+ item(
+ 3,
+ "Renew library card",
+ "errands",
+ "Bring proof of address.",
+ false,
+ ),
+ item(4, "Back up the photo library", "computer", "", true),
+ item(
+ 5,
+ "Call about the bike tune-up",
+ "errands",
+ "Ask whether they have the 11-speed chain in stock.",
+ false,
+ ),
+ ]
+}
+
+type Items = Arc<Mutex<Vec<Item>>>;
+
+/// Binds an ephemeral port, reports it through the handshake file
+/// when one is given, and serves until the process is killed.
+pub async fn run(handshake: Option<PathBuf>) -> Result<(), Error> {
+ let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
+ .await
+ .map_err(Error::Bind)?;
+ let addr = listener.local_addr().map_err(Error::Bind)?;
+ let url = format!("http://{addr}");
+ match handshake {
+ Some(path) => write_handshake(&path, &url).map_err(Error::Handshake)?,
+ None => println!("{url}"),
+ }
+ axum::serve(listener, router()).await.map_err(Error::Serve)
+}
+
+/// Writes through a sibling temporary file and renames it, so a reader
+/// polling for the path never sees a partial file.
+fn write_handshake(path: &Path, url: &str) -> io::Result<()> {
+ let body = json!({"url": url, "data": {"featured_item": FEATURED}});
+ let mut tmp = path.as_os_str().to_owned();
+ tmp.push(".tmp");
+ fs_err::write(&tmp, body.to_string())?;
+ fs_err::rename(&tmp, path)
+}
+
+pub fn router() -> Router {
+ let items: Items = Arc::new(Mutex::new(seed()));
+ Router::new()
+ .route("/", get(dashboard))
+ .route("/items/{id}", get(item_detail))
+ .route("/items/{id}/complete", post(toggle_item))
+ .route("/broken/{kind}", get(broken))
+ .route("/__fixtures/items", post(create_item))
+ .fallback(not_found)
+ .with_state(items)
+}
+
+#[derive(Deserialize)]
+struct DashboardQuery {
+ toggled: Option<u32>,
+}
+
+async fn dashboard(
+ State(items): State<Items>,
+ Query(query): Query<DashboardQuery>,
+) -> Html<String> {
+ let items = items.lock().await;
+ let mut body = String::new();
+
+ if let Some(item) = query
+ .toggled
+ .and_then(|id| items.iter().find(|i| i.id == id))
+ {
+ let verb = if item.done { "Completed" } else { "Reopened" };
+ let _ = write!(
+ body,
+ r#"<p class="flash-bar" role="status">{verb} “{}”.</p>"#,
+ escape(&item.title)
+ );
+ }
+
+ body.push_str(r#"<div class="dashboard"><section class="item-list" aria-label="Tasks"><ul>"#);
+ for item in items.iter() {
+ let (state, label) = if item.done {
+ ("done", "Reopen")
+ } else {
+ ("open", "Complete")
+ };
+ let _ = write!(
+ body,
+ r#"<li class="item {state}" data-id="{id}">
+<a href="/items/{id}">{title}</a>
+<span class="tag">{tag}</span>
+<form method="post" action="/items/{id}/complete"><button class="complete-btn">{label}</button></form>
+</li>"#,
+ id = item.id,
+ title = escape(&item.title),
+ tag = escape(&item.tag),
+ );
+ }
+ body.push_str("</ul></section>");
+
+ let open = items.iter().filter(|i| !i.done).count();
+ let done = items.iter().filter(|i| i.done).count();
+ let _ = write!(
+ body,
+ r#"<aside class="summary"><h2>Summary</h2><dl>
+<div><dt>Open</dt><dd>{open}</dd></div>
+<div><dt>Done</dt><dd>{done}</dd></div>
+</dl></aside></div>"#
+ );
+
+ page("Tasks", &body)
+}
+
+async fn item_detail(State(items): State<Items>, UrlPath(id): UrlPath<u32>) -> Response {
+ let items = items.lock().await;
+ let Some(item) = items.iter().find(|i| i.id == id) else {
+ return not_found().await.into_response();
+ };
+ let status = if item.done { "Done" } else { "Open" };
+ let notes = if item.notes.is_empty() {
+ r#"<p class="empty">No notes.</p>"#.to_owned()
+ } else {
+ format!("<p>{}</p>", escape(&item.notes))
+ };
+ let body = format!(
+ r#"<article class="item-detail">
+<p><a href="/">← All tasks</a></p>
+<h2>{title}</h2>
+<dl><div><dt>Status</dt><dd>{status}</dd></div><div><dt>Tag</dt><dd class="tag">{tag}</dd></div></dl>
+{notes}
+</article>"#,
+ title = escape(&item.title),
+ tag = escape(&item.tag),
+ );
+ page(&item.title, &body).into_response()
+}
+
+async fn toggle_item(State(items): State<Items>, UrlPath(id): UrlPath<u32>) -> Response {
+ let mut items = items.lock().await;
+ let Some(item) = items.iter_mut().find(|i| i.id == id) else {
+ return not_found().await.into_response();
+ };
+ item.done = !item.done;
+ Redirect::to(&format!("/?toggled={id}")).into_response()
+}
+
+/// Pages that misbehave on purpose, so tests can check that parallax
+/// fails a flow on each kind of page error.
+async fn broken(UrlPath(kind): UrlPath<String>) -> Response {
+ let script = match kind.as_str() {
+ "console-error" => r#"console.error("fixture: deliberate console error");"#,
+ "console-warning" => r#"console.warn("fixture: deliberate console warning");"#,
+ "exception" => r#"throw new Error("fixture: deliberate uncaught exception");"#,
+ _ => return not_found().await.into_response(),
+ };
+ let body = format!(
+ r#"<p class="broken">This page misbehaves on purpose.</p><script>{script}</script>"#
+ );
+ page("Broken", &body).into_response()
+}
+
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+struct NewItem {
+ title: String,
+ #[serde(default)]
+ tag: String,
+ #[serde(default)]
+ notes: String,
+}
+
+/// Lets flows add state outside the UI, standing in for the fixture
+/// routes a real app mounts for parallax's `post` helper.
+async fn create_item(State(items): State<Items>, Json(new): Json<NewItem>) -> Response {
+ if new.title.trim().is_empty() {
+ return (
+ StatusCode::UNPROCESSABLE_ENTITY,
+ Json(json!({"error": "title must not be empty"})),
+ )
+ .into_response();
+ }
+ let mut items = items.lock().await;
+ let Some(id) = items.iter().map(|i| i.id).max().unwrap_or(0).checked_add(1) else {
+ return (
+ StatusCode::INSUFFICIENT_STORAGE,
+ Json(json!({"error": "out of ids"})),
+ )
+ .into_response();
+ };
+ let item = Item {
+ id,
+ title: new.title,
+ tag: new.tag,
+ notes: new.notes,
+ done: false,
+ };
+ items.push(item.clone());
+ (StatusCode::CREATED, Json(item)).into_response()
+}
+
+async fn not_found() -> (StatusCode, Html<String>) {
+ (
+ StatusCode::NOT_FOUND,
+ page(
+ "Not found",
+ r#"<p class="not-found">Nothing lives here. <a href="/">Back to tasks</a>.</p>"#,
+ ),
+ )
+}
+
+fn page(title: &str, body: &str) -> Html<String> {
+ Html(format!(
+ r#"<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{title} · Tally</title>
+<style>{STYLE}</style>
+</head>
+<body>
+<header class="masthead"><h1><a href="/">Tally</a></h1></header>
+<main>{body}</main>
+</body>
+</html>"#,
+ title = escape(title),
+ ))
+}
+
+fn escape(text: &str) -> String {
+ let mut out = String::with_capacity(text.len());
+ for c in text.chars() {
+ match c {
+ '&' => out.push_str("&"),
+ '<' => out.push_str("<"),
+ '>' => out.push_str(">"),
+ '"' => out.push_str("""),
+ '\'' => out.push_str("'"),
+ _ => out.push(c),
+ }
+ }
+ out
+}
+
+/// Type and space steps are Utopia's defaults: 18px at a 320px viewport
+/// with a 1.2 ratio, up to 20px at 1240px with a 1.25 ratio. System
+/// fonts only, so screenshots don't wait on a network.
+const STYLE: &str = r#"
+:root {
+ --step--1: clamp(0.9375rem, 0.9158rem + 0.1087vw, 1rem);
+ --step-0: clamp(1.125rem, 1.0815rem + 0.2174vw, 1.25rem);
+ --step-1: clamp(1.35rem, 1.2761rem + 0.3696vw, 1.5625rem);
+ --step-2: clamp(1.62rem, 1.5041rem + 0.5793vw, 1.9531rem);
+ --space-2xs: clamp(0.5625rem, 0.5408rem + 0.1087vw, 0.625rem);
+ --space-xs: clamp(0.875rem, 0.8533rem + 0.1087vw, 0.9375rem);
+ --space-s: clamp(1.125rem, 1.0815rem + 0.2174vw, 1.25rem);
+ --space-m: clamp(1.6875rem, 1.6223rem + 0.3261vw, 1.875rem);
+ --space-l: clamp(2.25rem, 2.163rem + 0.4348vw, 2.5rem);
+
+ --bg: #f7f5f0;
+ --surface: #ffffff;
+ --text: #1f1d1a;
+ --muted: #6b665e;
+ --border: #e2ddd3;
+ --accent: #2f6f4f;
+ --accent-text: #ffffff;
+ --flash: #e3f1e8;
+
+ color-scheme: light dark;
+}
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #181715;
+ --surface: #23211e;
+ --text: #ece8e1;
+ --muted: #a39d93;
+ --border: #3a3732;
+ --accent: #7cc49b;
+ --accent-text: #10251a;
+ --flash: #1f3a2b;
+ }
+}
+*, *::before, *::after { box-sizing: border-box; }
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--text);
+ font: var(--step-0)/1.5 system-ui, sans-serif;
+}
+a { color: inherit; }
+.masthead {
+ padding: var(--space-s) var(--space-m);
+ border-bottom: 1px solid var(--border);
+}
+.masthead h1 { margin: 0; font-size: var(--step-2); letter-spacing: -0.02em; }
+.masthead a { text-decoration: none; }
+main { max-width: 64rem; margin: 0 auto; padding: var(--space-m); }
+h2 { font-size: var(--step-1); margin: 0 0 var(--space-xs); }
+.flash-bar {
+ margin: 0 0 var(--space-m);
+ padding: var(--space-xs) var(--space-s);
+ background: var(--flash);
+ border-radius: 0.5rem;
+}
+.dashboard { display: flex; flex-wrap: wrap; gap: var(--space-m); align-items: flex-start; }
+.item-list { flex: 999 1 28rem; }
+.summary { flex: 1 1 14rem; }
+.item-list ul { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-2xs); }
+.item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-xs);
+ padding: var(--space-xs) var(--space-s);
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+}
+.item > a { flex: 1; text-decoration: none; }
+.item.done > a { color: var(--muted); text-decoration: line-through; }
+.item form { margin: 0; }
+.tag {
+ font-size: var(--step--1);
+ color: var(--muted);
+}
+.complete-btn {
+ font: inherit;
+ font-size: var(--step--1);
+ padding: 0.25em 0.75em;
+ border: 1px solid var(--accent);
+ border-radius: 999px;
+ background: var(--accent);
+ color: var(--accent-text);
+ cursor: pointer;
+}
+.item.done .complete-btn { background: transparent; color: var(--accent); }
+.summary, .item-detail {
+ padding: var(--space-s);
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+}
+dl { margin: 0; display: grid; gap: var(--space-2xs); }
+dl > div { display: flex; justify-content: space-between; gap: var(--space-s); }
+dt { color: var(--muted); }
+dd { margin: 0; font-weight: 600; }
+.item-detail dl { margin-bottom: var(--space-s); }
+.empty { color: var(--muted); font-style: italic; }
+"#;
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn escapes_markup() {
+ assert_eq!(
+ escape(r#"<a href="x">'&'</a>"#),
+ "<a href="x">'&'</a>"
+ );
+ }
+
+ #[test]
+ fn featured_item_is_seeded() {
+ assert!(seed().iter().any(|i| i.id == FEATURED));
+ }
+}
diff --git a/fixtures/app/src/main.rs b/fixtures/app/src/main.rs
new file mode 100644
index 0000000..f69c610
--- /dev/null
+++ b/fixtures/app/src/main.rs
@@ -0,0 +1,6 @@
+#[tokio::main]
+async fn main() -> miette::Result<()> {
+ miette::set_panic_hook();
+ parallax_fixture::run(std::env::var_os("PARALLAX_HANDSHAKE").map(Into::into)).await?;
+ Ok(())
+}
diff --git a/fixtures/app/tests/app.rs b/fixtures/app/tests/app.rs
new file mode 100644
index 0000000..cc3c0ec
--- /dev/null
+++ b/fixtures/app/tests/app.rs
@@ -0,0 +1,177 @@
+#![cfg_attr(
+ test,
+ allow(
+ clippy::arithmetic_side_effects,
+ clippy::expect_used,
+ clippy::indexing_slicing,
+ clippy::unwrap_used
+ )
+)]
+
+use std::path::Path;
+use std::process::Child;
+use std::process::Command;
+use std::time::Duration;
+use std::time::Instant;
+
+use serde_json::Value;
+use serde_json::json;
+
+/// A running fixture that dies with the test.
+struct Fixture {
+ child: Child,
+ url: String,
+ data: Value,
+ agent: ureq::Agent,
+ _dir: tempfile::TempDir,
+}
+
+impl Drop for Fixture {
+ fn drop(&mut self) {
+ let _ = self.child.kill();
+ let _ = self.child.wait();
+ }
+}
+
+impl Fixture {
+ fn start() -> Self {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let handshake = dir.path().join("handshake.json");
+ let child = Command::new(env!("CARGO_BIN_EXE_parallax-fixture"))
+ .env("PARALLAX_HANDSHAKE", &handshake)
+ .spawn()
+ .expect("spawn fixture");
+ let body = wait_for(&handshake);
+ let url = body["url"].as_str().expect("handshake url").to_owned();
+ let agent = ureq::Agent::config_builder()
+ .http_status_as_error(false)
+ .build()
+ .into();
+ Fixture {
+ child,
+ url,
+ data: body["data"].clone(),
+ agent,
+ _dir: dir,
+ }
+ }
+
+ fn get(&self, path: &str) -> (u16, String) {
+ let mut response = self
+ .agent
+ .get(format!("{}{path}", self.url))
+ .call()
+ .expect("get");
+ let body = response.body_mut().read_to_string().expect("body");
+ (response.status().as_u16(), body)
+ }
+
+ fn post_json(&self, path: &str, body: Value) -> (u16, Value) {
+ let mut response = self
+ .agent
+ .post(format!("{}{path}", self.url))
+ .send_json(body)
+ .expect("post");
+ let body = response.body_mut().read_json().expect("json body");
+ (response.status().as_u16(), body)
+ }
+}
+
+fn wait_for(handshake: &Path) -> Value {
+ let deadline = Instant::now() + Duration::from_secs(10);
+ loop {
+ if let Ok(text) = fs_err::read_to_string(handshake) {
+ return serde_json::from_str(&text).expect("handshake is complete JSON");
+ }
+ assert!(Instant::now() < deadline, "no handshake after 10s");
+ std::thread::sleep(Duration::from_millis(20));
+ }
+}
+
+#[test]
+fn handshake_reports_a_reachable_url_and_the_featured_item() {
+ let app = Fixture::start();
+ assert_eq!(app.data, json!({"featured_item": 2}));
+ let (status, body) = app.get("/");
+ assert_eq!(status, 200);
+ assert!(body.contains(r#"class="dashboard""#));
+ assert!(body.contains("Water the ferns"));
+}
+
+#[test]
+fn completing_an_item_redirects_to_a_flash() {
+ let app = Fixture::start();
+ let mut response = app
+ .agent
+ .post(format!("{}/items/1/complete", app.url))
+ .send_empty()
+ .expect("post");
+ assert_eq!(response.status().as_u16(), 200);
+ let body = response.body_mut().read_to_string().expect("body");
+ assert!(
+ body.contains(r#"<p class="flash-bar" role="status">Completed “Water the ferns”.</p>"#)
+ );
+ assert!(body.contains(r#"<li class="item done" data-id="1">"#));
+}
+
+#[test]
+fn featured_item_has_a_detail_page() {
+ let app = Fixture::start();
+ let id = app.data["featured_item"].as_u64().expect("featured id");
+ let (status, body) = app.get(&format!("/items/{id}"));
+ assert_eq!(status, 200);
+ assert!(body.contains(r#"class="item-detail""#));
+ assert!(body.contains("Sharpen the kitchen knives"));
+}
+
+#[test]
+fn fixture_route_adds_escaped_items() {
+ let app = Fixture::start();
+ let (status, item) = app.post_json(
+ "/__fixtures/items",
+ json!({"title": "<b>Bold</b>", "tag": "test"}),
+ );
+ assert_eq!(status, 201);
+ assert_eq!(item["id"], 6);
+ let (_, body) = app.get("/");
+ assert!(body.contains("<b>Bold</b>"));
+}
+
+#[test]
+fn fixture_route_rejects_bad_items() {
+ let app = Fixture::start();
+ let (status, body) = app.post_json("/__fixtures/items", json!({"title": " "}));
+ assert_eq!(status, 422);
+ assert_eq!(body["error"], "title must not be empty");
+}
+
+#[test]
+fn each_process_starts_from_the_seed() {
+ let first = Fixture::start();
+ first.post_json("/__fixtures/items", json!({"title": "Only in the first"}));
+ let second = Fixture::start();
+ let (_, body) = second.get("/");
+ assert!(!body.contains("Only in the first"));
+}
+
+#[test]
+fn broken_pages_misbehave_by_kind() {
+ let app = Fixture::start();
+ for (kind, fragment) in [
+ ("console-error", "console.error("),
+ ("console-warning", "console.warn("),
+ ("exception", "throw new Error("),
+ ] {
+ let (status, body) = app.get(&format!("/broken/{kind}"));
+ assert_eq!(status, 200, "{kind}");
+ assert!(body.contains(fragment), "{kind}");
+ }
+ assert_eq!(app.get("/broken/nope").0, 404);
+}
+
+#[test]
+fn unknown_paths_are_not_found() {
+ let app = Fixture::start();
+ assert_eq!(app.get("/nope").0, 404);
+ assert_eq!(app.get("/items/99").0, 404);
+}