fixture: serve static pages instead of the Tally app
The app's state existed only to power the click round-trip and `post's
visible effect; a client-side toggle and a stateless echo cover both, so
any static directory — a generated site, later — can stand in as the app.
Assisted-by: GLM-5.3 via pi
diff --git a/Cargo.lock b/Cargo.lock
index 828fba6..5256fbd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -463,6 +463,12 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+[[package]]
+name = "futures-sink"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
+
[[package]]
name = "futures-task"
version = "0.3.34"
@@ -554,6 +560,12 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "http-range-header"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
+
[[package]]
name = "httparse"
version = "1.10.1"
@@ -899,6 +911,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -1057,12 +1079,11 @@ version = "0.1.0"
dependencies = [
"axum",
"fs-err",
- "miette",
"serde",
"serde_json",
"tempfile",
- "thiserror",
"tokio",
+ "tower-http",
"ureq",
]
@@ -1646,6 +1667,19 @@ dependencies = [
"syn 3.0.6",
]
+[[package]]
+name = "tokio-util"
+version = "0.7.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
[[package]]
name = "tower"
version = "0.5.3"
@@ -1662,6 +1696,31 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "tower-http"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "http-range-header",
+ "httpdate",
+ "mime",
+ "mime_guess",
+ "percent-encoding",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+ "tower-layer",
+ "tower-service",
+]
+
[[package]]
name = "tower-layer"
version = "0.3.3"
@@ -1742,6 +1801,12 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
[[package]]
name = "unicode-ident"
version = "1.0.26"
diff --git a/Cargo.toml b/Cargo.toml
index 2d1b571..f963134 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -33,7 +33,7 @@ tempfile = "*"
workspace = true
[workspace]
-members = [".", "fixtures/app"]
+members = [".", "fixtures/serve"]
[workspace.lints.clippy]
self_named_module_files = "warn"
diff --git a/fixtures/app/src/lib.rs b/fixtures/app/src/lib.rs
deleted file mode 100644
index bce6c48..0000000
--- a/fixtures/app/src/lib.rs
+++ /dev/null
@@ -1,445 +0,0 @@
-//! 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
deleted file mode 100644
index f69c610..0000000
--- a/fixtures/app/src/main.rs
+++ /dev/null
@@ -1,6 +0,0 @@
-#[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/parallax.fnl b/fixtures/parallax.fnl
similarity index 76%
rename from fixtures/app/parallax.fnl
rename to fixtures/parallax.fnl
index c63989d..d1c1101 100644
--- a/fixtures/app/parallax.fnl
+++ b/fixtures/parallax.fnl
@@ -1,5 +1,5 @@
-;; Demo config for the fixture app. Run from anywhere in the workspace:
-;; parallax run --config fixtures/app/parallax.fnl
+;; Demo config for the static fixture site. Run from anywhere in the
+;; workspace: parallax run --config fixtures/parallax.fnl
{:name "parallax-fixture"
:variants {:desktop-light {:viewport [1280 900]}
:desktop-dark {:viewport [1280 900] :scheme :dark}
@@ -16,8 +16,6 @@
(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)))
+ (goto (.. "/items/" (. (app) :featured_item) ".html"))
(wait-for ".item-detail")
(snap :item-detail {:selector ".item-detail"}))}
diff --git a/fixtures/app/Cargo.toml b/fixtures/serve/Cargo.toml
similarity index 84%
rename from fixtures/app/Cargo.toml
rename to fixtures/serve/Cargo.toml
index b3685cc..7a5a281 100644
--- a/fixtures/app/Cargo.toml
+++ b/fixtures/serve/Cargo.toml
@@ -7,11 +7,10 @@ publish = false
[dependencies]
axum = "*"
fs-err = "*"
-miette = { version = "*", features = ["fancy"] }
serde = { version = "*", features = ["derive"] }
serde_json = "*"
-thiserror = "*"
tokio = { version = "*", features = ["full"] }
+tower-http = { version = "*", features = ["fs"] }
[dev-dependencies]
tempfile = "*"
diff --git a/fixtures/serve/src/main.rs b/fixtures/serve/src/main.rs
new file mode 100644
index 0000000..76b6d48
--- /dev/null
+++ b/fixtures/serve/src/main.rs
@@ -0,0 +1,97 @@
+//! Serves the committed pages in `fixtures/site` and speaks parallax's
+//! handshake, so parallax's tests and demos run against static files.
+
+use std::io;
+use std::net::Ipv4Addr;
+use std::path::Path;
+
+use axum::Json;
+use axum::Router;
+use axum::http::StatusCode;
+use axum::response::IntoResponse;
+use axum::response::Response;
+use axum::routing::post;
+use serde::Deserialize;
+use serde::Serialize;
+use serde_json::json;
+use tokio::net::TcpListener;
+use tower_http::services::ServeDir;
+
+/// Baked in at compile time, so the binary serves the pages no matter
+/// which directory `cargo run` starts it from.
+const SITE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../site");
+
+/// The item the handshake points flows at; `items/2.html` is its only
+/// committed detail page.
+const FEATURED: u32 = 2;
+
+/// The id the echo route hands back, next after the five listed items.
+const ECHOED_ID: u32 = 6;
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+ let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
+ let addr = listener.local_addr()?;
+ let url = format!("http://{addr}");
+ match std::env::var_os("PARALLAX_HANDSHAKE") {
+ Some(path) => write_handshake(Path::new(&path), &url)?,
+ None => println!("{url}"),
+ }
+ axum::serve(listener, router()).await?;
+ Ok(())
+}
+
+/// 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)
+}
+
+fn router() -> Router {
+ Router::new()
+ .route("/__fixtures/items", post(create_item))
+ .fallback_service(ServeDir::new(SITE))
+}
+
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+struct NewItem {
+ title: String,
+ #[serde(default)]
+ tag: String,
+ #[serde(default)]
+ notes: String,
+}
+
+#[derive(Serialize)]
+struct Item {
+ id: u32,
+ title: String,
+ tag: String,
+ notes: String,
+ done: bool,
+}
+
+/// Stands in for the fixture routes a real app mounts for parallax's
+/// `post` helper. Stateless, so every post echoes back with the same id.
+async fn create_item(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 item = Item {
+ id: ECHOED_ID,
+ title: new.title,
+ tag: new.tag,
+ notes: new.notes,
+ done: false,
+ };
+ (StatusCode::CREATED, Json(item)).into_response()
+}
diff --git a/fixtures/app/tests/app.rs b/fixtures/serve/tests/serve.rs
similarity index 56%
rename from fixtures/app/tests/app.rs
rename to fixtures/serve/tests/serve.rs
index cc3c0ec..478d13b 100644
--- a/fixtures/app/tests/app.rs
+++ b/fixtures/serve/tests/serve.rs
@@ -1,12 +1,4 @@
-#![cfg_attr(
- test,
- allow(
- clippy::arithmetic_side_effects,
- clippy::expect_used,
- clippy::indexing_slicing,
- clippy::unwrap_used
- )
-)]
+#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
use std::path::Path;
use std::process::Child;
@@ -89,89 +81,31 @@ fn wait_for(handshake: &Path) -> Value {
}
#[test]
-fn handshake_reports_a_reachable_url_and_the_featured_item() {
+fn handshake_serves_the_site() {
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}"));
+ let (status, body) = app.get("/items/2.html");
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() {
+fn fixture_route_round_trips() {
let app = Fixture::start();
let (status, item) = app.post_json(
"/__fixtures/items",
- json!({"title": "<b>Bold</b>", "tag": "test"}),
+ json!({"title": "Added by the flow", "tag": "parallax"}),
);
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();
+ assert_eq!(
+ item,
+ json!({"id": 6, "title": "Added by the flow", "tag": "parallax", "notes": "", "done": false}),
+ );
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);
-}
diff --git a/fixtures/site/broken/console-error.html b/fixtures/site/broken/console-error.html
new file mode 100644
index 0000000..2172d54
--- /dev/null
+++ b/fixtures/site/broken/console-error.html
@@ -0,0 +1,16 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Broken · Tally</title>
+<link rel="stylesheet" href="/tally.css">
+</head>
+<body>
+<header class="masthead"><h1><a href="/">Tally</a></h1></header>
+<main>
+<p class="broken">This page misbehaves on purpose.</p>
+<script>console.error("fixture: deliberate console error");</script>
+</main>
+</body>
+</html>
diff --git a/fixtures/site/broken/console-warning.html b/fixtures/site/broken/console-warning.html
new file mode 100644
index 0000000..6a2e281
--- /dev/null
+++ b/fixtures/site/broken/console-warning.html
@@ -0,0 +1,16 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Broken · Tally</title>
+<link rel="stylesheet" href="/tally.css">
+</head>
+<body>
+<header class="masthead"><h1><a href="/">Tally</a></h1></header>
+<main>
+<p class="broken">This page misbehaves on purpose.</p>
+<script>console.warn("fixture: deliberate console warning");</script>
+</main>
+</body>
+</html>
diff --git a/fixtures/site/broken/exception.html b/fixtures/site/broken/exception.html
new file mode 100644
index 0000000..3b3a837
--- /dev/null
+++ b/fixtures/site/broken/exception.html
@@ -0,0 +1,16 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Broken · Tally</title>
+<link rel="stylesheet" href="/tally.css">
+</head>
+<body>
+<header class="masthead"><h1><a href="/">Tally</a></h1></header>
+<main>
+<p class="broken">This page misbehaves on purpose.</p>
+<script>throw new Error("fixture: deliberate uncaught exception");</script>
+</main>
+</body>
+</html>
diff --git a/fixtures/site/index.html b/fixtures/site/index.html
new file mode 100644
index 0000000..56189df
--- /dev/null
+++ b/fixtures/site/index.html
@@ -0,0 +1,62 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Tasks · Tally</title>
+<link rel="stylesheet" href="/tally.css">
+</head>
+<body>
+<header class="masthead"><h1><a href="/">Tally</a></h1></header>
+<main>
+<div class="dashboard"><section class="item-list" aria-label="Tasks"><ul>
+<li class="item open" data-id="1">
+<a href="/items/1.html">Water the ferns</a>
+<span class="tag">home</span>
+<button class="complete-btn">Complete</button>
+</li>
+<li class="item open" data-id="2">
+<a href="/items/2.html">Sharpen the kitchen knives</a>
+<span class="tag">home</span>
+<button class="complete-btn">Complete</button>
+</li>
+<li class="item open" data-id="3">
+<a href="/items/3.html">Renew library card</a>
+<span class="tag">errands</span>
+<button class="complete-btn">Complete</button>
+</li>
+<li class="item done" data-id="4">
+<a href="/items/4.html">Back up the photo library</a>
+<span class="tag">computer</span>
+<button class="complete-btn">Reopen</button>
+</li>
+<li class="item open" data-id="5">
+<a href="/items/5.html">Call about the bike tune-up</a>
+<span class="tag">errands</span>
+<button class="complete-btn">Complete</button>
+</li>
+</ul></section>
+<aside class="summary"><h2>Summary</h2><dl>
+<div><dt>Open</dt><dd>4</dd></div>
+<div><dt>Done</dt><dd>1</dd></div>
+</dl></aside></div>
+</main>
+<script>
+// The fixture has no server state, so completing an item is a local
+// toggle that mimics the old form round-trip's flash bar.
+document.addEventListener("click", (event) => {
+ const button = event.target.closest(".complete-btn");
+ if (!button) return;
+ const item = button.closest(".item");
+ const done = item.classList.toggle("done");
+ button.textContent = done ? "Reopen" : "Complete";
+ const title = item.querySelector("a").textContent;
+ const flash = document.createElement("p");
+ flash.className = "flash-bar";
+ flash.setAttribute("role", "status");
+ flash.textContent = (done ? "Completed" : "Reopened") + " \u201C" + title + "\u201D.";
+ document.querySelector("main").prepend(flash);
+});
+</script>
+</body>
+</html>
diff --git a/fixtures/site/items/2.html b/fixtures/site/items/2.html
new file mode 100644
index 0000000..f48c0de
--- /dev/null
+++ b/fixtures/site/items/2.html
@@ -0,0 +1,20 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Sharpen the kitchen knives · Tally</title>
+<link rel="stylesheet" href="/tally.css">
+</head>
+<body>
+<header class="masthead"><h1><a href="/">Tally</a></h1></header>
+<main>
+<article class="item-detail">
+<p><a href="/">← All tasks</a></p>
+<h2>Sharpen the kitchen knives</h2>
+<dl><div><dt>Status</dt><dd>Open</dd></div><div><dt>Tag</dt><dd class="tag">home</dd></div></dl>
+<p>Whetstone is under the sink. Coarse side first, then fine, then strop on the old belt.</p>
+</article>
+</main>
+</body>
+</html>
diff --git a/fixtures/site/tally.css b/fixtures/site/tally.css
new file mode 100644
index 0000000..b0234f4
--- /dev/null
+++ b/fixtures/site/tally.css
@@ -0,0 +1,102 @@
+/* Tally's stylesheet: 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. */
+: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; }
+.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; }