//! What the web pages show, as knotless-serve answers it in XRPC queries, //! over a reference PDS with an index following its firehose; and the //! pages themselves, handed to the app that draws them. #[path = "../../knotless/tests/common/mod.rs"] mod common; use common::*; use knotless_serve::code::Code; use knotless_serve::index::Index; use knotless_serve::{App, router}; use reqwest::blocking::{Client, Response}; use serde_json::Value; use std::sync::Arc; use std::time::Duration; struct Site { base: String, app: Arc, _rt: tokio::runtime::Runtime, _sandbox: Sandbox, } fn serve(indexed: bool) -> Site { serve_as(Sandbox::new(), indexed) } /// A server reading as `s`. fn serve_as(s: Sandbox, indexed: bool) -> Site { serve_with(s, indexed, false) } /// A server reading as `s`, searching code too if `code`, though not /// yet indexing it ([`knotless_serve::code::follow`]). fn serve_with(s: Sandbox, indexed: bool, code: bool) -> Site { let rt = tokio::runtime::Runtime::new().unwrap(); let listener = rt .block_on(tokio::net::TcpListener::bind("127.0.0.1:0")) .unwrap(); let base = format!("http://{}", listener.local_addr().unwrap()); let index = indexed.then(|| Index::open(&s.path("index.db")).unwrap()); let mut app: Arc = App::build(ctx(&s), Duration::ZERO, None, index); if code { let started = rt.block_on(Code::start(&s.path("code"))).unwrap(); Arc::get_mut(&mut app).unwrap().code = Some(Arc::new(started)); } let router = router(app.clone()); rt.spawn(async move { axum::serve(listener, router).await.unwrap() }); if indexed { rt.spawn(knotless_serve::firehose::watch( app.clone(), net().pds.replace("http://", "ws://"), )); } Site { base, app, _rt: rt, _sandbox: s, } } impl Site { fn get(&self, path: &str) -> Response { Client::new() .get(format!("{}{path}", self.base)) .send() .unwrap() } /// The answer at `path`, which must be there. An XRPC answer is kept /// too, where the run names a directory for them, for harness/ /// answers.mjs to hold to its lexicon. fn json(&self, path: &str) -> Value { let res = self.get(path); assert_eq!(res.status().as_u16(), 200, "{path}"); let answer: Value = res.json().unwrap(); if let Some(dir) = std::env::var_os("KNOTLESS_ANSWERS") && path.starts_with("/xrpc/") { keep(&dir, path, &answer); } answer } /// What the page at `path` shows. fn data(&self, path: &str) -> Value { let mut shown = self.json(&at(path)); if let Some(repo) = repo_query(path) { let (uri, _) = repo; shown["repo"] = self.json(&xrpc("getRepo", &[("repo", &uri)])); } shown } /// What the account `actor`'s page shows: who it is, as /// `dev.knotless.getProfile` says, and its repositories, as /// `dev.knotless.getActorRepos` does. fn profile(&self, actor: &str) -> Value { let mut shown = self.json(&format!("/xrpc/dev.knotless.getProfile?actor={actor}")); let held = self.json(&format!("/xrpc/dev.knotless.getActorRepos?actor={actor}")); shown["repos"] = held["repos"].clone(); shown } /// The repositories the index finds for `q`. fn search(&self, q: &str) -> Value { self.json(&format!("/xrpc/dev.knotless.listRepos?q={q}")) } /// What each of `actors`, by DID, did, back to `since`, page by page. /// Other tests write meanwhile, and pushes close together are one /// event only within a page, so the feed of all would cut them apart /// where its pages end. fn feed(&self, since: &str, actors: &[&str]) -> Value { let mut events = Vec::new(); for actor in actors { let mut cursor = String::new(); loop { let page = self.json(&format!( "/xrpc/dev.knotless.getFeed?limit=100&actor={actor}&cursor={}", cursor.replace(' ', "%20") )); let shown = page["events"].as_array().unwrap(); events.extend(shown.iter().cloned()); let last = shown.last().map(|e| e["createdAt"].as_str().unwrap()); match page["cursor"].as_str() { Some(next) if last.is_some_and(|at| at >= since) => cursor = next.into(), _ => break, } } } Value::Array(events) } /// What the procedure `nsid` of `dev.knotless`'s answers `input` with, /// and its status. An answer is kept as `json` keeps one. fn post(&self, nsid: &str, input: Value) -> (u16, Value) { let path = format!("/xrpc/dev.knotless.{nsid}"); let res = Client::new() .post(format!("{}{path}", self.base)) .json(&input) .send() .unwrap(); let status = res.status().as_u16(); let answer: Value = res.json().unwrap(); if let Some(dir) = std::env::var_os("KNOTLESS_ANSWERS") && status == 200 { keep(&dir, &path, &answer); } (status, answer) } fn status(&self, path: &str) -> (u16, String) { let res = self.get(path); (res.status().as_u16(), res.text().unwrap()) } } /// A card's PNG, which must be there, with its cache control. fn card(site: &Site, path: &str) -> (Vec, String) { let res = site.get(path); assert_eq!(res.status().as_u16(), 200, "{path}"); assert_eq!(res.headers()["content-type"], "image/png"); let cache = res.headers()["cache-control"].to_str().unwrap().to_string(); let png = res.bytes().unwrap().to_vec(); assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n"); // The width and height, from the header chunk. assert_eq!(png[16..24], [0, 0, 4, 176, 0, 0, 2, 118]); (png, cache) } /// The XRPC query `nsid` of `dev.knotless`'s, given `params`. /// Adds the answer at `path` to this process's file of them under `dir`, /// a line at a time, as the tests' threads answer. fn keep(dir: &std::ffi::OsStr, path: &str, answer: &Value) { use std::io::Write; static FILE: std::sync::Mutex<()> = std::sync::Mutex::new(()); let _held = FILE.lock().unwrap(); std::fs::create_dir_all(dir).unwrap(); let file = std::path::Path::new(dir).join(format!("{}.jsonl", std::process::id())); let mut out = std::fs::OpenOptions::new() .create(true) .append(true) .open(file) .unwrap(); let line = serde_json::json!({ "path": path, "body": answer }); writeln!(out, "{line}").unwrap(); } fn xrpc(nsid: &str, params: &[(&str, &str)]) -> String { let url = reqwest::Url::parse_with_params(&format!("http://x/xrpc/dev.knotless.{nsid}"), params); let url = url.unwrap(); format!("{}?{}", url.path(), url.query().unwrap_or_default()) } /// For the page of a repository at `page`, `//` and what /// follows, the repository's record, by its owner as the page names it, /// and the query that page asks: its tree, a file, its commits or one, /// its branches, its pulls or issues, one's thread, its merge queue, its /// runs, a check, or its runners. None for any other page. fn repo_query(page: &str) -> Option<(String, String)> { let (path, query) = page.split_once('?').unwrap_or((page, "")); let asked: Vec<(&str, &str)> = query.split('&').filter_map(|p| p.split_once('=')).collect(); let mut parts = path.trim_start_matches('/').splitn(4, '/'); let (owner, name) = (parts.next()?, parts.next()?); let (kind, rest) = (parts.next(), parts.next()); let mut item = rest.into_iter().flat_map(|r| r.splitn(2, '/')); let uri = format!("at://{owner}/dev.knotless.repo/{name}"); let repo = ("repo", uri.as_str()); let with = |nsid: &str, params: &[(&str, &str)]| xrpc(nsid, &[params, &asked].concat()); let query = match (kind, rest, (item.next(), item.next())) { (None, ..) => xrpc("getTree", &[repo]), (Some("tree"), Some(r), _) => xrpc("getTree", &[repo, ("ref", r)]), (Some("blob"), Some(r), _) => xrpc("getFile", &[repo, ("ref", r)]), (Some("commits"), Some(r), _) => with("listCommits", &[repo, ("ref", r)]), (Some("commit"), Some(sha), _) => { xrpc("getCommit", &[repo, ("sha", sha), ("firstRows", "true")]) } (Some(kind @ ("pulls" | "issues")), None, _) => { with("listItems", &[repo, ("kind", kind.trim_end_matches('s'))]) } (Some(kind @ ("pull" | "issue")), _, (Some(did), Some(rkey))) => { let item = format!("at://{did}/dev.knotless.{kind}/{rkey}"); xrpc("getThread", &[repo, ("uri", &item)]) } (Some("branches"), None, _) => xrpc("listBranches", &[repo, ("commits", "true")]), (Some("queue"), None, _) => xrpc("getQueue", &[repo]), (Some("checks"), None, _) => with("listRuns", &[repo]), (Some("settings"), Some("actions"), _) => xrpc("listRunners", &[repo]), (Some("check"), _, (Some(did), Some(rkey))) => { let check = format!("at://{did}/dev.knotless.check/{rkey}"); xrpc("getCheck", &[repo, ("uri", &check)]) } _ => return None, }; Some((uri, query)) } /// The page of the check `c`, as a view has it. fn check_page(c: &Value) -> String { let uri = c["uri"].as_str().unwrap_or_default(); let (runner, _, rkey) = knotless::forge::parse_uri(uri).unwrap(); let repo = &c["repo"]; format!( "/{}/{}/check/{runner}/{rkey}", repo["handle"].as_str().unwrap(), repo["name"].as_str().unwrap() ) } /// Where the page at `page` reads what it shows. fn at(page: &str) -> String { repo_query(page).expect("a repository's page").1 } /// A PNG of one color. fn one_color(rgb: [u8; 3]) -> Vec { let mut pixmap = resvg::tiny_skia::Pixmap::new(8, 8).unwrap(); pixmap.fill(resvg::tiny_skia::Color::from_rgba8( rgb[0], rgb[1], rgb[2], 255, )); knotless_serve::card::encode(&pixmap) } /// The color a PNG has at `x`, `y`. fn color_at(png: &[u8], x: u32, y: u32) -> [u8; 3] { let pixmap = resvg::tiny_skia::Pixmap::decode_png(png).unwrap(); let p = pixmap.pixel(x, y).unwrap(); [p.red(), p.green(), p.blue()] } fn person(account: Account, site: &Site) -> Sandbox { let mut s = Sandbox::with(account); s.set("KNOTLESS_PASSWORD", ""); s.set("KNOTLESS_APPVIEW", &site.base); let password = s.account.password.clone(); let handle = s.account.handle.clone(); assert_eq!( s.cli(&["login", &handle, "--password-stdin"], password.as_bytes()) .0, 0 ); s } fn ok(s: &Sandbox, args: &[&str]) -> String { let (code, out) = s.cli(args, b""); assert_eq!(code, 0, "{args:?}: {out}"); out } fn write(dir: &std::path::Path, path: &str, bytes: &[u8]) { let path = dir.join(path); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(path, bytes).unwrap(); } /// The entry named `name` among `entries`. fn named<'a>(entries: &'a Value, name: &str) -> &'a Value { entries .as_array() .unwrap() .iter() .find(|e| e["name"] == name) .unwrap_or_else(|| panic!("no {name} in {entries}")) } fn names(entries: &Value) -> Vec<&str> { entries .as_array() .unwrap() .iter() .map(|e| e["name"].as_str().unwrap()) .collect() } #[test] fn browsing_a_repository() { let site = serve(true); let s = person(account(), &site); let url = s.url("browsed"); let work = s.work("w", 51); write(&work, "README.md", b"# Browsed\n\n\n\n[guide](docs/guide.md) [docs](docs) [x](javascript:alert(1)) ![logo](logo.png)\n"); write(&work, "docs/README", b"plain \n"); write( &work, "docs/guide.md", b"# Guide\n\n\n\n[home](../README.md) [up](../../../x) [top](/src/main.rs#L2) [src](../src/) ![shot](shot.png)\n", ); write( &work, "src/main.rs", "// héllo\nfn main() {\n println!(\"hi\");\n}\n".as_bytes(), ); write(&work, "logo.png", b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR"); write(&work, "data.bin", &[0, 1, 2, 3]); write(&work, "big.txt", &vec![b'a'; 1100 * 1024]); write(&work, "notes.unknown-kind", b"no grammar\n"); s.git(&work, &["add", "-A"]); s.git( &work, &[ "commit", "-q", "-m", "Lay out the tree\n\nWith a body that says why.", ], ); let tip = s.git(&work, &["rev-parse", "HEAD"]); s.git(&work, &["push", "-q", &url, "main", "main:feature/x"]); // Tags: one plain, one annotated, and one of a tree, which names no // commit. s.git(&work, &["tag", "v2"]); s.git(&work, &["tag", "-a", "v1", "-m", "First"]); s.git(&work, &["tag", "tree", "HEAD^{tree}"]); s.git(&work, &["push", "-q", &url, "--tags"]); let h = &s.account.handle; let repo = format!("/{h}/browsed"); // The search finds it once the index has seen it. wait_until(|| names(&site.search("")["repos"]).contains(&"browsed")); let upper = site.search("BROWS"); assert!(names(&upper["repos"]).contains(&"browsed")); assert_eq!(upper["repos"][0]["owner"]["handle"], h.as_str()); assert_eq!(site.search("zzzz")["repos"], serde_json::json!([])); let res = site.get(&at(&repo)); assert_eq!(res.headers()["cache-control"], "no-cache"); let top = site.data(&repo); assert_eq!(top["empty"], Value::Null); assert_eq!(top["ref"], "main"); assert_eq!(top["repo"]["defaultBranch"], "main"); assert_eq!(top["repo"]["owner"]["handle"], h.as_str()); assert_eq!( top["repo"]["uri"], format!("at://{}/dev.knotless.repo/browsed", s.account.did) ); assert!( top["lastCommit"]["message"] .as_str() .unwrap() .starts_with("Lay out the tree") ); // Folders first, then files, each by name. let listed = names(&top["entries"]); assert!( listed.iter().position(|n| *n == "src") < listed.iter().position(|n| *n == "README.md") ); assert_eq!(named(&top["entries"], "docs")["kind"], "tree"); assert_eq!(named(&top["entries"], "data.bin")["size"], 4); // Written in Rust alone: the README, the docs and what no language // names are not counted. let rust = "// héllo\nfn main() {\n println!(\"hi\");\n}\n".len(); assert_eq!( top["languages"], serde_json::json!([{"name": "Rust", "color": "#dea584", "bytes": rust, "permille": 1000}]) ); let readme = top["readme"]["html"].as_str().unwrap(); for want in [ "

Browsed

", "<script>alert(1)</script>", &format!(r#"href="{repo}/blob/main/docs/guide.md""#), &format!(r#"href="{repo}/tree/main/docs""#), r##"href="#""##, &format!(r#"src="{repo}/raw/main/logo.png""#), ] { assert!(readme.contains(want), "{want} not in\n{readme}"); } // Each page names a card for whatever unfurls a link to it: cached // for good under the version it names, checked again under any other. assert_eq!(top["repo"]["description"], Value::Null); let named_card = top["repo"]["card"].as_str().unwrap().to_string(); let unversioned = format!("{repo}/card.png"); assert!( named_card.starts_with(&format!("{unversioned}?v=")), "{named_card}" ); let (png, cache) = card(&site, &named_card); assert_eq!(cache, "public, max-age=31536000, immutable"); assert_eq!(card(&site, &unversioned), (png, "no-cache".to_string())); assert_eq!( site.data(&format!("{repo}/commits/main"))["repo"]["card"], named_card.as_str() ); let tag = site.get(&named_card).headers()["etag"].clone(); let again = Client::new() .get(format!("{}{named_card}", site.base)) .header("if-none-match", tag.clone()) .send() .unwrap(); assert_eq!(again.status().as_u16(), 304); assert_eq!(again.headers()["etag"], tag); let (_, cache) = card(&site, "/card.png"); assert_eq!(cache, "no-cache"); // Compressed for those that ask. let res = Client::builder() .no_gzip() .no_brotli() .build() .unwrap() .get(format!("{}{}", site.base, at(&repo))) .header("accept-encoding", "br") .send() .unwrap(); assert_eq!(res.headers()["content-encoding"], "br"); // A branch with a slash in its name, and a pinned commit, whose answer is // still checked again: it shows the repository as it is now. let sub = site.data(&format!("{repo}/tree/feature/x/src")); assert_eq!( (sub["ref"].as_str(), sub["path"].as_str()), (Some("feature/x"), Some("src")) ); assert_eq!(names(&sub["entries"]), ["main.rs"]); // As a query names them apart, the ref and the path within it. let uri = top["repo"]["uri"].as_str().unwrap(); let apart = [("repo", uri), ("ref", "feature/x"), ("path", "src")]; let apart = site.json(&xrpc("getTree", &apart)); assert_eq!((&apart["ref"], &apart["path"]), (&sub["ref"], &sub["path"])); assert_eq!(sub["languages"], serde_json::json!([])); // The commit that last changed each entry, asked apart from the tree. let last = site.json(&xrpc("getLastCommits", &[("repo", uri)])); assert_eq!((&last["ref"], &last["path"]), (&top["ref"], &top["path"])); let sorted = |entries: &Value| { let mut all: Vec = names(entries).into_iter().map(String::from).collect(); all.sort(); all }; // In the tree's order, not the page's. assert_eq!(sorted(&last["entries"]), sorted(&top["entries"])); assert_eq!(named(&last["entries"], "src")["sha"], tip.as_str()); assert_eq!(last["commits"][0]["sha"], tip.as_str()); assert_eq!(last["commits"][0]["message"], top["lastCommit"]["message"]); // At the root, how long its history is; below it, not said. assert!(last["commitCount"].as_u64().is_some_and(|n| n >= 1)); let src = [("repo", uri), ("ref", "feature/x"), ("path", "src")]; let last = site.json(&xrpc("getLastCommits", &src)); assert_eq!(names(&last["entries"]), ["main.rs"]); assert!(last.get("commitCount").is_none()); let file = [("repo", uri), ("ref", "main"), ("path", "src/main.rs")]; let file = site.get(&xrpc("getLastCommits", &file)); assert_eq!(file.status().as_u16(), 404); // Every path, folders and files, to find a file by: the root's // first, so a limit leaves out the deepest. let paths = site.json(&xrpc("listPaths", &[("repo", uri)])); assert_eq!(paths["ref"], "main"); assert!(paths.get("truncated").is_none()); let all: Vec<(&str, &str)> = paths["paths"] .as_array() .unwrap() .iter() .map(|p| (p["path"].as_str().unwrap(), p["kind"].as_str().unwrap())) .collect(); for want in [ ("docs", "tree"), ("src/main.rs", "blob"), ("README.md", "blob"), ] { assert!(all.contains(&want), "{want:?} not in {all:?}"); } let deep = all.iter().position(|(p, _)| p.contains('/')).unwrap(); assert!(all[deep..].iter().all(|(p, _)| p.contains('/')), "{all:?}"); let some = [("repo", uri), ("ref", "feature/x"), ("limit", "3")]; let some = site.json(&xrpc("listPaths", &some)); assert_eq!(some["ref"], "feature/x"); assert_eq!(some["truncated"], true); assert_eq!(some["paths"].as_array().unwrap().len(), 3); let nowhere = [("repo", uri), ("ref", "no-such-branch")]; assert_eq!( site.get(&xrpc("listPaths", &nowhere)).status().as_u16(), 404 ); // Every branch, the default first, a page at a time. let branches = |params: &[(&str, &str)]| { site.json(&xrpc("listBranches", &[&[("repo", uri)], params].concat())) }; let all = branches(&[]); assert_eq!( all, serde_json::json!({"branches": [{"name": "main"}, {"name": "feature/x"}]}) ); let first = branches(&[("limit", "1")]); assert_eq!(first["branches"], serde_json::json!([{"name": "main"}])); assert_eq!(first["cursor"], "1"); let rest = branches(&[("limit", "1"), ("cursor", "1")]); assert_eq!(rest["branches"], serde_json::json!([{"name": "feature/x"}])); assert_eq!(rest["cursor"], Value::Null); let bad = site.get(&xrpc("listBranches", &[("repo", uri), ("cursor", "x")])); assert_eq!(bad.status().as_u16(), 400); // Asked, each with its tip and its checks, as the branches page shows. let shown = branches(&[("commits", "true")]); assert_eq!(shown["branches"][1]["name"], "feature/x"); assert_eq!(shown["branches"][1]["commit"]["sha"], tip.as_str()); assert_eq!(shown["branches"][1]["checks"], serde_json::json!([])); // Its tags that name a commit, an annotated one peeled: by name, as // both are of one commit. let tags = |params: &[(&str, &str)]| { site.json(&xrpc("listTags", &[&[("repo", uri)], params].concat())) }; assert_eq!( tags(&[]), serde_json::json!({"tags": [{"name": "v1"}, {"name": "v2"}]}) ); let first = tags(&[("commits", "true"), ("limit", "1")]); assert_eq!(first["tags"][0]["commit"]["sha"], tip.as_str()); assert_eq!(first["tags"][0]["checks"], serde_json::json!([])); assert_eq!(first["cursor"], "1"); let res = site.get(&at(&format!("{repo}/tree/{tip}/docs"))); assert_eq!(res.headers()["cache-control"], "no-cache"); let docs: Value = res.json().unwrap(); assert_eq!(docs["readme"]["text"], "plain \n"); assert_eq!(docs["readme"]["html"], Value::Null); let blob = |path: &str| site.data(&format!("{repo}/blob/main/{path}")); let file = blob("src/main.rs"); assert_eq!(file["kind"], "text"); assert_eq!(file["lines"], 4); assert!( file["ranges"].as_str().unwrap().starts_with("comment:0,8;"), "{file}" ); assert!( file["text"] .as_str() .unwrap() .starts_with("// héllo\nfn main()") ); assert_eq!(blob("notes.unknown-kind")["ranges"], ""); assert_eq!(file["html"], Value::Null); // A Markdown file comes made into HTML, its links from its own folder // and never out of the repository, and as text for the plain view. let guide = blob("docs/guide.md"); assert!(guide["text"].as_str().unwrap().starts_with("# Guide")); let html = guide["html"].as_str().unwrap(); for want in [ "

Guide

", "<img src=x onerror=alert(1)>", &format!(r#"href="{repo}/blob/main/README.md""#), &format!(r#"href="{repo}/blob/main/x""#), &format!(r#"href="{repo}/blob/main/src/main.rs#L2""#), &format!(r#"href="{repo}/tree/main/src""#), &format!(r#"src="{repo}/raw/main/docs/shot.png""#), ] { assert!(html.contains(want), "{want} not in\n{html}"); } assert_eq!(blob("logo.png")["kind"], "image"); assert_eq!(blob("data.bin")["kind"], "binary"); let big = blob("big.txt"); assert_eq!(big["kind"], "large"); assert_eq!(big["text"], Value::Null); assert_eq!(big["size"], 1100 * 1024); for (path, kind) in [ ("logo.png", "image/png"), ("data.bin", "application/octet-stream"), ("src/main.rs", "text/plain; charset=utf-8"), ] { let res = site.get(&format!("{repo}/raw/main/{path}")); assert_eq!(res.headers()["content-type"], kind); assert!( res.headers()["content-security-policy"] .to_str() .unwrap() .starts_with("sandbox") ); } // A file at a commit's hash is the same forever; at a branch, it can move. let cached = |rev: &str| { site.get(&format!("{repo}/raw/{rev}/src/main.rs")).headers()["cache-control"].clone() }; assert_eq!(cached(tip.trim()), "public, max-age=31536000, immutable"); assert_eq!(cached("main"), "no-cache"); // The files at a revision as one download, in a folder named for the // repository and the revision. let archive = |rest: &str| site.get(&format!("{repo}/archive/{rest}")); let header = |res: &Response, name: &str| res.headers()[name].to_str().unwrap().to_string(); let tarred = archive("main.tar.gz"); assert_eq!(tarred.status(), 200); assert_eq!(header(&tarred, "content-type"), "application/gzip"); assert_eq!(header(&tarred, "cache-control"), "no-cache"); assert_eq!( header(&tarred, "content-disposition"), "attachment; filename=\"browsed-main.tar.gz\"" ); let mut tar = Vec::new(); std::io::Read::read_to_end( &mut flate2::read::GzDecoder::new(&tarred.bytes().unwrap()[..]), &mut tar, ) .unwrap(); let holds = |bytes: &[u8], name: &str| bytes.windows(name.len()).any(|w| w == name.as_bytes()); assert!(holds(&tar, "browsed-main/src/main.rs")); assert!(holds(&tar, "println!(\"hi\");")); // A branch's `/` is left out of the names; a tag's archive, as a // commit's, never changes. let branch = archive("feature/x.zip"); assert_eq!( header(&branch, "content-disposition"), "attachment; filename=\"browsed-feature-x.zip\"" ); let zip = branch.bytes().unwrap(); assert!(zip.starts_with(b"PK\x03\x04")); assert!(holds(&zip, "browsed-feature-x/src/main.rs")); let pinned = archive(&format!("{}.zip", tip.trim())); assert_eq!(header(&pinned, "content-type"), "application/zip"); assert_eq!( header(&pinned, "cache-control"), "public, max-age=31536000, immutable" ); assert_eq!(archive("v1.tar.gz").status(), 200); // Only the two kinds, of a revision naming a commit. for missing in ["main.rar", "main", "nope.zip", "tree.zip", "-x.zip"] { assert_eq!(archive(missing).status(), 404, "{missing}"); } // Its commits and tags as Atom feeds, the newest first, linked to // their pages in full. let feed = |name: &str| { let res = site.get(&format!("{repo}/{name}.atom")); assert_eq!(res.status(), 200, "{name}"); let kind = res.headers()["content-type"].to_str().unwrap().to_string(); assert_eq!(kind, "application/atom+xml; charset=utf-8"); res.text().unwrap() }; let commits = feed("commits"); assert_eq!(commits.matches("").count(), 30); assert!(commits.contains(&format!("Commits to {h}/browsed"))); assert!(commits.contains(&format!("{}{repo}/commits/main\"", site.base))); let first = &commits[commits.find("").unwrap()..]; assert!(first.contains("Lay out the tree")); assert!(first.contains(&format!( "{}{repo}/commit/{}", site.base, tip.trim() ))); assert!(first.contains( "Lay out the tree\n\nWith a body that says why." )); let tagged = feed("tags"); assert_eq!(tagged.matches("").count(), 2); assert!(tagged.contains("v1")); assert!(tagged.contains(&format!("href=\"{}{repo}/tree/v2\"", site.base))); // v1 and v2 tag one commit, and are two entries all the same. for tag in ["v1", "v2"] { let id = format!("{}{repo}/tree/{tag}", site.base); assert!(tagged.contains(&id), "{tag}"); } // With nothing released, a feed of no entries. let none = feed("releases"); assert!(!none.contains("")); assert!(none.contains("1970-01-01T00:00:00Z")); let log = site.data(&format!("{repo}/commits/main")); assert_eq!(log["commits"].as_array().unwrap().len(), 50); assert_eq!(log["cursor"], "50"); assert_eq!( log["commits"][0]["message"], "Lay out the tree\n\nWith a body that says why." ); // The commits page asks for each message's subject alone. let subjects = site.data(&format!("{repo}/commits/main?subjectOnly=true")); assert_eq!(subjects["commits"][0]["message"], "Lay out the tree"); let older = site.data(&format!("{repo}/commits/main?cursor=50")); assert_eq!(older["commits"].as_array().unwrap().len(), 2); assert_eq!(older["cursor"], Value::Null); // As many as asked, from 1 to 100. let limited = |limit: &str| { let uri = top["repo"]["uri"].as_str().unwrap(); site.json(&xrpc("listCommits", &[("repo", uri), ("limit", limit)])) }; let one = limited("0"); assert_eq!(one["commits"].as_array().unwrap().len(), 1); assert_eq!(one["cursor"], "1"); let all = limited("1000"); assert_eq!(all["commits"].as_array().unwrap().len(), 52); assert_eq!(all["cursor"], Value::Null); let bad = site.get(&format!("{}&cursor=x", at(&format!("{repo}/commits/main")))); assert_eq!(bad.status().as_u16(), 400); assert_eq!(bad.json::().unwrap()["error"], "InvalidRequest"); let src = site.data(&format!("{repo}/commits/main/src")); assert_eq!(src["commits"].as_array().unwrap().len(), 1); assert_eq!(src["path"], "src"); assert_eq!( site.data(&format!("{repo}/commits/main/nowhere"))["commits"], serde_json::json!([]) ); let commit = site.data(&format!("{repo}/commit/{tip}")); assert_eq!(commit["commit"]["sha"], tip.as_str()); assert_eq!( commit["commit"]["message"], "Lay out the tree\n\nWith a body that says why." ); assert_eq!(commit["commit"]["parents"].as_array().unwrap().len(), 1); let files = commit["files"].as_array().unwrap(); let main = files.iter().find(|f| f["new"] == "src/main.rs").unwrap(); assert_eq!( (main["status"].as_str(), main["added"].as_u64()), (Some("added"), Some(4)) ); assert!(main["ranges"].as_str().unwrap().starts_with("comment:0,8;")); let last = main["rows"].as_array().unwrap().last().unwrap(); assert_eq!( ( last["kind"].as_str(), last["new"].as_u64(), last["text"].as_str() ), (Some("a"), Some(4), Some("}")) ); // A commit shows as its author's account when its address is one the // repository's own people claim: the first push claimed git's. One // knotless made names its account; one by any other address is no // one's. So with co-authors, whom its trailers name. assert_eq!(commit["commit"]["author"]["actor"]["handle"], h.as_str()); assert_eq!(top["lastCommit"]["author"]["actor"]["handle"], h.as_str()); let made = |message: &str, author: &[&str]| { let args = [ &[ "repo", "commit", &url, "-b", "made", "-m", message, "--put", "made", "-", ], author, ] .concat(); let (code, sha) = s.cli(&args, message.as_bytes()); assert_eq!(code, 0, "{sha}"); let at = format!("{repo}/commit/{}", sha.trim()); wait_until(|| site.status(&self::at(&at)).0 == 200); site.data(&at)["commit"].clone() }; let ours = made("Made", &[]); assert_eq!(ours["author"]["actor"]["handle"], h.as_str()); assert_eq!(ours["coauthors"], serde_json::json!([])); // Made and written by the same one, it names no committer of its own. assert_eq!(ours["committer"], Value::Null); assert!(ours["committedAt"].as_str().unwrap().contains('T')); let together = "Together\n\nCo-authored-by: Claude \nCo-authored-by: Me "; let theirs = made(together, &["--author", "Someone", "someone@example.org"]); let (author, helped) = (&theirs["author"], &theirs["coauthors"]); assert_eq!( (&author["name"], &author["actor"]), (&Value::from("Someone"), &Value::Null) ); assert_eq!( (&helped[0]["name"], &helped[0]["actor"], &helped[0]["agent"]), (&Value::from("Claude"), &Value::Null, &Value::from("claude")) ); assert_eq!(helped[1]["agent"], Value::Null); assert_eq!( (&helped[1]["name"], &helped[1]["actor"]["handle"]), (&Value::from("Me"), &Value::from(h.as_str())) ); let first = s.git(&work, &["rev-list", "--max-parents=0", "HEAD"]); assert_eq!( site.data(&format!("{repo}/commit/{first}"))["commit"]["parents"], serde_json::json!([]) ); for missing in [ at(&format!( "{repo}/commit/0000000000000000000000000000000000000000" )), at(&format!("{repo}/tree/main/nope")), at(&format!("{repo}/blob/main/nope")), format!("{repo}/raw/main/nope"), at(&format!("{repo}/commits/nope")), at(&format!("{repo}/tree/-x")), at(&format!("{repo}/commit/-x")), at(&format!("{repo}/pull/did:plc:x/y")), at(&format!("/{h}/missing")), ] { let res = site.get(&missing); let status = res.status().as_u16(); assert!(status == 404 || status == 502, "{missing}: {status}"); assert_eq!(res.headers()["cache-control"], "no-store", "{missing}"); // As XRPC has a failure: its name, and why. let failure: Value = res.json().unwrap(); let named = if status == 404 { "NotFound" } else { "UpstreamFailure" }; assert_eq!(failure["error"], named, "{missing}"); assert!(failure["message"].is_string(), "{missing}"); } // A scanner's guess at a page names no one, and is not the PDS failing. let probe = at("/wp-admin/install.php"); let res = site.get(&probe); assert_eq!(res.status().as_u16(), 404, "{probe}"); assert_eq!(res.json::().unwrap()["error"], "NotFound"); // A repository with nothing in it yet. ok(&s, &["repo", "create", &s.url("empty")]); let empty = site.data(&format!("/{h}/empty")); assert_eq!(empty["empty"], true); assert_eq!(empty["entries"], serde_json::json!([])); // Its default branch, which the pages link to before it exists, is // as empty, not missing. let tree = site.data(&format!("/{h}/empty/tree/main")); assert_eq!(tree["empty"], true); let history = site.data(&format!("/{h}/empty/commits/main")); assert_eq!(history["commits"], serde_json::json!([])); assert_eq!(history["ref"], "main"); assert_eq!(history.get("cursor"), None); let feed = site .get(&format!("/{h}/empty/commits.atom")) .text() .unwrap(); assert!(feed.contains("")); // Anything else there is not found. let other = site.get(&at(&format!("/{h}/empty/commits/other"))); assert_eq!(other.status().as_u16(), 404); // A query of a repository names it by its record, and asks what it // holds. let browsed = format!("at://{h}/dev.knotless.repo/browsed"); for bad in [ xrpc("getRepo", &[("repo", "knotless://x/y")]), xrpc( "getTree", &[("repo", &format!("at://{h}/dev.knotless.pull/x"))], ), xrpc( "getCommit", &[("repo", &format!("at://{h}/dev.knotless.repo/browsed"))], ), xrpc("listItems", &[("repo", &browsed), ("kind", "wiki")]), xrpc( "listItems", &[("repo", &browsed), ("kind", "pull"), ("cursor", "x")], ), xrpc("getThread", &[("repo", &browsed), ("uri", &browsed)]), xrpc("getCheck", &[("repo", &browsed), ("uri", &browsed)]), ] { let res = site.get(&bad); assert_eq!(res.status().as_u16(), 400, "{bad}"); assert_eq!(res.json::().unwrap()["error"], "InvalidRequest"); } } /// A merge commit's page shows what it brought in, against its first /// parent, and not what its branch did meanwhile. #[test] fn a_merge_commit_shows_what_it_brought_in() { let site = serve(true); let s = person(account(), &site); let url = s.url("merged"); let work = s.work("w", 0); write(&work, "begun.txt", b"begun\n"); s.git(&work, &["add", "-A"]); s.git(&work, &["commit", "-q", "-m", "Begin"]); s.git(&work, &["checkout", "-q", "-b", "topic"]); write(&work, "brought.txt", b"brought\n"); s.git(&work, &["add", "-A"]); s.git(&work, &["commit", "-q", "-m", "Bring"]); s.git(&work, &["checkout", "-q", "main"]); write(&work, "meanwhile.txt", b"meanwhile\n"); s.git(&work, &["add", "-A"]); s.git(&work, &["commit", "-q", "-m", "Meanwhile"]); s.git( &work, &["merge", "-q", "--no-ff", "-m", "Merge topic", "topic"], ); let merge = s.git(&work, &["rev-parse", "HEAD"]); s.git(&work, &["push", "-q", &url, "main"]); let uri = format!("at://{}/dev.knotless.repo/merged", s.account.did); let commit = xrpc("getCommit", &[("repo", &uri), ("sha", &merge)]); wait_until(|| site.status(&commit).0 == 200); let change = site.json(&commit); let files = change["files"].as_array().unwrap(); assert_eq!(files.len(), 1, "{change}"); assert_eq!(files[0]["new"], "brought.txt"); assert_eq!(files[0]["added"], 1); // Asked for its first rows, a few files come with them, as a page // draws them at once, and the rest without, but with how many. for i in 0..12 { write(&work, &format!("many/{i:02}.txt"), b"one\n"); } s.git(&work, &["add", "-A"]); s.git(&work, &["commit", "-q", "-m", "Many"]); let many = s.git(&work, &["rev-parse", "HEAD"]); s.git(&work, &["push", "-q", &url, "main"]); let first = xrpc( "getCommit", &[("repo", &uri), ("sha", &many), ("firstRows", "true")], ); wait_until(|| site.status(&first).0 == 200); let first = site.json(&first); let rows = |f: &Value| f["rows"].as_array().unwrap().len(); let files = first["files"].as_array().unwrap(); assert_eq!(files.len(), 12, "{first}"); assert!(files[..10].iter().all(|f| rows(f) > 0), "{first}"); assert_eq!(rows(&files[10]), 0); assert!(files[10]["rowCount"].as_u64().unwrap() > 0, "{first}"); // Those it named, rows and all. let named = [ ("repo", uri.as_str()), ("sha", many.as_str()), ("paths", "many/10.txt"), ("paths", "many/11.txt"), ("firstRows", "true"), ]; let named = site.json(&xrpc("getCommit", &named)); let files = named["files"].as_array().unwrap(); assert_eq!(files.len(), 2, "{named}"); assert_eq!(files[1]["new"], "many/11.txt"); assert!(rows(&files[1]) > 0, "{named}"); // No more of them than a page of them. let path = ("paths", "many/00.txt"); let too_many = [ &[("repo", uri.as_str()), ("sha", many.as_str())][..], &[path; 51][..], ] .concat(); let (code, body) = site.status(&xrpc("getCommit", &too_many)); assert_eq!(code, 400, "{body}"); } /// Who last changed each line of a file, as the blame page shows it. #[test] fn blaming_a_file() { use serde_json::json; let site = serve(true); let s = person(account(), &site); let url = s.url("blamed"); let work = s.work("w", 0); write(&work, "src/lib.rs", b"fn a() {}\nfn b() {}\nfn c() {}\n"); write(&work, "data.bin", &[0, 1, 2, 3]); write(&work, "big.txt", "line\n".repeat(60 * 1024).as_bytes()); s.git(&work, &["add", "-A"]); s.git(&work, &["commit", "-q", "-m", "Begin\n\nWith a body."]); let first = s.git(&work, &["rev-parse", "HEAD"]); write(&work, "src/lib.rs", b"fn a() {}\nfn B() {}\nfn c() {}\n"); let someone = ["--author", "Someone "]; s.git( &work, &[&["commit", "-q", "-am", "Shout b"][..], &someone[..]].concat(), ); let second = s.git(&work, &["rev-parse", "HEAD"]); s.git(&work, &["push", "-q", &url, "main"]); let h = &s.account.handle; let uri = format!("at://{}/dev.knotless.repo/blamed", s.account.did); let blame = |params: &[(&str, &str)]| xrpc("getBlame", &[&[("repo", uri.as_str())], params].concat()); let lib = blame(&[("ref", "main"), ("path", "src/lib.rs")]); wait_until(|| site.status(&lib).0 == 200); let b = site.json(&lib); assert_eq!( (&b["ref"], &b["path"]), (&json!("main"), &json!("src/lib.rs")) ); assert_eq!(b["lines"], 3); assert!(b["text"].as_str().unwrap().starts_with("fn a() {}\n")); assert!(!b["ranges"].as_str().unwrap().is_empty(), "{b}"); // In runs, from the first line on: the line changed since, between // two from where the file began, which blame goes on back past. assert_eq!( b["hunks"], json!([ {"sha": first, "start": 1, "count": 1}, {"sha": second, "start": 2, "count": 1, "previous": {"sha": first, "path": "src/lib.rs"}}, {"sha": first, "start": 3, "count": 1}, ]) ); // Each commit once, as the file first names it, by account where its // address is one the repository's own people claim. let commits = b["commits"].as_array().unwrap(); assert_eq!(commits.len(), 2); assert_eq!(commits[0]["sha"], first.as_str()); assert_eq!(commits[0]["message"], "Begin\n\nWith a body."); assert_eq!(commits[0]["author"]["actor"]["handle"], h.as_str()); let them = &commits[1]["author"]; assert_eq!( (&them["name"], &them["actor"]), (&json!("Someone"), &Value::Null) ); // Before the change, as the page links it, the ref running on into // the path; and once more, as kept. let at_first = format!("{first}/src/lib.rs"); let before = blame(&[("ref", at_first.as_str())]); let whole = json!([{"sha": first, "start": 1, "count": 3}]); assert_eq!(site.json(&before)["hunks"], whole); assert_eq!(site.json(&before)["hunks"], whole); // Not a binary, nor a file too large, and not what is not there. for (path, status, why) in [ ("data.bin", 400, "binary"), ("big.txt", 400, "too large"), ("nope", 404, "nope"), ] { let res = site.get(&blame(&[("ref", "main"), ("path", path)])); assert_eq!(res.status().as_u16(), status, "{path}"); let failure: Value = res.json().unwrap(); let message = failure["message"].as_str().unwrap(); assert!(message.contains(why), "{path}: {message}"); } } #[test] fn a_copy_shows_whom_the_original_vouched_for() { let site = serve(true); let owner = person(account(), &site); let url = owner.url("original"); ok(&owner, &["create", &url]); let work = owner.work("w", 2); // The first push claims git's address as the owner's. owner.git(&work, &["push", "-q", &url, "main"]); let first = owner.git(&work, &["rev-parse", "HEAD^"]); let o = Value::from(owner.account.handle.as_str()); let (copier, taker) = (person(account(), &site), person(account(), &site)); let author = |who: &Sandbox, name: &str, sha: &str| { let at = format!("/{}/{name}/commit/{sha}", who.account.handle); wait_until(|| site.status(&self::at(&at)).0 == 200); site.data(&at)["commit"]["author"]["actor"]["handle"].clone() }; // A copy, pushed as a repository of its own rather than forked, by // one who claims another address, shows the owner's commits as no // one's until the original's pages have walked them, then as the // owner's: the original's owner vouched for them there. ok(&copier, &["email", "add", "copier@example.com"]); let copy = copier.url("copy"); ok(&copier, &["create", ©]); copier.git(&work, &["push", "-q", ©, "main"]); assert_eq!(author(&copier, "copy", &first), Value::Null); assert_eq!(author(&owner, "original", &first), o); wait_until(|| author(&copier, "copy", &first) == o); // Shown again with nothing new, the walk has nothing to do; a commit // pushed since is walked on from where the last stopped. assert_eq!(author(&owner, "original", &first), o); let tip = owner.commit_file(&work, "g", "g\n"); owner.git(&work, &["push", "-q", &url, "main"]); copier.git(&work, &["push", "-q", ©, "main"]); assert_eq!(author(&owner, "original", &tip), o); wait_until(|| author(&copier, "copy", &tip) == o); // One who copies them and claims the owner's address, as their own // first push does, has them shown as theirs only in their own // repository. Elsewhere the two disagree, and the copy shows the // commits as no one's rather than either's. let taken = taker.url("taken"); ok(&taker, &["create", &taken]); taker.git(&work, &["push", "-q", &taken, "main"]); let t = Value::from(taker.account.handle.as_str()); wait_until(|| author(&taker, "taken", &first) == t); wait_until(|| author(&copier, "copy", &first) == Value::Null); assert_eq!(author(&owner, "original", &first), o); } #[test] fn comments_on_lines() { let site = serve(true); let owner = person(account(), &site); let other = person(account(), &site); let url = owner.url("lined"); ok(&owner, &["repo", "create", &url]); let work = owner.work("w", 1); let numbered = |five: &str| format!("one\ntwo\nthree\nfour\n{five}\nsix\n"); owner.commit_file(&work, "a.rs", &numbered("five")); owner.git(&work, &["push", "-q", &url, "main"]); let fork = ok(&other, &["repo", "fork", &url]); let clone = other.path("clone"); other.git(other.dir.path(), &["clone", "-q", &fork, "clone"]); other.commit_file(&clone, "a.rs", &numbered("FIVE")); other.git(&clone, &["push", "-q", "origin", "main:topic"]); let open = ["pull", "create", &url, "main", &fork, "topic", "Shout"]; let pull = ok(&other, &open); let pull = pull.trim(); let issue = ok(&other, &["issue", "create", &url, "Quiet"]); // Its author says something of a line of their own, on its own. let own = ok( &other, &[ "comment", "add", &url, pull, "", "--line", "a.rs:5", "Louder", ], ); assert_eq!(own.lines().count(), 1); // A review writes itself and its comments on lines at once. let review = [ "comment", "add", &url, pull, "A few things", "--changes", "--line", "a.rs:4-5", "These", "--line", "a.rs:5:old", "Was quiet", "--line", "gone.rs:1", "Where", ]; let wrote = ok(&owner, &review); let wrote: Vec<&str> = wrote.lines().collect(); let [said, these, was, gone] = wrote[..] else { panic!("{wrote:?}") }; let fails = |s: &Sandbox, args: &[&str]| assert_ne!(s.cli(args, b"").0, 0, "{args:?}"); fails( &owner, &[ "comment", "add", &url, pull, "", "--line", "a.rs", "No line", ], ); fails( &owner, &[ "comment", "add", &url, pull, "", "--line", "a.rs:0", "Before it", ], ); fails( &owner, &["comment", "add", &url, pull, "", "--line", "a.rs:1", " "], ); fails( &owner, &[ "comment", "add", &url, issue.trim(), "", "--line", "a.rs:1", "An issue", ], ); fails(&owner, &["comment", "add", &url, these, "No", "--approve"]); let unknown = format!( "at://{}/dev.knotless.comment/3zzzzzzzzzzzz", owner.account.did ); fails(&owner, &["comment", "add", &url, &unknown, "Who?"]); // An answer to an answer goes in the thread it answers. let answer = ok(&other, &["comment", "add", &url, these, "Done"]); let again = ok(&owner, &["comment", "add", &url, answer.trim(), "Thanks"]); // The pull's author resolves a thread; none but a thread is resolved. assert_eq!( ok(&other, &["comment", "resolve", &url, these]).trim(), "resolved" ); assert_eq!( ok(&owner, &["comment", "resolve", &url, was]).trim(), "resolved" ); assert_eq!(ok(&owner, &["pull", "reopen", &url, was]).trim(), "open"); for not in [said, answer.trim(), pull, &unknown] { fails(&owner, &["comment", "resolve", &url, not]); } fails(&owner, &["pull", "close", &url, these]); let shown = ok(&owner, &["pull", "show", &url, pull]); assert!( shown.contains(&format!(" on a.rs:4-5 ({these}, resolved): These")), "{shown}" ); assert!( shown.contains(&format!(" on a.rs:5:old ({was}): Was quiet")), "{shown}" ); assert!( shown.contains(&format!(" answering {these}: Thanks")), "{shown}" ); let rkey = |uri: &str| uri.rsplit('/').next().unwrap().to_string(); let thread = format!( "/{}/lined/pull/{}/{}", owner.account.handle, other.account.did, rkey(pull) ); let comment = |d: &Value, uri: &str| -> Value { let all = d["comments"].as_array().unwrap(); all.iter() .find(|c| c["uri"] == uri) .cloned() .unwrap_or(Value::Null) }; let all_said = |d: &Value| comment(d, again.trim()).is_object() && comment(d, these)["resolved"] == true; wait_until(|| all_said(&site.data(&thread))); let d = site.data(&thread); let line = |uri: &str| comment(&d, uri)["line"].clone(); let text = |l: &Value| -> Vec<(u64, String)> { // None when there are none. let lines = l["lines"].as_array().into_iter().flatten(); lines .map(|t| { ( t["number"].as_u64().unwrap(), t["text"].as_str().unwrap().to_string(), ) }) .collect() }; assert_eq!(text(&line(these)), [(4, "four".into()), (5, "FIVE".into())]); assert_eq!( ( line(these)["startLine"].as_u64(), line(these)["outdated"].as_bool() ), (Some(4), Some(false)) ); let quiet: Vec<(u64, String)> = vec![ (2, "two".into()), (3, "three".into()), (4, "four".into()), (5, "five".into()), ]; assert_eq!( (text(&line(was)), line(was)["side"].as_str()), (quiet.clone(), Some("old")) ); assert_eq!( (line(gone)["outdated"].as_bool(), text(&line(gone))), (Some(true), vec![]) ); assert_eq!(comment(&d, own.trim())["review"], Value::Null); assert_eq!(comment(&d, these)["review"], said); assert_eq!(comment(&d, again.trim())["reply"], these); assert!(comment(&d, was)["resolved"].is_null()); // Once the pull changes those lines, what was said of them is outdated; // the side it leaves stands. other.commit_file(&clone, "a.rs", &numbered("Five")); other.git(&clone, &["push", "-q", "origin", "main:topic"]); let moved = |d: &Value| comment(d, these)["line"]["outdated"] == true; wait_until(|| moved(&site.data(&thread))); // Merged, it still shows them, from where the pull came. ok(&owner, &["pull", "merge", &url, pull]); wait_until(|| site.data(&thread)["merged"].is_string()); let d = site.data(&thread); assert_eq!(comment(&d, these)["line"]["outdated"], true); assert_eq!( ( text(&comment(&d, was)["line"]), comment(&d, was)["line"]["outdated"].as_bool() ), (quiet, Some(false)) ); } #[test] fn pulls_and_issues() { let site = serve(true); let owner = person(account(), &site); let other = person(account(), &site); let url = owner.url("tracked"); ok( &owner, &["repo", "create", &url, "--description", "Tracks "], ); let work = owner.work("w", 1); owner.git(&work, &["push", "-q", &url, "main"]); let repo = format!("/{}/tracked", owner.account.handle); let fork = ok(&other, &["repo", "fork", &url]); let clone = other.path("clone"); other.git(other.dir.path(), &["clone", "-q", &fork, "clone"]); ok(&other, &["account", "email", "add", "other@example.com"]); write(&clone, "b", b"b\n"); other.git(&clone, &["add", "b"]); let as_other = [ "commit", "-q", "--author", "Other ", "-m", "b", ]; other.git(&clone, &as_other); let brought = other.git(&clone, &["rev-parse", "HEAD"]); other.git(&clone, &["push", "-q", "origin", "main:topic"]); let pull = ok( &other, &[ "pull", "create", &url, "main", &fork, "topic", "Add b", "-m", "Adds **b**.", ], ); let issue = ok(&other, &["issue", "create", &url, "Broken "]); let quiet = ok(&other, &["issue", "create", &url, "Quiet"]); ok( &owner, &["comment", "add", &url, &pull, "Not yet", "--changes"], ); ok( &owner, &["comment", "add", &url, &pull, "Fixed", "--approve"], ); ok(&owner, &["comment", "add", &url, &pull, "Thanks"]); ok(&owner, &["pull", "close", &url, &quiet]); let rkey = |uri: &str| uri.rsplit('/').next().unwrap().to_string(); let other_did = &other.account.did; let thread = format!("{repo}/pull/{other_did}/{}", rkey(&pull)); // Open, it shows what its branch in the fork brings in. let changed = |d: &Value| -> Vec<(String, u64)> { d["changes"]["files"] .as_array() .unwrap() .iter() .map(|f| { ( f["new"].as_str().unwrap().into(), f["added"].as_u64().unwrap(), ) }) .collect() }; wait_until(|| site.data(&thread)["changes"].is_object()); let open = site.data(&thread); assert_eq!(changed(&open), [("b".to_string(), 1)]); // Asked for its files only, they come without their rows, which come // a few files at a time. let tracked = format!("at://{}/dev.knotless.repo/tracked", owner.account.did); let (tracked, uri) = (tracked.as_str(), pull.trim()); let files_only = [("repo", tracked), ("uri", uri), ("filesOnly", "true")]; let listed = site.json(&xrpc("getThread", &files_only)); let b = &listed["changes"]["files"][0]; assert_eq!( (b["new"].as_str(), b["added"].as_u64()), (Some("b"), Some(1)) ); assert_eq!(b["rows"], serde_json::json!([])); // With how many rows are to come, and hunks among them, to keep room for. assert!(b["rowCount"].as_u64().unwrap() >= 2, "{b}"); assert_eq!(b["hunkCount"], 1); let diff = |params: &[(&str, &str)]| site.status(&xrpc("getPullDiff", params)); let both = [ ("repo", tracked), ("uri", uri), ("paths", "b"), ("paths", "c"), ]; let (code, body) = diff(&both); assert_eq!(code, 200, "{body}"); let rows: Value = serde_json::from_str(&body).unwrap(); let files = rows["files"].as_array().unwrap(); assert_eq!(files.len(), 1); assert!( files[0]["rows"] .as_array() .unwrap() .iter() .any(|r| r["kind"] == "a") ); // Asked for the first rows too, the first files come with them, as // getPullDiff gives them. let first = [ ("repo", tracked), ("uri", uri), ("filesOnly", "true"), ("firstRows", "true"), ]; let first = site.json(&xrpc("getThread", &first)); assert_eq!(first["changes"]["files"][0]["rows"], files[0]["rows"]); // It needs a pull, and a file of it to show. assert_eq!(diff(&[("repo", tracked), ("paths", "b")]).0, 400); assert_eq!(diff(&[("repo", tracked), ("uri", uri)]).0, 400); let not_a_pull = [("repo", tracked), ("uri", issue.trim()), ("paths", "b")]; assert_eq!(diff(¬_a_pull).0, 400); // Its commits are the fork's, shown on the fork's pages. let c = &open["changes"]; assert_eq!(c["commits"].as_array().unwrap().len(), 1); assert_eq!(c["commits"][0]["sha"], brought.as_str()); assert_eq!( (c["repo"]["handle"].as_str(), c["repo"]["name"].as_str()), (Some(other.account.handle.as_str()), Some("tracked")) ); // Open, only its checks of the head it is at show: one of an older // head is not, though it ran later. ok(&owner, &["runner", "add", &url, &owner.account.handle]); let tracked = serde_json::json!({ "uri": format!("at://{}/dev.knotless.repo/tracked", owner.account.did), "key": owner.record("dev.knotless.repo", "tracked")["key"].clone(), }); // Keyed before the checks below, which are to be the newest once it // is merged, and the older head's after the head's, to be its newest. for (rkey, sha, status, at) in [ ( "a0", brought.as_str(), "running", "2026-10-19T00:00:01.000Z", ), ( "a1", &"0".repeat(40)[..], "failure", "2026-10-19T00:00:02.000Z", ), ] { owner.put_record( "dev.knotless.check", rkey, serde_json::json!({ "$type": "dev.knotless.check", "repo": tracked.clone(), "ref": "refs/heads/topic", "commit": sha, "pull": pull.trim(), "workflow": "pulled", "status": status, "startedAt": at, }), ); } let older = at(&format!("{repo}/check/{}/a1", owner.account.did)); wait_until(|| site.status(&older).0 == 200); wait_until(|| site.data(&thread)["checks"].as_array().unwrap().len() == 1); let head = site.data(&thread)["checks"][0].clone(); assert_eq!( (head["status"].as_str(), head["commit"].as_str()), (Some("running"), Some(brought.as_str())) ); // Queued, open, it says so. let key = owner.record("dev.knotless.repo", "tracked")["key"].clone(); let queued = serde_json::json!({ "$type": "dev.knotless.queue", "subject": pull, "state": "queued", "repo": {"uri": format!("at://{}/dev.knotless.repo/tracked", owner.account.did), "key": key}, "createdAt": "2026-10-21T00:00:00.000Z", }); owner.put_record("dev.knotless.queue", &knotless::keys::tid(), queued); wait_until(|| site.data(&thread)["queue"]["state"] == "queued"); assert_eq!( site.data(&thread)["queue"]["createdAt"], "2026-10-21T00:00:00.000Z" ); let merged = ok(&owner, &["pull", "merge", &url, &pull]); wait_until(|| site.data(&thread)["merged"].is_string()); assert!(site.data(&thread)["queue"].is_null()); // Merged, what the merge brought, shown on the base's pages. let done = site.data(&thread); assert_eq!(changed(&done), [("b".to_string(), 1)]); let shas: Vec<&str> = done["changes"]["commits"] .as_array() .unwrap() .iter() .map(|c| c["sha"].as_str().unwrap()) .collect(); assert!(shas.contains(&brought.as_str()), "{shas:?}"); assert_eq!(done["changes"]["repo"]["did"], owner.account.did.as_str()); // What the pull brought in shows as its author's: the merge vouched for // it. Their address anywhere else is no one's. let theirs = site.data(&format!("{repo}/commit/{brought}")); assert_eq!( theirs["commit"]["author"]["actor"]["handle"], other.account.handle.as_str() ); let args = [ "repo", "commit", &url, "-b", "aside", "-m", "Not theirs", "--put", "c", "-", ]; let author = ["--author", "Other", "other@example.com"]; let (code, sha) = owner.cli(&[&args[..], &author].concat(), b"c\n"); assert_eq!(code, 0, "{sha}"); let aside = at(&format!("{repo}/commit/{}", sha.trim())); wait_until(|| site.status(&aside).0 == 200); assert_eq!(site.json(&aside)["commit"]["author"]["actor"], Value::Null); let d = site.data(&thread); assert_eq!( (d["item"]["kind"].as_str(), d["item"]["title"].as_str()), (Some("pull"), Some("Add b")) ); assert_eq!(d["item"]["state"], "merged"); assert_eq!(d["item"]["author"]["handle"], other.account.handle.as_str()); assert_eq!(d["item"]["comments"], 3); assert!(d["html"].as_str().unwrap().contains("b")); let verdicts: Vec<&Value> = d["comments"] .as_array() .unwrap() .iter() .map(|c| &c["verdict"]) .collect(); assert_eq!( verdicts, [ &Value::from("changes"), &Value::from("approve"), &Value::Null ] ); assert_eq!(d["comments"][2]["html"], "

Thanks

\n"); // The owner asked for changes, then approved: the latest stands. let review = &d["reviews"].as_array().unwrap()[..]; assert_eq!(review.len(), 1, "{review:?}"); assert_eq!(review[0]["reviewer"]["did"], owner.account.did.as_str()); assert_eq!(review[0]["verdict"], "approve"); assert_eq!(review[0]["commit"], d["comments"][1]["commit"]); assert_eq!( (&review[0]["outdated"], &review[0]["maintainer"]), (&serde_json::json!(false), &serde_json::json!(true)) ); // No rule names the owner a gate of main. assert_eq!(review[0].get("gate"), None); // Checks: those of the runners each repository names, the newest run // of each workflow, of the commit a listing shows and of a pull's branch. let check = |s: &Sandbox, rkey: &str, repo: Value, r: &str, sha: &str, workflow: &str, status: &str, at: &str| { s.put_record( "dev.knotless.check", rkey, serde_json::json!({ "$type": "dev.knotless.check", "repo": repo, "ref": r, "commit": sha, "workflow": workflow, "status": status, "startedAt": at, }), ) }; let reference = |s: &Sandbox, name: &str| { let key = s.record("dev.knotless.repo", name)["key"].clone(); let uri = format!("at://{}/dev.knotless.repo/{name}", s.account.did); serde_json::json!({"uri": uri, "key": key}) }; let fork_name = fork.rsplit('/').next().unwrap(); ok(&other, &["runner", "add", &fork, &other.account.handle]); let tip = site.data(&repo)["lastCommit"]["sha"] .as_str() .unwrap() .to_string(); let ours = reference(&owner, "tracked"); let theirs = reference(&other, fork_name); let main = "refs/heads/main"; let topic = "refs/heads/topic"; check( &owner, "c1", ours.clone(), main, &tip, "ci", "failure", "2026-10-20T00:00:01.000Z", ); check( &owner, "c2", ours.clone(), main, &tip, "ci", "success", "2026-10-20T00:00:02.000Z", ); check( &owner, "c3", ours.clone(), main, &"0".repeat(40), "ci", "failure", "2026-10-20T00:00:03.000Z", ); // Not a runner the repository names. check( &other, "c4", ours.clone(), main, &tip, "lint", "failure", "2026-10-20T00:00:04.000Z", ); check( &other, "c5", theirs.clone(), topic, &"1".repeat(40), "ci", "running", "2026-10-20T00:00:05.000Z", ); check( &other, "c6", theirs, "refs/heads/other", &"1".repeat(40), "lint", "error", "2026-10-20T00:00:06.000Z", ); // A run of the pull itself, by the target's runner: on the pull's page, // in its workflow's place by name, and not on the code page, though it // is of the commit shown. owner.put_record( "dev.knotless.check", "c7", serde_json::json!({ "$type": "dev.knotless.check", "repo": ours.clone(), "ref": topic, "commit": tip, "pull": pull.trim(), "workflow": "pulled", "status": "success", "startedAt": "2026-10-20T00:00:07.000Z", }), ); // Its newest run of `pulled` is c7, not the runs of its heads above. wait_until(|| { let shown = site.data(&thread); let uri = shown["checks"][1]["uri"].as_str().unwrap_or_default(); shown["checks"].as_array().unwrap().len() == 2 && uri.ends_with("/c7") }); let shown = site.data(&thread); assert_eq!(shown["checks"][0]["workflow"], "ci"); assert_eq!(shown["checks"][0]["status"], "running"); assert_eq!(shown["checks"][1]["workflow"], "pulled"); // What its runner counts for the owner: on the pull's page, with its // runs and in its settings, in the order the record names runners. assert_eq!(shown["minutes"], Value::Null); owner.put_record( "dev.knotless.minutes", &owner.account.did, serde_json::json!({ "$type": "dev.knotless.minutes", "owner": owner.account.did, "minutes": 1640, "allowance": 2000, "freeing": [{"at": "2026-11-02T00:00:00Z", "minutes": 310}], "updatedAt": "2026-10-20T00:00:09.000Z", }), ); wait_until(|| site.data(&thread)["minutes"].is_array()); let counted = &site.data(&thread)["minutes"][0]; assert_eq!(counted["runner"]["handle"], owner.account.handle.as_str()); assert_eq!( (&counted["minutes"], &counted["allowance"]), (&1640.into(), &2000.into()) ); assert_eq!(counted["freeing"][0]["minutes"], 310); let tab = site.data(&format!("{repo}/checks")); assert_eq!(tab["minutes"][0]["minutes"], 1640, "{tab}"); let settings = site.data(&format!("{repo}/settings/actions")); assert_eq!(settings["minutes"][0]["minutes"], 1640, "{settings}"); wait_until(|| site.data(&repo)["checks"].as_array().unwrap().len() == 1); let code = site.data(&repo); assert_eq!(code["checks"][0]["status"], "success"); assert_eq!(code["checks"][0]["commit"], tip.as_str()); assert_eq!(code["checks"][0]["error"], Value::Null); assert_eq!( check_page(&code["checks"][0]), format!("{repo}/check/{}/c2", owner.account.did) ); // History shows the same beside the commit, its newest run of each // workflow alone: c2, not c1 before it, nor the pull's c7. let history = site.data(&format!("{repo}/commits/main")); let listed = history["checks"].as_array().unwrap(); assert_eq!(listed.len(), 1, "{history}"); assert_eq!(check_page(&listed[0]), check_page(&code["checks"][0])); assert_eq!(listed[0]["commit"], tip.as_str()); // And beside the branch whose tip it is, on the branches page. let branches = site.data(&format!("{repo}/branches")); let main = &branches["branches"][0]; assert_eq!(main["commit"]["sha"], tip.as_str(), "{branches}"); assert_eq!(check_page(&main["checks"][0]), check_page(&listed[0])); assert_eq!( check_page(&shown["checks"][1]), format!("{repo}/check/{}/c7", owner.account.did) ); // A check's own page: its steps, the pull it ran for, its log, whole // or its tail, and the pictures it made, passing over outputs of a // kind it does not know. let log = owner.upload_blob(b"==> fmt\nok\n==> clippy\nwarning: unused\n", "text/plain"); let picture = owner.upload_blob(b"GIF89a", "image/gif"); let page_like = owner.upload_blob(b"", "text/html"); owner.put_record( "dev.knotless.check", "c8", serde_json::json!({ "$type": "dev.knotless.check", "repo": ours.clone(), "ref": topic, "commit": tip, "pull": pull.trim(), "workflow": "steps", "status": "failure", "steps": [ {"name": "fmt", "status": "success", "exitCode": 0, "durationMs": 1200}, {"name": "clippy", "status": "failure", "exitCode": 1, "durationMs": 64000}, ], "log": log, "outputs": [ {"$type": "dev.knotless.check#image", "image": picture.clone(), "alt": "home 2 after"}, {"$type": "dev.knotless.check#image", "image": page_like.clone(), "alt": "not one"}, {"$type": "dev.knotless.check#trace", "name": "trace.zip"}, ], "startedAt": "2026-10-20T00:00:08.000Z", "finishedAt": "2026-10-20T00:01:13.000Z", }), ); let page = format!("{repo}/check/{}/c8", owner.account.did); wait_until(|| site.status(&at(&page)).0 == 200); let run = site.data(&page); assert_eq!(run["check"]["workflow"], "steps"); assert_eq!(run["ref"], topic); assert_eq!(run["check"]["commit"], tip.as_str()); assert_eq!(run["pull"], pull.trim()); assert_eq!(run["runner"]["handle"], owner.account.handle.as_str()); assert_eq!(run["check"]["finishedAt"], "2026-10-20T00:01:13.000Z"); assert_eq!(run["steps"][1]["exitCode"], 1); assert_eq!(run["steps"][1]["durationMs"], 64000); assert_eq!(run["log"], "==> fmt\nok\n==> clippy\nwarning: unused\n"); let images = run["check"]["images"].as_array().unwrap(); assert_eq!(images.len(), 2); assert_eq!(images[0]["alt"], "home 2 after"); let cid = |blob: &Value| blob["ref"]["$link"].as_str().unwrap().to_string(); let shown = images[0]["url"].as_str().unwrap(); assert!( shown.starts_with("/xrpc/dev.knotless.getCheckImage?"), "{shown}" ); assert!( shown.ends_with(&format!("&cid={}", cid(&picture))), "{shown}" ); // Each picture, served as one, and only those the check names. let served = |url: &Value| { let res = site.get(url.as_str().unwrap()); let kind = res.headers()["content-type"].to_str().unwrap().to_string(); (res.status().as_u16(), kind, res.bytes().unwrap().to_vec()) }; assert_eq!( served(&images[0]["url"]), (200, "image/gif".to_string(), b"GIF89a".to_vec()) ); assert_eq!(served(&images[1]["url"]).1, "application/octet-stream"); // The pull's page shows the same pictures beside the check. wait_until(|| site.data(&thread)["checks"].as_array().unwrap().len() == 3); // By name: ci, pulled, steps. let listed = site.data(&thread)["checks"][2].clone(); assert_eq!(listed["workflow"], "steps"); assert_eq!(&listed["images"], &run["check"]["images"]); let unnamed = shown.replace(&cid(&picture), &cid(&log)); assert_eq!(site.status(&unnamed).0, 404); let plain = site.data(&format!("{repo}/check/{}/c2", owner.account.did)); assert_eq!( (plain["pull"].clone(), plain["log"].clone()), (Value::Null, Value::Null) ); // Not a runner the repository names, not a check, and a check of another // repository: none of them shown. for gone in [ format!("{repo}/check/{other_did}/c4"), format!("{repo}/check/{}/none", owner.account.did), format!("/{}/{fork_name}/check/{other_did}/c4", other.account.handle), ] { assert_eq!(site.status(&at(&gone)).0, 404, "{gone}"); } // Landed by the merge queue, its checks are its batch's: those on its // base at the batch's tip, not the runs of its own heads. let landed = serde_json::json!({ "$type": "dev.knotless.state", "repo": ours.clone(), "subject": pull.trim(), "state": "merged", "commit": d["merged"].clone(), "batch": tip, "createdAt": "2026-10-20T00:00:09.000Z", }); owner.put_record("dev.knotless.state", &knotless::keys::tid(), landed); wait_until(|| site.data(&thread)["checks"].as_array().unwrap().len() == 1); assert_eq!( check_page(&site.data(&thread)["checks"][0]), format!("{repo}/check/{}/c2", owner.account.did) ); assert_eq!( d["comments"][2]["author"]["handle"], owner.account.handle.as_str() ); assert!( merged .trim() .starts_with(&d["merged"].as_str().unwrap()[..7]), "{merged}" ); assert_eq!(d["base"], "main"); let head = serde_json::json!({ "repo": {"did": other_did, "handle": other.account.handle, "name": "tracked"}, "branch": "topic", }); assert_eq!(d["head"], head); // A pull squashed into a linear branch names itself on its commit's // last line, which shows as a link to it. ok(&owner, &["branch", "create", &url, "release"]); ok(&owner, &["branch", "protect", &url, "release"]); write(&clone, "d", b"d\n"); other.git(&clone, &["add", "d"]); other.git(&clone, &["commit", "-q", "-m", "d"]); other.git(&clone, &["push", "-q", "origin", "main:more"]); let squashed = ok( &other, &[ "pull", "create", &url, "release", &fork, "more", "Add d", "-m", "Adds d.", ], ); let sha = ok(&owner, &["pull", "merge", &url, &squashed]); let squashed_thread = format!("{repo}/pull/{other_did}/{}", rkey(&squashed)); wait_until(|| site.data(&squashed_thread)["merged"].is_string()); wait_until(|| site.status(&at(&format!("{repo}/commit/{sha}"))).0 == 200); let shown = site.data(&format!("{repo}/commit/{sha}"))["commit"].clone(); assert_eq!(shown["message"], "Add d\n\nAdds d."); assert_eq!( shown["pull"], serde_json::json!({"uri": squashed.trim(), "title": "Add d"}) ); let log = site.data(&format!("{repo}/commits/release")); assert_eq!(log["commits"][0]["pull"]["title"], "Add d"); // Under it, the merge commit of the pull before, naming it too. assert_eq!(log["commits"][1]["pull"]["uri"], pull.trim()); let items = |tail: &str| site.data(&format!("{repo}{tail}")); let titles = |v: &Value| -> Vec { v["items"] .as_array() .unwrap() .iter() .map(|i| i["title"].as_str().unwrap().to_string()) .collect() }; wait_until(|| titles(&items("/issues?state=closed")).contains(&"Quiet".to_string())); assert_eq!(items("")["repo"]["description"], "Tracks "); let open = items("/issues"); assert_eq!(titles(&open), ["Broken "]); assert_eq!( (open["openCount"].as_u64(), open["closedCount"].as_u64()), (Some(1), Some(1)) ); assert_eq!(open["items"][0]["kind"], "issue"); assert_eq!( ( open["repo"]["openIssues"].as_u64(), open["repo"]["openPulls"].as_u64() ), (Some(1), Some(0)) ); assert_eq!(open["items"][0]["uri"], issue.trim()); assert_eq!(items("/pulls")["items"], serde_json::json!([])); let closed = items("/pulls?state=closed"); // A page at a time, from where the one before ended. let first = items("/pulls?state=closed&limit=1"); assert_eq!(first["items"][0]["uri"], closed["items"][0]["uri"]); let next = first["cursor"].as_str().unwrap(); let second = items(&format!("/pulls?state=closed&limit=1&cursor={next}")); assert_eq!(second["items"][0]["uri"], closed["items"][1]["uri"]); assert_eq!( ( closed["items"][1]["state"].as_str(), closed["items"][1]["comments"].as_u64() ), (Some("merged"), Some(3)) ); let page = site.data(&format!("{repo}/issue/{other_did}/{}", rkey(&issue))); assert_eq!(page["item"]["title"], "Broken "); assert_eq!( (&page["body"], &page["html"], &page["base"], &page["merged"]), (&Value::Null, &Value::Null, &Value::Null, &Value::Null) ); assert_eq!(page["comments"], serde_json::json!([])); // A pull's card and an issue's, and what the pull says, as written. // Said more about, the issue names a new card. let issue_page = format!("{repo}/issue/{other_did}/{}", rkey(&issue)); let before = page["card"].as_str().unwrap().to_string(); assert!(before.starts_with(&format!("{issue_page}/card.png?v="))); let (png, _) = card(&site, &before); let pulled = site.data(&thread); assert_eq!(pulled["body"], "Adds **b**."); assert_ne!(card(&site, pulled["card"].as_str().unwrap()).0, png); ok(&owner, &["comment", "add", &url, &issue, "Seen"]); wait_until(|| site.data(&issue_page)["card"] != before.as_str()); let after = site.data(&issue_page)["card"].as_str().unwrap().to_string(); assert_ne!(card(&site, &after).0, png); let (_, cache) = card(&site, &before); assert_eq!(cache, "no-cache"); for missing in [ format!("{repo}/issue/{other_did}/nothing/card.png"), format!("{repo}/wiki/{other_did}/{}/card.png", rkey(&issue)), format!("/{}/nothing/card.png", owner.account.handle), ] { assert_eq!(site.status(&missing).0, 404, "{missing}"); } assert_eq!( site.status(&at(&format!("{repo}/issue/{other_did}/nothing"))) .0, 404 ); // The fork says where it came from. let source = serde_json::json!({ "did": owner.account.did, "handle": owner.account.handle, "name": "tracked", }); let forked = site.data(&format!("/{}/tracked", other.account.handle)); assert_eq!(forked["repo"]["forkOf"], source); // Each account has a page: its public repositories, newest first, and // who it is, as its Bluesky profile says. let theirs = site.profile(&other.account.handle); assert_eq!(theirs["did"], other_did.as_str()); assert_eq!( theirs["repos"][0]["forkOf"], serde_json::json!({ "did": owner.account.did, "handle": owner.account.handle, "name": "tracked", }) ); assert_eq!( ( &theirs["displayName"], &theirs["description"], &theirs["avatar"] ), (&Value::Null, &Value::Null, &Value::Null) ); let session: Value = Client::new() .post(format!( "{}/xrpc/com.atproto.server.createSession", net().pds )) .json(&serde_json::json!({ "identifier": owner.account.handle, "password": owner.account.password, })) .send() .unwrap() .json() .unwrap(); let picture: Value = Client::new() .post(format!("{}/xrpc/com.atproto.repo.uploadBlob", net().pds)) .bearer_auth(session["accessJwt"].as_str().unwrap()) .header("content-type", "image/png") .body(b"\x89PNG\r\n\x1a\n".to_vec()) .send() .unwrap() .json() .unwrap(); let put = Client::new() .post(format!("{}/xrpc/com.atproto.repo.putRecord", net().pds)) .bearer_auth(session["accessJwt"].as_str().unwrap()) .json(&serde_json::json!({ "repo": owner.account.did, "collection": "app.bsky.actor.profile", "rkey": "self", "record": { "$type": "app.bsky.actor.profile", "displayName": "The ", "description": "Keeps things tracked.", "website": "https://site.example", "pronouns": "she/her", "avatar": picture["blob"], }, })) .send() .unwrap(); assert!(put.status().is_success()); ok(&owner, &["repo", "create", &owner.url("later")]); // While the firehose is watched, both are read from the index, which // has them once the firehose says so. wait_until(|| { let mine = site.profile(&owner.account.did); names(&mine["repos"]) == ["later", "tracked"] && mine["description"] == "Keeps things tracked." }); let mine = site.profile(&owner.account.did); assert_eq!(names(&mine["repos"]), ["later", "tracked"]); assert_eq!(mine["displayName"], "The "); assert_eq!(mine["handle"], owner.account.handle.as_str()); assert_eq!(mine["description"], "Keeps things tracked."); // Its picture, as Bluesky's CDN serves it, wherever the account is // shown: over its repositories, beside them, and beside what it said. let cid = picture["blob"]["ref"]["$link"].as_str().unwrap(); let pictured = format!( "https://cdn.bsky.app/img/avatar/plain/{}/{cid}@jpeg", owner.account.did ); assert_eq!(mine["avatar"], pictured.as_str()); assert_eq!(mine["repos"][0]["owner"]["avatar"], pictured.as_str()); let discussed = site.data(&issue_page); assert_eq!(discussed["repo"]["owner"]["avatar"], pictured.as_str()); assert_eq!(discussed["item"]["author"]["avatar"], Value::Null); let seen = discussed["comments"].as_array().unwrap().iter(); let seen: Vec<_> = seen .filter(|c| c["author"]["handle"] == owner.account.handle.as_str()) .collect(); assert_eq!(seen[0]["author"]["avatar"], pictured.as_str()); assert_eq!(mine["pronouns"], "she/her"); // With no links, its website is one. let site_link = serde_json::json!([{"url": "https://site.example"}]); assert_eq!(mine["links"], site_link); // Its links, as the link records its profile names in its repo say, // in order: only http and https addresses, and only its own records. let elsewhere = owner.put_record( "app.bsky.actor.link", "3mlinkaaaaaa2", serde_json::json!({"url": "https://elsewhere.example", "title": "Elsewhere", "createdAt": "2026-10-23T00:00:00Z"}), ); let script = owner.put_record( "app.bsky.actor.link", "3mlinkaaaaaa3", serde_json::json!({"url": "javascript:alert(1)", "createdAt": "2026-10-23T00:00:00Z"}), ); let foreign = serde_json::json!({ "uri": format!("at://{other_did}/app.bsky.actor.link/3mlinkaaaaaa2"), "cid": elsewhere["cid"], }); let mut bsky = owner.record("app.bsky.actor.profile", "self"); bsky["links"] = serde_json::json!([foreign, script, elsewhere]); owner.put_record("app.bsky.actor.profile", "self", bsky); let kept = serde_json::json!([{"url": "https://elsewhere.example", "title": "Elsewhere"}]); wait_until(|| site.profile(&owner.account.did)["links"] == kept); // A knotless profile stands in for the Bluesky one, field by field, // and its picture is served from here, as the account's PDS has it. let own = owner.path("own.png"); std::fs::write(&own, b"\x89PNG\r\n\x1a\nmine").unwrap(); let own = own.to_str().unwrap(); ok( &owner, &[ "profile", "--bio", "Code, mostly.", "--link", "https://owner.example", "--avatar", own, ], ); wait_until(|| site.profile(&owner.account.did)["description"] == "Code, mostly."); let mine = site.profile(&owner.account.did); assert_eq!(mine["displayName"], "The "); assert_eq!(mine["description"], "Code, mostly."); assert_eq!(mine["pronouns"], "she/her"); assert_eq!( mine["links"], serde_json::json!([{"url": "https://owner.example"}]) ); // Its address in full, as it was asked for. let full = mine["avatar"].as_str().unwrap(); let shown = full.strip_prefix(&site.base).unwrap(); let mine_cid = shown.rsplit("&cid=").next().unwrap(); let avatar = |did: &str, cid: &str| xrpc("getAvatar", &[("actor", did), ("cid", cid)]); assert_eq!(shown, avatar(&owner.account.did, mine_cid)); assert_eq!(mine["repos"][0]["owner"]["avatar"], full); let res = site.get(shown); assert_eq!(res.headers()["content-type"], "image/png"); assert_eq!(res.bytes().unwrap().as_ref(), b"\x89PNG\r\n\x1a\nmine"); // Only the picture the profile names, of an account there is. assert_eq!(site.status(&avatar(&owner.account.did, cid)).0, 404); assert_eq!(site.status(&avatar(other_did, cid)).0, 404); assert_eq!(site.status(&avatar("nobody.invalid", "x")).0, 404); // In full, by handle as by DID; and, asked through the pages, on the // site they serve. let view = site.json(&format!( "/xrpc/dev.knotless.getProfile?actor={}", owner.account.handle )); assert_eq!( view, serde_json::json!({ "did": owner.account.did, "handle": owner.account.handle, "displayName": "The ", "description": "Code, mostly.", "pronouns": "she/her", "links": [{"url": "https://owner.example"}], "avatar": full, "followersCount": 0, "followsCount": 0, "badges": [ {"kind": "forked-and-fixed", "tier": 1, "count": 2}, {"kind": "first-light", "tier": 1, "count": 1}, ], }) ); let through: Value = Client::new() .get(format!( "{}/xrpc/dev.knotless.getActorRepos?actor={}", site.base, owner.account.did )) .header("x-forwarded-host", "knotless.example") .header("x-forwarded-proto", "https") .send() .unwrap() .json() .unwrap(); assert_eq!( through["repos"][0]["owner"]["avatar"], format!("https://knotless.example{shown}") ); for method in ["getProfile", "getActorRepos"] { let asked = format!("/xrpc/dev.knotless.{method}?actor=nobody.invalid"); let (status, said) = site.status(&asked); assert_eq!(status, 400, "{method}"); let said: Value = serde_json::from_str(&said).unwrap(); assert_eq!(said["error"], "ProfileNotFound", "{method}"); } // Its cards show its picture where its initial was, cut to a circle: // a picture no renderer reads leaves the initial, but is what the // profile names, so the card is kept for good all the same. A new // picture names a new card. let tracked = format!("/{}/tracked", owner.account.handle); let unread = site.data(&tracked)["repo"]["card"] .as_str() .unwrap() .to_string(); let (unread_png, cache) = card(&site, &unread); assert_eq!(cache, "public, max-age=31536000, immutable"); let face = owner.path("face.png"); std::fs::write(&face, one_color([200, 10, 20])).unwrap(); ok( &owner, &["account", "profile", "--avatar", face.to_str().unwrap()], ); wait_until(|| site.profile(&owner.account.did)["avatar"] != full); let pictured_card = site.data(&tracked)["repo"]["card"] .as_str() .unwrap() .to_string(); assert_ne!(pictured_card, unread); let (png, cache) = card(&site, &pictured_card); assert_eq!(cache, "public, max-age=31536000, immutable"); assert_eq!(color_at(&png, 112, 90), [200, 10, 20]); assert_ne!(color_at(&unread_png, 112, 90), [200, 10, 20]); let item_card = site.data(&issue_page)["card"].as_str().unwrap().to_string(); assert_eq!(color_at(&card(&site, &item_card).0, 112, 90), [200, 10, 20]); // Taken out again, the Bluesky profile's shows. ok( &owner, &[ "account", "profile", "--bio", "", "--link", "", "--avatar", "", ], ); wait_until(|| site.profile(&owner.account.did)["avatar"] == pictured.as_str()); let mine = site.profile(&owner.account.did); assert_eq!(mine["description"], "Keeps things tracked."); assert_eq!(mine["avatar"], pictured.as_str()); let view = site.json(&format!( "/xrpc/dev.knotless.getProfile?actor={}", owner.account.did )); assert_eq!(view["avatar"], pictured.as_str()); assert_eq!(view["description"], "Keeps things tracked."); assert_eq!( named(&mine["repos"], "tracked")["description"], "Tracks " ); assert_eq!( named(&mine["repos"], "tracked")["owner"]["handle"], owner.account.handle.as_str() ); // The profile the index keeps changes as the firehose says, its links // with it. ok(&owner, &["account", "profile", "--bio", "Kept here."]); let shown = format!("/xrpc/dev.knotless.getProfile?actor={}", owner.account.did); wait_until(|| site.json(&shown)["description"] == "Kept here."); assert_eq!(site.json(&shown)["links"], kept); // A server with no index reads the same from the PDS. let read = serve(false).profile(&owner.account.did); assert_eq!(read["description"], "Kept here."); assert_eq!(read["links"], kept); assert!(names(&read["repos"]).contains(&"tracked")); ok(&owner, &["account", "profile", "--bio", ""]); wait_until(|| site.json(&shown)["description"] == "Keeps things tracked."); let nobody = person(account(), &site); let view = site.json(&format!( "/xrpc/dev.knotless.getProfile?actor={}", nobody.account.did )); assert_eq!( view, serde_json::json!({ "did": nobody.account.did, "handle": nobody.account.handle, "followersCount": 0, "followsCount": 0, }) ); assert_eq!( site.profile(&nobody.account.handle)["repos"], serde_json::json!([]) ); // Renamed, it keeps its pulls and issues, even through the old name. // An answer asked for by the old name redirects to the same one under // the new, as does a raw file; and the fork names its source as it is // called now. ok(&owner, &["repo", "rename", &url, "tracked2"]); let moved = format!("/{}/tracked2", owner.account.handle); wait_until(|| { titles(&site.data(&format!("{moved}/issues"))).contains(&"Broken ".to_string()) }); assert!(ok(&owner, &["pull", "list", &url]).contains(&format!("pull\tmerged\t{pull}"))); let direct = Client::builder() .redirect(reqwest::redirect::Policy::none()) .build() .unwrap(); let old = |path: &str| direct.get(format!("{}{path}", site.base)).send().unwrap(); // Once the firehose has told the server of the rename, its pulls by // the old name are the new one's. let closed = format!("{repo}/pulls?state=closed"); wait_until(|| site.data(&closed)["repo"]["name"] == "tracked2"); assert_eq!(site.data(&closed)["items"][0]["state"], "merged"); let file = site.data(&moved)["entries"][0]["name"] .as_str() .unwrap() .to_string(); assert_eq!( old(&format!("{repo}/raw/main/{file}")).headers()["location"], format!("{moved}/raw/main/{file}") ); // A query by the old name answers under the new, which the pages // send the old address on to. let renamed = site.data(&repo); assert_eq!(renamed["repo"]["name"], "tracked2"); assert_eq!(names(&renamed["entries"])[0], file.as_str()); assert!(site.data(&thread)["merged"].is_string()); wait_until(|| { site.data(&format!("/{}/tracked", other.account.handle))["repo"]["forkOf"]["name"] == "tracked2" }); // A pull whose branch is gone shows no changes. other.git(&clone, &["push", "-q", "origin", "main:gone"]); let gone = ok( &other, &[ "pull", "create", &owner.url("tracked2"), "main", &fork, "gone", "Gone", ], ); ok(&other, &["branch", "delete", &fork, "gone"]); let gone = format!("{moved}/pull/{other_did}/{}", rkey(&gone)); wait_until(|| site.status(&at(&gone)).0 == 200); wait_until(|| site.data(&gone)["changes"].is_null()); // Without an index, there is no knowing. let bare = serve(false); for path in [ "/xrpc/dev.knotless.getFeed".to_string(), "/xrpc/dev.knotless.listRepos?q=x".to_string(), "/xrpc/dev.knotless.getActorActivity?actor=did:plc:x".to_string(), "/xrpc/dev.knotless.listActorItems?actor=did:plc:x&kind=pull".to_string(), at(&format!("{moved}/pulls")), ] { let (status, body) = bare.status(&path); assert_eq!(status, 404, "{path}"); assert!(body.contains("keeps no index"), "{path}: {body}"); } } /// The events about `handle`'s repository `name`, newest first. fn about<'a>(feed: &'a Value, handle: &str, name: &str) -> Vec<&'a Value> { feed.as_array() .unwrap() .iter() .filter(|e| e["repo"]["handle"] == handle && e["repo"]["name"] == name) .collect() } /// The events about `handle`'s repository `fed` that moved `refname`. fn on_ref<'a>(feed: &'a Value, handle: &str, refname: &str) -> Vec<&'a Value> { let events = about(feed, handle, "fed").into_iter(); events.filter(|e| e["action"]["ref"] == refname).collect() } /// Whether one of `events` did `kind`, with each field as `fields` has it. fn did(events: &[&Value], kind: &str, fields: Value) -> bool { events.iter().any(|e| { let a = &e["action"]; a["$type"] == format!("dev.knotless.getFeed#{kind}") && fields.as_object().unwrap().iter().all(|(k, v)| &a[k] == v) }) } #[test] fn a_forced_push() { use serde_json::json; let site = serve(true); let owner = person(account(), &site); let url = owner.url("forced"); ok(&owner, &["repo", "create", &url]); let work = owner.work("w", 1); owner.commit_file(&work, "a", "a\n"); owner.git(&work, &["push", "-q", &url, "main"]); // The last commit again, its tree changed as a rebase changes it but // by the same author at the same time saying the same, and one more: // only that one is new. std::fs::write(work.join("a"), "a, again\n").unwrap(); owner.git(&work, &["commit", "-q", "-a", "--amend", "--no-edit"]); let b = owner.commit_file(&work, "b", "b\n"); owner.git(&work, &["push", "-q", "-f", &url, "main"]); // A page of one is the forced push alone, without the push before it. let did = &owner.account.did; let newest = || { let page = site.json(&format!("/xrpc/dev.knotless.getFeed?limit=1&actor={did}")); page["events"][0]["action"].clone() }; wait_until(|| newest()["forced"] == true); let pushed = newest(); assert_eq!( (&pushed["created"], &pushed["count"], &pushed["new"]), (&json!(false), &json!(1), &json!(1)), "{pushed}" ); assert_eq!(pushed["commits"][0]["sha"], b.as_str()); // Where it left the ref, for a link that outlives the ref. assert_eq!(pushed["to"], b.as_str()); // One that only moved forward was not forced. owner.commit_file(&work, "c", "c\n"); owner.git(&work, &["push", "-q", &url, "main"]); wait_until(|| newest()["count"] == 1 && newest().get("forced").is_none()); } #[test] fn the_feed() { use serde_json::json; let site = serve(true); let owner = person(account(), &site); let other = person(account(), &site); let (o, t) = (&owner.account.handle, &other.account.handle); let us = [owner.account.did.as_str(), other.account.did.as_str()]; // Other tests' pushes and pulls share the feed, so it is read back to // where it was as this began: once it shows `last`, it must show each // of `wants`. let newest = site.json("/xrpc/dev.knotless.getFeed?limit=1"); let since = newest["events"][0]["createdAt"].as_str().unwrap_or(""); let shows = |handle: &str, last: (&str, Value), wants: &[(&str, Value)]| { wait_until(|| { did( &about(&site.feed(since, &us), handle, "fed"), last.0, last.1.clone(), ) }); let feed = site.feed(since, &us); let events = about(&feed, handle, "fed"); for (kind, fields) in wants { assert!( did(&events, kind, fields.clone()), "no {kind} {fields} in {events:#?}" ); } feed }; let url = owner.url("fed"); ok( &owner, &["repo", "create", &url, "--description", "Fed the feed"], ); let work = owner.work("w", 1); owner.git(&work, &["push", "-q", &url, "main"]); // Pushes to a ref close together are one, from where the first found // it to where the last left it, so one back to where it was counts // only what stayed. for i in 0..6 { owner.commit_file(&work, "f", &format!("{i}\n")); } owner.git(&work, &["push", "-q", &url, "main"]); owner.git(&work, &["push", "-q", "-f", &url, "HEAD~2:main"]); owner.git(&work, &["checkout", "-q", "-b", "side"]); owner.commit_file(&work, "s", "s\n"); owner.git(&work, &["push", "-q", &url, "side"]); // A commit another ref had first is counted, not listed again; nor is // one listed by an earlier event, though main was pushed first. owner.git(&work, &["tag", "v1"]); owner.git(&work, &["push", "-q", &url, "v1"]); owner.git(&work, &["push", "-q", &url, "side:main"]); owner.git(&work, &["push", "-q", &url, ":side"]); let feed = shows( o, ("deleted", json!({"ref": "refs/heads/side"})), &[ ("created", json!({"description": "Fed the feed"})), ( "pushed", json!({"ref": "refs/heads/main", "created": true, "count": 8, "new": 5}), ), ( "pushed", json!({"ref": "refs/heads/side", "created": true, "count": 3, "new": 3}), ), ( "pushed", json!({"ref": "refs/tags/v1", "created": true, "count": 3, "new": 0, "commits": []}), ), ], ); let on = |r: &str| on_ref(&feed, o, r); let (main, side) = (on("refs/heads/main"), on("refs/heads/side")); assert_eq!((main.len(), side.len()), (1, 2)); // None of what side listed. let shas = |e: &Value| { e["action"]["commits"] .as_array() .unwrap() .iter() .map(|c| c["sha"].as_str().unwrap().to_string()) .collect::>() }; let (main_shas, side_shas) = (shas(main[0]), shas(side[1])); assert_eq!((main_shas.len(), side_shas.len()), (5, 3)); assert!(main_shas.iter().all(|sha| !side_shas.contains(sha))); assert_eq!(main[0]["actor"]["handle"], o.as_str()); assert_eq!(main[0]["actor"]["did"], owner.account.did.as_str()); // A merge says it is one; a commit with one parent says nothing. owner.git(&work, &["checkout", "-q", "-b", "joined"]); owner.commit_file(&work, "j", "j\n"); owner.git(&work, &["checkout", "-q", "-b", "other", "HEAD~1"]); owner.commit_file(&work, "o", "o\n"); owner.git(&work, &["merge", "-q", "--no-ff", "-m", "Join", "joined"]); owner.git(&work, &["push", "-q", &url, "other"]); let feed = shows(o, ("pushed", json!({"ref": "refs/heads/other"})), &[]); let joined = on_ref(&feed, o, "refs/heads/other"); let merges: Vec<_> = joined[0]["action"]["commits"] .as_array() .unwrap() .iter() .map(|c| (c["message"].as_str().unwrap().trim(), c.get("merge"))) .collect(); assert_eq!(merges[0], ("Join", Some(&json!(true))), "{merges:?}"); assert!( merges[1..].iter().all(|(_, merge)| merge.is_none()), "{merges:?}" ); let fork = ok(&other, &["repo", "fork", &url]); let clone = other.path("clone"); other.git(other.dir.path(), &["clone", "-q", &fork, "clone"]); other.commit_file(&clone, "b", "b\n"); other.git(&clone, &["push", "-q", "origin", "main:topic"]); let feed = shows( t, ( "pushed", json!({"ref": "refs/heads/topic", "created": true, "count": 1}), ), &[( "created", json!({"forkOf": {"did": owner.account.did, "handle": o, "name": "fed"}}), )], ); // A fork's copy of its source's branches is the fork itself. assert!(!did( &about(&feed, t, "fed"), "pushed", json!({"ref": "refs/heads/main"}) )); let pull = ok( &other, &["pull", "create", &url, "main", &fork, "topic", "Add b"], ); let issue = ok(&other, &["issue", "create", &url, "Broken "]); ok( &owner, &["comment", "add", &url, &pull, "Not *yet*", "--changes"], ); ok( &owner, &["comment", "add", &url, &pull, "Fixed", "--approve"], ); ok(&owner, &["comment", "add", &url, &issue, "Seen it"]); ok(&owner, &["issue", "close", &url, &issue]); ok(&owner, &["issue", "reopen", &url, &issue]); let (pull, issue) = (pull.trim(), issue.trim()); wait_until(|| { did( &about(&site.feed(since, &us), o, "fed"), "changed", json!({"subject": issue, "state": "open"}), ) }); ok(&owner, &["pull", "merge", &url, pull]); let feed = shows( o, ("changed", json!({"subject": pull, "state": "merged"})), &[ // Each says where its subject stands now, not then: the pull // merged, and the issue, closed and reopened above, open. ( "opened", json!({"subject": pull, "title": "Add b", "subjectState": "merged"}), ), ( "commented", json!({"subject": pull, "verdict": "changes", "text": "Not *yet*", "subjectState": "merged"}), ), ( "commented", json!({"subject": pull, "verdict": "approve", "title": "Add b"}), ), ( "opened", json!({"subject": issue, "title": "Broken ", "subjectState": "open"}), ), ( "commented", json!({"subject": issue, "text": "Seen it", "title": "Broken "}), ), ( "changed", json!({"subject": issue, "state": "closed", "subjectState": "open"}), ), ], ); // Opened, said and decided about, by title. let titled = |title: &str| { about(&feed, o, "fed") .iter() .filter(|e| e["action"]["title"] == title) .count() }; assert_eq!((titled("Add b"), titled("Broken ")), (4, 4)); // A comment without a verdict has none to say. assert!( about(&feed, o, "fed") .iter() .any(|e| e["action"]["text"] == "Seen it" && e["action"].get("verdict").is_none()) ); // At most forty, unless asked for more or fewer; at least one. A page // is the newest records less those not shown, such as another test's // fork's copy of its source's refs, so it may hold fewer, but it // always ends where the next begins. let page = |q: &str| site.json(&format!("/xrpc/dev.knotless.getFeed{q}")); assert!(page("")["events"].as_array().unwrap().len() <= 40); for q in ["?limit=1", "?limit=0"] { let one = page(q); assert!(one["events"].as_array().unwrap().len() <= 1, "{q}: {one}"); assert!(one["cursor"].is_string(), "{q}: {one}"); } // Page by page, each starting strictly after the last. let first = page("?limit=2"); let cursor = first["cursor"].as_str().unwrap().replace(' ', "%20"); let next = page(&format!("?limit=1&cursor={cursor}")); for after in next["events"].as_array().unwrap() { for shown in first["events"].as_array().unwrap() { assert!(after["createdAt"].as_str() <= shown["createdAt"].as_str()); assert_ne!(after, shown); } } assert!(names(&site.search("FED")["repos"]).contains(&"fed")); // Of one kind only: a page of nothing else, and of each but stars, // which only one account's or whom one follows' hold, some. let did = &other.account.did; let of = |e: &Value| { let a = &e["action"]; let on = |n: &str| a["subject"].as_str().unwrap_or("").contains(n); match a["$type"].as_str().unwrap().split('#').nth(1).unwrap() { "pushed" | "deleted" => "pushes", "created" => "repos", "commented" => "comments", "starred" => "stars", _ if on("/dev.knotless.pull/") => "pulls", _ => "issues", } }; // The pull's author, who reviews nothing of their own, says something. ok(&other, &["comment", "add", &url, issue, "Still broken"]); let comments = || page(&format!("?actor={did}&kind=comments"))["events"].clone(); wait_until(|| comments().as_array().is_some_and(|e| !e.is_empty())); for kind in ["pushes", "pulls", "issues", "comments", "repos"] { let only = page(&format!("?actor={did}&kind={kind}")); let events = only["events"].as_array().unwrap(); assert!(!events.is_empty(), "{kind}: {only}"); assert!(events.iter().all(|e| of(e) == kind), "{kind}: {only}"); } assert_eq!(page("?kind=stars")["events"], json!([])); assert_eq!(page("?kind=gists")["events"], json!([])); // How much each did, a day at a time, and each repository's pushes. let activity = |did: &str, q: &str| { site.json(&format!( "/xrpc/dev.knotless.getActorActivity?actor={did}{q}" )) }; let total = |a: &Value, kind: &str| -> u64 { let days = a["days"].as_array().unwrap().iter(); days.map(|d| d[kind].as_u64().unwrap()).sum() }; let kinds = ["repos", "pulls", "issues", "comments"]; let mine = activity(&owner.account.did, ""); let counted = kinds.map(|k| total(&mine, k)); assert_eq!(counted, [1, 0, 0, 3], "{mine}"); let theirs = activity(&other.account.did, ""); assert_eq!(kinds.map(|k| total(&theirs, k)), [1, 1, 1, 1], "{theirs}"); assert!(total(&theirs, "pushes") > 0); // Pushes, by the day and by the repository, agree. let fed = &mine["repos"][0]; assert_eq!( (mine["repos"].as_array().unwrap().len(), &fed["name"]), (1, &json!("fed")) ); let by_day = fed["days"].as_array().unwrap().iter(); let pushed: u64 = by_day.map(|d| d["pushes"].as_u64().unwrap()).sum(); assert!(pushed > 0, "{mine}"); assert_eq!(pushed, total(&mine, "pushes")); // Since a day to come, nothing done yet, but the last push still known. let later = activity(&owner.account.did, "&since=2999-01-01T00:00:00Z"); assert_eq!(later["days"], json!([])); assert_eq!(later["repos"][0]["days"], json!([])); assert_eq!(later["repos"][0]["pushedAt"], fed["pushedAt"]); assert_eq!( activity(&owner.account.did, "&since=2000-01-01T00:00:00Z"), mine ); // Days in another zone fall elsewhere, but count the same. let zoned = activity(&owner.account.did, "&tz=Pacific/Kiritimati"); let all = ["repos", "pushes", "pulls", "issues", "comments"]; assert_eq!(all.map(|k| total(&zoned, k)), all.map(|k| total(&mine, k))); } /// The pages: anything knotless-serve does not answer itself is handed to /// the app at `--web`, as asked, with the host it was asked of; and a /// failure to reach it is asked over if a read, and said. #[test] fn pages_drawn_elsewhere() { use axum::http::HeaderMap; use axum::response::IntoResponse; let rt = tokio::runtime::Runtime::new().unwrap(); let listen = || { let listener = rt .block_on(tokio::net::TcpListener::bind("127.0.0.1:0")) .unwrap(); let at = format!("http://{}", listener.local_addr().unwrap()); (listener, at) }; // An app that says what it was asked. let (app, at) = listen(); let echo = axum::Router::new().fallback( |method: axum::http::Method, uri: axum::http::Uri, headers: HeaderMap, body: String| async move { let header = |name: &str| headers.get(name).map(|v| v.to_str().unwrap().to_string()); let said = serde_json::json!({ "method": method.as_str(), "at": uri.to_string(), "host": header("x-forwarded-host"), "proto": header("x-forwarded-proto"), "body": body, }); let policy = if uri.path() == "/own" { "no-referrer" } else { "" }; let mut res = axum::Json(said).into_response(); if !policy.is_empty() { res.headers_mut() .insert("referrer-policy", policy.parse().unwrap()); } if uri.path() == "/packed" { res.headers_mut() .insert("content-encoding", "zstd".parse().unwrap()); } res }, ); // An app that drops the first connection it takes, then answers. let dropping = |echo: axum::Router| { let (app, at) = listen(); rt.spawn(async move { drop(app.accept().await.unwrap()); axum::serve(app, echo).await.unwrap() }); at }; let (reread, rewritten) = (dropping(echo.clone()), dropping(echo.clone())); rt.spawn(async move { axum::serve(app, echo).await.unwrap() }); let s = Sandbox::new(); let front = |web: String| { let (listener, base) = listen(); let app: Arc = App::build(ctx(&s), Duration::ZERO, None, None); let routes = router(app).merge(knotless_serve::web::pages(web)); rt.spawn(async move { axum::serve(listener, routes).await.unwrap() }); base }; let base = front(format!("{at}/")); let client = Client::new(); let res = client .post(format!("{base}/alice.test/tally?x=1")) .header("host", "pages.example") .header("x-forwarded-proto", "https") .body("hi") .send() .unwrap(); assert_eq!(res.status().as_u16(), 200); assert_eq!(res.headers()["x-content-type-options"], "nosniff"); assert_eq!(res.headers()["referrer-policy"], "same-origin"); let said: Value = res.json().unwrap(); assert_eq!( said, serde_json::json!({ "method": "POST", "at": "/alice.test/tally?x=1", "host": "pages.example", "proto": "https", "body": "hi", }) ); // What the app says of itself stands. let own = client.get(format!("{base}/own")).send().unwrap(); assert_eq!(own.headers()["referrer-policy"], "no-referrer"); assert_eq!(own.json::().unwrap()["proto"], "http"); // A page goes compressed to a browser that takes brotli; what the app // compressed itself goes as it is. let squeezed = |at: &str| { let res = client .get(format!("{base}{at}")) .header("accept-encoding", "br") .send() .unwrap(); res.headers()["content-encoding"] .to_str() .unwrap() .to_string() }; assert_eq!(squeezed("/alice.test/tally"), "br"); assert_eq!(squeezed("/packed"), "zstd"); // A page is sent on as the app sends it, compressed or not: its first // part reaches the browser while the app still holds the rest. let holding = || { let (app, at) = listen(); let gate = Arc::new(tokio::sync::Notify::new()); let held = gate.clone(); let page = axum::Router::new().fallback(move || { let held = held.clone(); async move { let parts = futures::stream::unfold(0, move |n| { let held = held.clone(); async move { if n == 1 { held.notified().await; } let part = ["first ", "rest"].get(n)?.to_string(); Some((Ok::<_, std::io::Error>(part), n + 1)) } }); let mut res = axum::body::Body::from_stream(parts).into_response(); let html = "text/html".parse().unwrap(); res.headers_mut().insert("content-type", html); res } }); rt.spawn(async move { axum::serve(app, page).await.unwrap() }); (gate, at) }; let patient = Client::builder() .timeout(Duration::from_secs(30)) .build() .unwrap(); for encoding in ["identity", "br"] { let (gate, at) = holding(); let mut res = patient .get(format!("{}/page", front(at))) .header("accept-encoding", encoding) .send() .unwrap(); let mut first = [0; 64]; let n = std::io::Read::read(&mut res, &mut first).unwrap(); assert!(n > 0, "{encoding}: nothing before the rest"); gate.notify_one(); let mut all = first[..n].to_vec(); std::io::Read::read_to_end(&mut res, &mut all).unwrap(); let sent = res.headers().get("content-encoding").cloned(); if encoding == "identity" { assert!(b"first ".starts_with(&first[..n]), "{encoding}"); assert_eq!((all.as_slice(), sent), (&b"first rest"[..], None)); } else { assert_eq!(sent.unwrap(), "br"); } } // What knotless-serve answers itself is not handed on. let ours = client .get(format!("{base}/xrpc/dev.knotless.getFeed")) .send() .unwrap(); assert!(ours.text().unwrap().contains("keeps no index")); // Nor is `/favicon.ico`, which would be drawn as a handle's profile: it // goes to the pages' icon. let staying = Client::builder() .redirect(reqwest::redirect::Policy::none()) .build() .unwrap(); let icon = staying.get(format!("{base}/favicon.ico")).send().unwrap(); assert_eq!(icon.status().as_u16(), 308); assert_eq!(icon.headers()["location"], "/favicon.svg"); // A crawler is kept off what robots.txt keeps it off, whether it read // it or not: by the longest rule, with its escapes or without. A // browser, or a preview of a link shared, is not. const GPT: &str = "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.4)"; const SLACK: &str = "Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)"; const BROWSER: &str = "Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 Safari/605.1.15"; for (agent, at, status) in [ ( GPT, "/alice.test/tally/issues?q=label:area/serve%20author:bob", 403, ), ( GPT, "/alice.test/tally/pulls/__data.json?x-sveltekit-invalidated=01", 403, ), (GPT, "/alice.test/tally/tree/dev/src", 403), (GPT, "/search?q=x", 403), (GPT, "/alice.test/tally/issues?q=state:closed", 200), ( GPT, "/alice.test/tally/issues?q=state%3Aclosed&cursor=2", 200, ), ( GPT, "/alice.test/tally/issues?q=state%3Aclosed%20label:x", 403, ), (GPT, "/alice.test/tally/issues", 200), (GPT, "/alice.test/tally/tree/main/src", 200), (GPT, "/alice.test", 200), (SLACK, "/alice.test/tally/commit/abc", 200), (BROWSER, "/alice.test/tally/issues?q=label:x", 200), ] { let res = client .get(format!("{base}{at}")) .header("user-agent", agent) .send() .unwrap(); assert_eq!(res.status().as_u16(), status, "{agent} at {at}"); if status == 403 { assert!(res.text().unwrap().contains("robots.txt")); } } // Too much to hand on, or no app to hand it to. // The answer can come before all of it is sent, which closes the // connection under the rest. let big = client .post(format!("{base}/x")) .body(vec![b'x'; 2 << 20]) .send(); match big { Ok(res) => assert_eq!(res.status().as_u16(), 502), Err(e) => assert!(e.is_request(), "{e}"), } // A connection lost is asked over on a read, and not on a write. let res = client.get(format!("{}/x", front(reread))).send().unwrap(); assert_eq!(res.status().as_u16(), 200); let written = front(rewritten); let res = client.post(format!("{written}/x")).send().unwrap(); assert_eq!(res.status().as_u16(), 502); let res = client.post(format!("{written}/x")).send().unwrap(); assert_eq!(res.status().as_u16(), 200); let (gone, nowhere) = listen(); drop(gone); let res = client.get(front(nowhere)).send().unwrap(); assert_eq!(res.status().as_u16(), 502); assert!( res.text() .unwrap() .starts_with("The pages are not being drawn") ); } #[test] fn the_merge_queue() { let site = serve(true); let owner = person(account(), &site); let url = owner.url("lined"); ok(&owner, &["repo", "create", &url]); ok(&owner, &["runner", "add", &url, &owner.account.handle]); let work = owner.work("w", 1); owner.git(&work, &["push", "-q", &url, "main"]); let repo = format!("/{}/lined", owner.account.handle); let mut pulls = Vec::new(); for b in ["a", "b", "c", "d", "e", "f"] { owner.git(&work, &["checkout", "-q", "-B", b, "main"]); owner.commit_file(&work, b, &format!("{b}\n")); owner.git(&work, &["push", "-q", &url, b]); pulls.push(ok( &owner, &[ "pull", "create", &url, "main", &url, b, &format!("Pull {b}"), ], )); } // g, into another branch. owner.git(&work, &["push", "-q", &url, "main:feature/x"]); owner.git(&work, &["checkout", "-q", "-B", "g", "main"]); owner.commit_file(&work, "g", "g\n"); owner.git(&work, &["push", "-q", &url, "g"]); pulls.push(ok( &owner, &["pull", "create", &url, "feature/x", &url, "g", "Pull g"], )); let issue = ok(&owner, &["issue", "create", &url, "Not a pull"]); let page = |uri: &str| { let (did, _, rkey) = knotless::forge::parse_uri(uri).unwrap(); let kind = if uri.contains(".pull/") { "pull" } else { "issue" }; format!("{repo}/{kind}/{did}/{rkey}") }; let key = owner.record("dev.knotless.repo", "lined")["key"].clone(); let reference = serde_json::json!({ "uri": format!("at://{}/dev.knotless.repo/lined", owner.account.did), "key": key, }); let did = owner.account.did.clone(); let check_uri = |rkey: &str| format!("at://{did}/dev.knotless.check/{rkey}"); // a, b and c queued, in that order, and e; g into feature/x between b // and c; d ejected by the checks of a batch it was in, one of which the // index knows, and f before it. for (at, p) in [ ("01.000", 0), ("02.000", 1), ("02.500", 6), ("03.000", 2), ("05.000", 4), ] { let queued = serde_json::json!({ "$type": "dev.knotless.queue", "repo": reference, "subject": pulls[p], "state": "queued", "createdAt": format!("2026-10-21T00:00:{at}Z"), }); owner.put_record("dev.knotless.queue", &knotless::keys::tid(), queued); } let ejected = serde_json::json!({ "$type": "dev.knotless.queue", "repo": reference, "subject": pulls[3], "state": "ejected", "reason": "its batch failed its checks", "checks": [ {"workflow": "test", "status": "failure", "check": check_uri("q1")}, {"workflow": "gone", "status": "error", "check": check_uri("q0")}, ], "createdAt": "2026-10-21T00:00:04.000Z", }); owner.put_record("dev.knotless.queue", &knotless::keys::tid(), ejected); let earlier = serde_json::json!({ "$type": "dev.knotless.queue", "repo": reference, "subject": pulls[5], "state": "ejected", "reason": "it conflicts with main", "createdAt": "2026-10-21T00:00:00.000Z", }); owner.put_record("dev.knotless.queue", &knotless::keys::tid(), earlier); // e lands, as the batch of its own that passed. let merged = ok(&owner, &["pull", "merge", &url, &pulls[4]]); let merged = merged.lines().next().unwrap().to_string(); let batched = |p: usize, commit: &str| serde_json::json!({"pull": pulls[p], "commit": commit}); let run = |rkey: &str, commit: &str, batch: Value, status: &str, at: &str| { let finished = (status != "running").then(|| format!("2026-10-22T0{at}:30:00.000Z")); owner.put_record( "dev.knotless.check", rkey, serde_json::json!({ "$type": "dev.knotless.check", "repo": reference, "ref": "refs/heads/main", "commit": commit, "baseCommit": "b".repeat(40), "batch": batch, "workflow": "test", "status": status, "startedAt": format!("2026-10-22T0{at}:00:00.000Z"), "finishedAt": finished, }), ); }; let (t1, tx) = ("1".repeat(40), "2".repeat(40)); run( "q1", &tx, serde_json::json!([batched(3, &tx)]), "failure", "1", ); run( "q2", &merged, serde_json::json!([batched(4, &merged)]), "success", "2", ); let ab = serde_json::json!([batched(0, &"3".repeat(40)), batched(1, &t1)]); run("q3", &t1, ab, "running", "3"); // c's pre-flight, running at its head. owner.put_record( "dev.knotless.check", "q4", serde_json::json!({ "$type": "dev.knotless.check", "repo": reference, "ref": "refs/heads/c", "commit": "4".repeat(40), "pull": pulls[2].trim(), "queue": "first", "workflow": "test", "status": "running", "startedAt": "2026-10-22T04:00:00.000Z", }), ); let queue = format!("{repo}/queue"); wait_until(|| { let q = site.data(&queue); q["landed"].as_array().is_some_and(|l| !l.is_empty()) && q["branches"][1]["batches"] .as_array() .is_some_and(|b| !b.is_empty()) && q["ejected"][0]["checks"] .as_array() .is_some_and(|c| !c.is_empty()) && q["branches"][1]["pulls"][2]["stage"] == "preflight" }); let q = site.data(&queue); // One line a branch, by name, though their pulls were queued between // each other's. let names: Vec<&Value> = q["branches"] .as_array() .unwrap() .iter() .map(|b| &b["branch"]) .collect(); assert_eq!(names, ["feature/x", "main"]); assert_eq!(q["branches"][0]["pulls"][0]["said"], "Queued, 1st of 1"); let line = &q["branches"][1]; let said: Vec<(&str, &str, &Value)> = (line["pulls"].as_array().unwrap().iter()) .map(|p| { let title = p["pull"]["title"].as_str().unwrap(); (title, p["said"].as_str().unwrap(), &p["batch"]) }) .collect(); assert_eq!( said, [ ("Pull a", "In a batch of 2 being checked", &Value::from(0)), ("Pull b", "In a batch of 2 being checked", &Value::from(0)), ( "Pull c", "Queued, 3rd of 3, running its checks", &Value::Null ), ] ); // Each says where it stands, with its pre-flights' runs until it is in // a batch; and the queue how long it takes, as the batch that landed // did, its pre-flights too with none of them passed. let stages: Vec<&Value> = (line["pulls"].as_array().unwrap().iter()) .map(|p| &p["stage"]) .collect(); assert_eq!(stages, ["checking", "checking", "preflight"]); assert_eq!(line["pulls"][2]["checks"][0]["workflow"], "test"); assert_eq!(line["pulls"][0]["checks"].as_array().map(Vec::len), Some(0)); let typical = serde_json::json!({"batchSeconds": 1800, "preflightSeconds": 1800}); assert_eq!(q["typical"], typical); assert_eq!(q["branches"][0]["pulls"][0]["stage"], "queued"); assert_eq!(line["pulls"][0]["queuedAt"], "2026-10-21T00:00:01.000Z"); assert_eq!(line["pulls"][0]["pull"]["uri"], pulls[0].trim()); let batch = &line["batches"][0]; assert_eq!( (&batch["commit"], &batch["state"]), (&Value::from(t1.as_str()), &Value::from("checking")) ); assert_eq!(batch["size"], 2); assert_eq!(batch["startedAt"], "2026-10-22T03:00:00.000Z"); assert_eq!(batch["checks"][0]["status"], "running"); let e = &q["ejected"][0]; assert_eq!(e["pull"]["title"], "Pull d"); assert_eq!(e["reason"], "its batch failed its checks"); assert_eq!(e["checks"].as_array().unwrap().len(), 1); assert_eq!( check_page(&e["checks"][0]), format!("{repo}/check/{did}/q1") ); assert_eq!(q["ejected"][1]["pull"]["title"], "Pull f"); let landed = &q["landed"][0]; assert_eq!(landed["commit"], merged.as_str()); assert_eq!(landed["branch"], "main"); assert_eq!(landed["landedAt"], "2026-10-22T02:30:00.000Z"); assert_eq!(landed["pulls"][0]["title"], "Pull e"); // The pull page says where it stands, and links here: with its batch's // checks while that is checked, those that failed once ejected. let a = site.data(&page(&pulls[0]))["queue"].clone(); assert_eq!(a["said"], "In a batch of 2 being checked"); assert_eq!( check_page(&a["checks"][0]), format!("{repo}/check/{did}/q3") ); let c = site.data(&page(&pulls[2]))["queue"].clone(); assert_eq!( (c["said"].as_str(), c["checks"].as_array().map(Vec::len)), (Some("Queued, 3rd of 3, running its checks"), Some(1)) ); assert_eq!(c["stage"], "preflight"); assert_eq!(c["typical"], typical); assert_eq!(c["aheadStartedAt"], "2026-10-22T03:00:00.000Z"); assert_eq!(a["batchStartedAt"], "2026-10-22T03:00:00.000Z"); let d = site.data(&page(&pulls[3]))["queue"].clone(); assert_eq!(d["state"], "ejected"); assert_eq!( d["said"], "Ejected: its batch failed its checks. Failed: test (failure), gone (error)" ); assert_eq!(d["checks"][0]["workflow"], "test"); assert!(site.data(&page(&issue))["queue"].is_null()); assert!(site.data(&page(&pulls[4]))["queue"].is_null()); } #[test] fn the_runs() { let site = serve(true); let owner = person(account(), &site); let url = owner.url("ran"); ok(&owner, &["repo", "create", &url]); ok(&owner, &["runner", "add", &url, &owner.account.handle]); let work = owner.work("w", 1); // A workflow run on pushes, one run only when asked, one that does not // read as one, and a file that is not one. let flows = [ ( "ci.yml", "# Builds and tests. On every push.\nsteps: [{run: make}]\n", ), ( "audit.yaml", "# Checks the docs\n# against the code.\non: {asked: {}}\nsteps: [{run: x}]\n", ), ("broken.yml", "on: {nope: 1}\nsteps: []\n"), ("notes.md", "# Not a workflow.\n"), ]; for (name, text) in flows { write( &work, &format!(".knotless/workflows/{name}"), text.as_bytes(), ); } owner.git(&work, &["add", "-A"]); owner.git(&work, &["commit", "-q", "-m", "Workflows\n\nAnd more."]); let tip = owner.git(&work, &["rev-parse", "HEAD"]); owner.git(&work, &["push", "-q", &url, "main"]); owner.git(&work, &["checkout", "-q", "-b", "topic"]); owner.commit_file(&work, "t", "t\n"); owner.git(&work, &["push", "-q", &url, "topic"]); let pull = ok( &owner, &["pull", "create", &url, "main", &url, "topic", "A topic"], ); let pull = pull.trim().to_string(); let key = owner.record("dev.knotless.repo", "ran")["key"].clone(); let reference = serde_json::json!({ "uri": format!("at://{}/dev.knotless.repo/ran", owner.account.did), "key": key, }); let run = |rkey: &str, workflow: &str, commit: &str, at: u8, more: Value| { let mut check = serde_json::json!({ "$type": "dev.knotless.check", "repo": reference, "ref": "refs/heads/main", "commit": commit, "workflow": workflow, "status": "success", "startedAt": format!("2026-10-22T0{at}:00:00.000Z"), "finishedAt": format!("2026-10-22T0{at}:01:30.000Z"), }); check .as_object_mut() .unwrap() .extend(more.as_object().unwrap().clone()); owner.put_record("dev.knotless.check", rkey, check); }; let none = serde_json::json!({}); run("r1", "ci", &tip, 1, none.clone()); let of_pull = serde_json::json!({"ref": "refs/heads/topic", "pull": pull, "status": "failure"}); run("r2", "ci", &"1".repeat(40), 2, of_pull); let batch = serde_json::json!({"batch": [{"pull": pull, "commit": "2".repeat(40)}]}); run("r3", "ci", &"2".repeat(40), 3, batch); run("r4", "audit", &"3".repeat(40), 4, none); let page = format!("/{}/ran/checks", owner.account.handle); wait_until(|| { site.data(&page)["runs"] .as_array() .is_some_and(|r| r.len() == 4) }); let shown = site.data(&page); let runs = shown["runs"].as_array().unwrap(); let rkeys: Vec<&str> = runs .iter() .map(|r| { r["check"]["uri"] .as_str() .unwrap() .rsplit('/') .next() .unwrap() }) .collect(); assert_eq!(rkeys, ["r4", "r3", "r2", "r1"]); // Asked for at a commit this server does not have: no subject. assert_eq!(runs[0]["check"]["workflow"], "audit"); assert!(runs[0]["subject"].is_null()); assert_eq!( (&runs[1]["batch"], &runs[1]["ref"]), (&Value::from(1), &Value::from("refs/heads/main")) ); assert!(runs[1]["subject"].is_null()); assert_eq!(runs[2]["pull"]["title"], "A topic"); assert_eq!(runs[2]["ref"], "refs/heads/topic"); assert_eq!(runs[2]["check"]["status"], "failure"); assert_eq!(runs[3]["subject"], "Workflows"); assert!(runs[3]["pull"].is_null() && runs[3]["batch"].is_null()); assert!(shown["cursor"].is_null()); let workflows: Vec<(&str, bool, Option<&str>)> = (shown["workflows"].as_array().unwrap().iter()) .map(|w| { ( w["name"].as_str().unwrap(), w["asked"].as_bool().unwrap(), w["about"].as_str(), ) }) .collect(); assert_eq!( workflows, [ ("audit", true, Some("Checks the docs against the code.")), ("ci", false, Some("Builds and tests.")), ] ); // One workflow's, and a page at a time. let audit = site.data(&format!("{page}?workflow=audit")); assert_eq!(audit["runs"].as_array().unwrap().len(), 1); let first = site.data(&format!("{page}?limit=2")); assert_eq!(first["runs"].as_array().unwrap().len(), 2); let cursor = first["cursor"].as_str().unwrap(); let next = site.data(&format!("{page}?limit=2&cursor={cursor}")); assert_eq!(next["runs"][0]["check"]["uri"], runs[2]["check"]["uri"]); } /// A composer's preview: Markdown as a comment in the repository shows it /// once posted, and nothing longer than a comment may be. #[test] fn previewing_markdown() { let site = serve(true); let owner = person(account(), &site); let url = owner.url("noted"); ok(&owner, &["repo", "create", &url]); let work = owner.work("w", 1); owner.git(&work, &["push", "-q", &url, "main"]); let issue = ok(&owner, &["issue", "create", &url, "Notes"]); let issue = issue.trim(); let said = "See [the guide](docs/guide.md), https://example.com and this."; ok(&owner, &["comment", "add", &url, issue, said]); let handle = &owner.account.handle; let (did, _, rkey) = knotless::forge::parse_uri(issue).unwrap(); let thread = at(&format!("/{handle}/noted/issue/{did}/{rkey}")); let commented = || { let (code, body) = site.status(&thread); code == 200 && serde_json::from_str::(&body).unwrap()["comments"][0].is_object() }; wait_until(commented); let shown = site.json(&thread)["comments"][0]["html"].clone(); let html = shown.as_str().unwrap(); assert!(html.contains(&format!("href=\"/{handle}/noted/docs/guide.md\""))); assert!(html.contains("<b>this</b>")); let repo = format!("at://{handle}/dev.knotless.repo/noted"); let input = serde_json::json!({ "text": said, "repo": repo }); let (status, preview) = site.post("renderMarkdown", input); assert_eq!((status, &preview["html"]), (200, &shown)); // Without a repository, relative links resolve from the site's root. let bare = serde_json::json!({ "text": "[a](b.md)" }); let (_, preview) = site.post("renderMarkdown", bare); assert_eq!(preview["html"], "

a

\n"); // Longer than a comment may be, or in a repository no record's URI // names, it is refused. let long = serde_json::json!({ "text": "a".repeat(20_001) }); let (status, why) = site.post("renderMarkdown", long); assert_eq!( (status, why["error"].as_str()), (400, Some("InvalidRequest")) ); assert!( why["message"] .as_str() .unwrap() .contains("longer than 20000") ); let elsewhere = serde_json::json!({ "text": "a", "repo": "noted" }); let (status, why) = site.post("renderMarkdown", elsewhere); assert_eq!(status, 400); assert!(why["message"].as_str().unwrap().contains("is not at://")); } /// Deletes `owner`'s record at `collection`/`rkey`, as any client could. fn delete_record(owner: &Sandbox, collection: &str, rkey: &str) { let client = Client::new(); let session: Value = client .post(format!( "{}/xrpc/com.atproto.server.createSession", net().pds )) .json(&serde_json::json!({ "identifier": owner.account.did, "password": owner.account.password, })) .send() .and_then(|r| r.json()) .unwrap(); let token = session["accessJwt"].as_str().unwrap(); let deleted = client .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", net().pds)) .bearer_auth(token) .json(&serde_json::json!({ "repo": owner.account.did, "collection": collection, "rkey": rkey, })) .send() .unwrap(); assert!(deleted.status().is_success()); } /// Real Cargo, with a home of its own, knowing nothing but `registries`. struct Cargo { home: std::path::PathBuf, token: String, } impl Cargo { fn run(&self, dir: &std::path::Path, args: &[&str]) -> (bool, String) { let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()); let mut command = std::process::Command::new(cargo); for (var, _) in std::env::vars() { // What building this test set, as a coverage run does. let building = ["CARGO", "RUSTC", "RUSTFLAGS", "LLVM_", "RUSTDOC"]; if building.iter().any(|b| var.starts_with(b)) { command.env_remove(var); } } let out = command .args(args) .current_dir(dir) .env("CARGO_HOME", &self.home) .env("CARGO_TARGET_DIR", dir.join("target")) .env("CARGO_TERM_COLOR", "never") .env("CARGO_REGISTRIES_HERE_TOKEN", &self.token) .output() .unwrap(); let said = String::from_utf8_lossy(&out.stderr).into_owned(); ( out.status.success(), said + &String::from_utf8_lossy(&out.stdout), ) } fn ok(&self, dir: &std::path::Path, args: &[&str]) -> String { let (ok, said) = self.run(dir, args); assert!(ok, "cargo {args:?}: {said}"); said } } /// A crate's directory, of `toml` after its `[package]`, and `lib` or `main`. fn cargo_crate( root: &std::path::Path, name: &str, version: &str, toml: &str, file: &str, code: &str, ) -> std::path::PathBuf { let dir = root.join(format!("{name}-{version}")); std::fs::create_dir_all(dir.join("src")).unwrap(); let manifest = format!( "[package]\nname = \"{name}\"\nversion = \"{version}\"\nedition = \"2021\"\ndescription = \"says {name}\"\nlicense = \"MIT\"\n{toml}\n" ); std::fs::write(dir.join("Cargo.toml"), manifest).unwrap(); std::fs::write(dir.join("src").join(file), code).unwrap(); dir } #[test] fn a_cargo_registry() { use serde_json::json; const PUBLISH: [&str; 4] = ["publish", "--registry", "here", "--allow-dirty"]; let site = serve(true); let owner = person(account(), &site); let handle = owner.account.handle.clone(); let url = owner.url("crates"); ok(&owner, &["repo", "create", &url]); let work = owner.work("w", 1); owner.git(&work, &["push", "-q", &url, "main"]); let did = owner.account.did.clone(); let key = owner.record("dev.knotless.repo", "crates")["key"] .as_str() .unwrap() .to_string(); let here = format!("/cargo/{did}/{key}"); wait_until(|| site.status(&format!("{here}/config.json")).0 == 200); let config = site.json(&format!("{here}/config.json")); assert_eq!( config["api"].as_str().unwrap(), format!("{}{here}", site.base) ); assert_eq!( config["dl"].as_str().unwrap(), format!("{}{here}/dl/{{crate}}/{{version}}/download", site.base) ); // Where cargo login sends its user, what the token is. let (status, page) = site.status(&format!("{here}/me")); assert_eq!(status, 200); assert!(page.contains(":"), "{page}"); assert!(page.contains(&did), "{page}"); let root = owner.path("cargo"); let home = root.join("home"); std::fs::create_dir_all(&home).unwrap(); std::fs::write( home.join("config.toml"), format!( "[registries.here]\nindex = \"sparse+{}{here}/\"\n", site.base ), ) .unwrap(); let cargo = Cargo { home, token: format!("{handle}:{}", owner.account.password), }; // A crate, and one depending on it, renamed and with a namespaced feature. let leaf = cargo_crate( &root, "leaf", "1.0.0", "[features]\nnothing = []\n", "lib.rs", "pub fn says() -> u8 { 1 }\n", ); cargo.ok(&leaf, &PUBLISH); let extra = cargo_crate( &root, "extra", "1.0.0", "", "lib.rs", "pub fn says() -> u8 { 2 }\n", ); cargo.ok(&extra, &PUBLISH); let mid = cargo_crate( &root, "mid", "0.2.0", "[dependencies]\nnamed = { package = \"leaf\", version = \"1\", registry = \"here\" }\nextra = { version = \"1\", registry = \"here\", optional = true }\n[features]\nwith-extra = [\"dep:extra\"]\n", "lib.rs", "pub fn says() -> u8 { named::says() + 40 }\n", ); let said = cargo.ok(&mid, &PUBLISH); // Found at once, with no wait for the firehose. assert!(!said.contains("timed out"), "{said}"); assert!(said.contains("Published mid v0.2.0"), "{said}"); // The index's line and a read of it, by its ETag. let line = site.status(&format!("{here}/3/m/mid")).1; let line: Value = serde_json::from_str(line.trim()).unwrap(); assert_eq!( (line["vers"].as_str(), line["v"].as_i64()), (Some("0.2.0"), Some(2)) ); assert_eq!(line["yanked"], false); let index = format!("{}{here}/3/m/mid", site.base); let first = reqwest::blocking::get(&index).unwrap(); let etag = first.headers()["etag"].to_str().unwrap().to_string(); let again = reqwest::blocking::Client::new() .get(&index) .header("if-none-match", &etag) .send() .unwrap(); assert_eq!(again.status(), 304); assert_eq!(site.status(&format!("{here}/m/id/mid")).0, 404); assert_eq!(site.status(&format!("{here}/no/ne/none")).0, 404); assert_eq!( site.status(&format!("{here}/dl/none/1.0.0/download")).0, 404 ); // Used by something that names only the registry, from nothing kept. let app_toml = "[dependencies]\nmid = { version = \"0.2\", registry = \"here\", features = [\"with-extra\"] }\n"; let app = cargo_crate( &root, "app", "0.0.1", app_toml, "main.rs", "fn main() { println!(\"{}\", mid::says()); }\n", ); let said = cargo.ok(&app, &["run", "-q"]); assert!(said.contains("41"), "{said}"); // Refused: what is there, a token that is no good, another's, or none. let (done, said) = cargo.run(&leaf, &PUBLISH); assert!(!done && said.contains("already exists"), "{said}"); let other = cargo_crate(&root, "other", "1.0.0", "", "lib.rs", ""); let denied = |token: &str| { let cargo = Cargo { home: cargo.home.clone(), token: token.into(), }; let (done, said) = cargo.run(&other, &PUBLISH); assert!(!done && said.contains("403"), "{said}"); said }; assert!(denied(&format!("{handle}:wrong")).contains("could not log in")); assert!(denied("nocolon").contains("log in: cargo login")); let stranger = account(); let theirs = format!("{}:{}", stranger.handle, stranger.password); assert!(denied(&theirs).contains("only ")); // What Cargo would never send is refused too. let put = |crate_: &[u8], said: Value, token: &str| { let said = said.to_string(); let mut body = (said.len() as u32).to_le_bytes().to_vec(); body.extend(said.as_bytes()); body.extend((crate_.len() as u32).to_le_bytes()); body.extend(crate_); reqwest::blocking::Client::new() .put(format!("{}{here}/api/v1/crates/new", site.base)) .header("authorization", token) .body(body) .send() .unwrap() }; let packed = |dir: &std::path::Path, name: &str, version: &str| { cargo.ok( dir, &[ "package", "--allow-dirty", "--no-verify", "--registry", "here", ], ); std::fs::read( dir.join("target/package") .join(format!("{name}-{version}.crate")), ) .unwrap() }; let token = cargo.token.as_str(); let crate_ = packed(&other, "other", "1.0.0"); let says = |res: reqwest::blocking::Response| (res.status().as_u16(), res.text().unwrap()); let meta = |name: &str, version: &str| json!({"name": name, "vers": version}); let (status, said) = says(put(&crate_, meta("claims", "1.0.0"), token)); assert!( status == 400 && said.contains("its Cargo.toml is not"), "{said}" ); let (status, said) = says(put(&crate_, meta("Other", "1.0.0"), token)); assert!(status == 400 && said.contains("package's name"), "{said}"); let (status, said) = says(put(b"not a crate", meta("other", "1.0.0"), token)); assert!(status == 400 && said.contains("no Cargo.toml"), "{said}"); let (status, said) = says(put(&crate_, json!("no"), token)); assert_eq!(status, 400, "{said}"); let raw = reqwest::blocking::Client::new() .put(format!("{}{here}/api/v1/crates/new", site.base)) .header("authorization", token) .body("xx") .send() .unwrap(); assert_eq!(raw.status(), 400); let long = "x".repeat(100_001); let (status, said) = says(put( &crate_, json!({"name": "other", "vers": "1.0.0", "links": long}), token, )); assert!(status == 400 && said.contains("index line"), "{said}"); // Build metadata makes a record of another key, which then goes. let twin = cargo_crate(&root, "leaf", "1.0.0+b", "", "lib.rs", ""); let crate_ = packed(&twin, "leaf", "1.0.0+b"); let (status, said) = says(put(&crate_, meta("leaf", "1.0.0+b"), token)); assert!( status == 400 && said.contains("leaf 1.0.0 is published already"), "{said}" ); let versions = |name: &str| -> Vec { owner .records("dev.knotless.release") .into_iter() .filter(|(_, r)| r["package"] == name) .map(|(_, r)| r["version"].as_str().unwrap().to_string()) .collect() }; assert_eq!(versions("leaf"), ["1.0.0"]); // A yank: a built lockfile still builds from nothing kept, and nothing // new takes it, until it is undone. Told at once, as a publish is. let yank = ["yank", "--registry", "here", "--version", "1.0.0", "extra"]; cargo.ok(&extra, &yank); let line = site.status(&format!("{here}/ex/tr/extra")).1; assert!(line.contains("\"yanked\":true"), "{line}"); std::fs::remove_dir_all(cargo.home.join("registry")).unwrap(); std::fs::remove_dir_all(app.join("target")).unwrap(); assert!(cargo.ok(&app, &["run", "-q", "--locked"]).contains("41")); let new_toml = "[dependencies]\nextra = { version = \"1\", registry = \"here\" }\n"; let new = cargo_crate(&root, "new", "0.0.1", new_toml, "main.rs", "fn main() {}\n"); let (done, said) = cargo.run(&new, &["build"]); assert!(!done && said.contains("yanked"), "{said}"); cargo.ok( &extra, &[ "yank", "--registry", "here", "--undo", "--version", "1.0.0", "extra", ], ); cargo.ok(&new, &["build"]); let (done, said) = cargo.run( &extra, &["yank", "--registry", "here", "--version", "9.0.0", "extra"], ); assert!( !done && said.contains("has published no extra 9.0.0"), "{said}" ); // Searched for by name, each at its latest, as many as asked. let found = site.json(&format!("{here}/api/v1/crates?q=e&per_page=1")); assert_eq!(found["meta"]["total"], 2); assert_eq!( found["crates"][0], json!({"name": "extra", "max_version": "1.0.0", "description": "says extra"}) ); assert_eq!(found["crates"].as_array().unwrap().len(), 1); let said = cargo.ok(&root, &["search", "--registry", "here", "mid"]); assert!( said.contains("mid = \"0.2.0\"") && said.contains("says mid"), "{said}" ); let everything = site.json(&format!("{here}/api/v1/crates")); assert_eq!(everything["meta"]["total"], 3); // The releases page knows each for a crate. let repo = format!("at://{did}/dev.knotless.repo/crates"); let listed = site.json(&xrpc("listReleases", &[("repo", &repo)])); let listed = listed["releases"].as_array().unwrap(); assert!(listed.iter().all(|r| r["crate"] == true), "{listed:?}"); // A file whose bytes are not what its record says is not sent, and a // body too large is refused as it is read. let blob = owner.upload_blob(b"x", "application/octet-stream"); let bad = serde_json::json!({ "$type": "dev.knotless.release", "repo": key, "package": "bad", "version": "1.0.0", "cargo": {"index": json!({"name": "bad", "vers": "1.0.0"}).to_string()}, "files": [{"name": "bad-1.0.0.crate", "size": 1, "sha256": "a".repeat(64), "parts": [{"blob": blob}]}], "createdAt": "2026-10-27T00:00:00.000Z", }); owner.put_record("dev.knotless.release", "bad", bad); wait_until(|| site.status(&format!("{here}/3/b/bad")).0 == 200); assert_eq!(site.status(&format!("{here}/dl/bad/1.0.0/download")).0, 502); let (status, _) = says( reqwest::blocking::Client::new() .put(format!("{}{here}/api/v1/crates/new", site.base)) .header("authorization", token) .body(vec![0u8; (64 << 20) + 1]) .send() .unwrap(), ); assert_eq!(status, 413); // A repository that is not there is none of it. assert_eq!( site.status(&format!("/cargo/{did}/nokey/config.json")).0, 404 ); let (status, _) = says( reqwest::blocking::Client::new() .put(format!("{}/cargo/{did}/nokey/api/v1/crates/new", site.base)) .header("authorization", token) .body("xx") .send() .unwrap(), ); assert_eq!(status, 404); } #[test] fn the_releases() { let site = serve(true); let owner = person(account(), &site); let handle = owner.account.handle.clone(); let url = owner.url("shipped"); ok(&owner, &["repo", "create", &url]); let work = owner.work("w", 1); owner.git(&work, &["push", "-q", &url, "main"]); let none = owner.url("unshipped"); ok(&owner, &["repo", "create", &none]); // The client twice, a pre-release of it after, and an image: one // whose files are an OCI layout's, its index beside its blobs. let file = |name: &str, bytes: &[u8]| { let path = owner.path(name); std::fs::write(&path, bytes).unwrap(); path.to_string_lossy().into_owned() }; let tarball = file("tool-x86_64.tar.gz", b"tarball"); let index = file("index.json", b"{}"); let blob = file(&format!("sha256-{}", "a".repeat(64)), b"layer"); let publish = |package: &str, version: &str, files: &[&str], notes: &str| { let mut args = vec!["release", "publish", &url, package, version]; args.extend(files); args.extend(["--notes", notes]); ok(&owner, &args).trim().to_string() }; let first = publish("tool", "0.1.0", &[&tarball], "The **first** one."); publish( "tool", "0.1.1", &[&tarball], "See [the guide](docs/guide.md).", ); publish("image", "1.0.0", &[&index, &blob], "An image."); publish("tool", "0.2.0-rc.1", &[&tarball], "Try it."); // A record that is no release, by its version, is passed by. let key = owner.record("dev.knotless.repo", "shipped")["key"].clone(); let bogus = serde_json::json!({ "$type": "dev.knotless.release", "repo": key, "package": "tool", "version": "not one", "files": [], "createdAt": "2026-10-27T00:00:00.000Z", }); owner.put_record("dev.knotless.release", "bogus", bogus); let repo = format!("at://{handle}/dev.knotless.repo/shipped"); let list = |more: &str| { site.json(&format!( "{}{more}", xrpc("listReleases", &[("repo", &repo)]) )) }; wait_until(|| list("")["releases"].as_array().unwrap().len() == 4); let all = list(""); let shown: Vec<(&str, &str, bool, bool)> = all["releases"] .as_array() .unwrap() .iter() .map(|r| { ( r["package"].as_str().unwrap(), r["version"].as_str().unwrap(), r["latest"] == true, r["image"] == true, ) }) .collect(); // Newest first; the latest of each is its highest without a // pre-release part, so not the pre-release, newest though it is. assert_eq!( shown, [ ("tool", "0.2.0-rc.1", false, false), ("image", "1.0.0", true, true), ("tool", "0.1.1", true, false), ("tool", "0.1.0", false, false), ] ); let r = &all["releases"][2]; let html = r["html"].as_str().unwrap(); assert!(html.contains(&format!("href=\"/{handle}/shipped/docs/guide.md\""))); assert_eq!(r["notes"], "See [the guide](docs/guide.md)."); assert_eq!(r["files"][0]["name"], "tool-x86_64.tar.gz"); assert_eq!(r["files"][0]["size"], 7); assert_eq!(r["files"][0]["sha256"].as_str().unwrap().len(), 64); assert_eq!( all["packages"], serde_json::json!([ {"name": "image", "latest": "1.0.0", "image": true, "releaseCount": 1}, {"name": "tool", "latest": "0.1.1", "releaseCount": 3}, ]) ); assert!(all["cursor"].is_null()); // One package's, a page at a time. let versions = |page: &Value| -> Vec { let releases = page["releases"].as_array().unwrap().iter(); releases .map(|r| r["version"].as_str().unwrap().to_string()) .collect() }; let tool = list("&package=tool&limit=2"); assert_eq!(versions(&tool), ["0.2.0-rc.1", "0.1.1"]); assert_eq!(tool["packages"].as_array().unwrap().len(), 2); let cursor = tool["cursor"].as_str().unwrap(); let rest = list(&format!("&package=tool&limit=2&cursor={cursor}")); assert_eq!(versions(&rest), ["0.1.0"]); assert!(rest["cursor"].is_null()); let bad = format!("{}&cursor=x", xrpc("listReleases", &[("repo", &repo)])); assert_eq!(site.status(&bad).0, 400); // As an Atom feed: each version, named by its record, with its notes // as HTML, linked to its package's releases. let feed = site .get(&format!("/{handle}/shipped/releases.atom")) .text() .unwrap(); assert_eq!(feed.matches("").count(), 4); assert!(feed.contains("tool 0.1.1")); assert!(feed.contains(&format!("{}", r["uri"].as_str().unwrap()))); assert!(feed.contains(&format!( "{}/{handle}/shipped/releases?package=tool\"", site.base ))); assert!(feed.contains("<p>See <a href="")); assert!(feed.contains(&format!( "Releases of {handle}/shipped" ))); // The repository's pages say, under its name, the newest that is its // package's latest, and its packages; the one with none says nothing. let head = site.json(&xrpc("getRepo", &[("repo", &repo)])); let summary = &head["releases"]; assert_eq!(summary["count"], 4); let release = &summary["release"]; assert_eq!( (&release["package"], &release["version"]), (&"image".into(), &"1.0.0".into()) ); assert_eq!( (&release["latest"], &release["image"]), (&true.into(), &true.into()) ); assert_eq!(summary["packages"], all["packages"]); let unshipped = format!("at://{handle}/dev.knotless.repo/unshipped"); let bare = site.json(&xrpc("getRepo", &[("repo", &unshipped)])); assert!(bare["releases"].is_null()); let listed = site.json(&xrpc("listReleases", &[("repo", &unshipped)])); assert_eq!(listed["releases"], serde_json::json!([])); assert!(listed["packages"].is_null()); // Each file downloads from the pages, as it was published, to be // saved rather than shown, and asked for again by its hash. let download = |rest: &str, etag: Option<&str>| { let path = format!("{}/{handle}/shipped/releases/download/{rest}", site.base); let mut asked = Client::new().get(path); if let Some(etag) = etag { asked = asked.header("if-none-match", etag); } asked.send().unwrap() }; let got = download("tool/0.1.1/tool-x86_64.tar.gz", None); assert_eq!(got.status().as_u16(), 200); let says = |name: &str| got.headers()[name].to_str().unwrap().to_string(); let sha256 = r["files"][0]["sha256"].as_str().unwrap(); assert_eq!(says("content-type"), "application/octet-stream"); assert_eq!( says("content-disposition"), "attachment; filename=\"tool-x86_64.tar.gz\"" ); assert_eq!(says("content-length"), "7"); assert_eq!(says("cache-control"), "no-cache"); assert_eq!(says("x-content-type-options"), "nosniff"); assert!(says("content-security-policy").starts_with("sandbox")); let etag = says("etag"); assert_eq!(etag, format!("\"{sha256}\"")); assert!(got.headers().get("content-encoding").is_none()); assert_eq!(got.bytes().unwrap().as_ref(), b"tarball"); let again = download("tool/0.1.1/tool-x86_64.tar.gz", Some(&etag)); assert_eq!(again.status().as_u16(), 304); // Kept, the same file of another version is not read again. let kept = download("tool/0.1.0/tool-x86_64.tar.gz", None); assert_eq!(kept.bytes().unwrap().as_ref(), b"tarball"); for missing in [ "tool/0.1.1/nothing.tar.gz", "tool/9.9.9/tool-x86_64.tar.gz", "other/0.1.1/tool-x86_64.tar.gz", "tool-x86_64.tar.gz", "0.1.1/tool-x86_64.tar.gz", ] { assert_eq!(download(missing, None).status().as_u16(), 404, "{missing}"); } // Unpublished, a version goes. let rkey = first.rsplit('/').next().unwrap(); delete_record(&owner, "dev.knotless.release", rkey); wait_until(|| list("")["releases"].as_array().unwrap().len() == 3); let head = site.json(&xrpc("getRepo", &[("repo", &repo)])); assert_eq!(head["releases"]["count"], 3); // Taken down, a release leaves the list and its files are not handed // out. let uri = r["uri"].as_str().unwrap(); let index = site.app.index.clone().unwrap(); let subject = serde_json::json!({"$type": "com.atproto.repo.strongRef", "uri": uri}); index .take_down("did:web:here", uri, &subject, true) .unwrap(); assert_eq!(list("")["releases"].as_array().unwrap().len(), 2); let gone = download("tool/0.1.1/tool-x86_64.tar.gz", None); assert_eq!(gone.status().as_u16(), 404); } #[test] fn badges_count_where_an_account_lives() { use knotless_serve::index::Home; let site = serve(true); // The reference PDS holds more accounts than one of an account's own. let others: Vec = (0..6).map(|_| account()).collect(); let did = &others[0].did; let shown = format!("/xrpc/dev.knotless.getProfile?actor={did}"); assert!(site.json(&shown)["badges"].is_null()); // Looked up as the profile was first asked for: an account that never // moved, on a PDS of many. let index = site.app.index.as_ref().unwrap(); let (home, _) = index.home(did).unwrap().unwrap(); assert_eq!(home, Home::default()); // Kept a day, what was found shows, and is looked up again. let kept = Home { moved: true, own: true, }; index.set_home(did, kept, 0).unwrap(); let kinds: Vec = site.json(&shown)["badges"] .as_array() .unwrap() .iter() .map(|b| b["kind"].clone()) .collect(); assert_eq!(kinds, ["carry-your-own", "moved-house"]); wait_until(|| index.home(did).unwrap().unwrap().0 == Home::default()); } #[test] fn the_steps_shared() { let site = serve(true); let owner = person(account(), &site); let handle = owner.account.handle.clone(); let url = owner.url("steps"); ok(&owner, &["repo", "create", &url]); let work = owner.work("w", 1); owner.git(&work, &["push", "-q", &url, "main"]); let commit = owner.git(&work, &["rev-parse", "HEAD"]); // Steps, each released as its package with the commit it is at; a // file that is no step, and a release naming no commit, are passed by. let file = |name: &str, text: &str| { let path = owner.path(name); std::fs::write(&path, text).unwrap(); path.to_string_lossy().into_owned() }; let wait = file( "wait.yml", "description: Waits for this commit's checks.\ninputs:\n workflows: {description: Which must pass, required: true}\n minutes: {default: \"25\"}\nneeds: [curl]\nsecrets: [TOKEN]\nsteps:\n - run: echo\n", ); let lint = file("lint.yml", "steps:\n - run: cargo clippy\n"); let broken = file("broken.yml", "not: a step\n"); let publish = |package: &str, version: &str, path: &str, commit: Option<&str>| { let mut args = vec!["release", "publish", &url, package, version, path]; args.extend(commit.map(|c| ["--commit", c]).into_iter().flatten()); ok(&owner, &args); }; publish("@step/wait", "1.0.0", &wait, Some(&commit)); let repo = format!("at://{handle}/dev.knotless.repo/steps"); let releases = || site.json(&xrpc("listReleases", &[("repo", &repo)]))["releases"].clone(); wait_until(|| releases().as_array().unwrap().len() == 1); // What was indexed before they start is read first. let rt = &site._rt; rt.spawn(knotless_serve::shared::follow(site.app.clone())); rt.spawn(knotless_serve::languages::follow(site.app.clone())); publish("@step/wait", "1.1.0", &wait, Some(&commit)); publish("@step/wait", "2.0.0-rc.1", &wait, Some(&commit)); publish("@step/lint", "0.3.0", &lint, Some(&commit)); publish("@step/broken", "1.0.0", &broken, Some(&commit)); publish("@step/loose", "1.0.0", &lint, None); wait_until(|| releases().as_array().unwrap().len() == 6); let list = |params: &[(&str, &str)]| site.json(&xrpc("listSteps", params)); let names = |listed: &Value| -> Vec { let steps = listed["steps"].as_array().unwrap().iter(); steps.map(|s| s["name"].as_str().unwrap().into()).collect() }; wait_until(|| names(&list(&[])).len() == 2 && list(&[])["steps"][1]["version"] == "1.1.0"); let all = list(&[]); assert_eq!(names(&all), ["lint", "wait"]); let shown = &all["steps"][1]; assert_eq!(shown["repo"]["handle"], handle.as_str()); assert_eq!(shown["repo"]["name"], "steps"); assert_eq!(shown["description"], "Waits for this commit's checks."); assert_eq!(shown["secretCount"], 1); assert_eq!(shown["useCount"], 0); assert!(all["steps"][0]["description"].is_null()); assert!(all["cursor"].is_null()); // A page at a time, by name; by how a name starts; one repository's. let first = list(&[("limit", "1")]); assert_eq!(names(&first), ["lint"]); let cursor = first["cursor"].as_str().unwrap().to_string(); let rest = list(&[("limit", "1"), ("cursor", &cursor)]); assert_eq!(names(&rest), ["wait"]); assert!(rest["cursor"].is_null()); assert_eq!(names(&list(&[("q", "wa")])), ["wait"]); assert!(names(&list(&[("q", "x")])).is_empty()); assert_eq!(names(&list(&[("repo", &repo)])).len(), 2); let other = owner.url("user"); ok(&owner, &["repo", "create", &other]); let used_in = format!("at://{handle}/dev.knotless.repo/user"); let status = |nsid: &str, params: &[(&str, &str)]| site.get(&xrpc(nsid, params)).status().as_u16(); wait_until(|| status("listSteps", &[("repo", &used_in)]) == 200); assert!(names(&list(&[("repo", &used_in)])).is_empty()); assert_eq!(status("listSteps", &[("repo", "at://nope")]), 400); let nowhere = format!("at://{handle}/dev.knotless.repo/nowhere"); assert_eq!(status("listSteps", &[("repo", &nowhere)]), 404); let unknown = "at://nobody.invalid/dev.knotless.repo/x"; assert_eq!(status("listSteps", &[("repo", unknown)]), 404); // One step: what it takes and asks for, and its versions, the highest // first. let get = |name: &str| site.json(&xrpc("getStep", &[("repo", &repo), ("name", name)])); let step = get("wait"); assert_eq!(step["version"], "1.1.0"); assert_eq!(step["commit"], commit.as_str()); assert_eq!(step["path"], ".knotless/steps/wait.yml"); assert_eq!(step["uses"], format!("{handle}/steps/wait@1")); assert_eq!( step["inputs"], serde_json::json!([ {"name": "minutes", "required": false, "default": "25"}, {"name": "workflows", "required": true, "description": "Which must pass"}, ]) ); assert_eq!(step["needs"], serde_json::json!([{"name": "curl"}])); assert_eq!(step["secrets"], serde_json::json!([{"name": "TOKEN"}])); let versions: Vec<&str> = step["versions"] .as_array() .unwrap() .iter() .map(|v| v["version"].as_str().unwrap()) .collect(); assert_eq!(versions, ["2.0.0-rc.1", "1.1.0", "1.0.0"]); assert_eq!(get("lint")["uses"], format!("{handle}/steps/lint@0.3")); for missing in ["broken", "loose", "nothing"] { assert_eq!( status("getStep", &[("repo", &repo), ("name", missing)]), 404 ); } // Used, as a repository's lock on its default branch pins it: read as // what the repository is written in is counted, once a list shows it. // Pinned twice, it is one repository using it. let user = owner.work("u", 1); std::fs::create_dir_all(user.join(".knotless")).unwrap(); let pin = format!("{{version: 1.1.0, commit: {commit}}}"); let lock = format!( "{handle}/steps/wait@1: {pin}\n{handle}/steps/wait@1.1: {pin}\n{handle}/nowhere/wait@1: {pin}\nnobody.invalid/x/y@1: {pin}\nnot-a-use: {pin}\n" ); owner.commit_file(&user, ".knotless/uses.lock", &lock); owner.git(&user, &["push", "-q", &other, "main"]); let users = || { site.profile(&handle); get("wait")["useCount"].clone() }; wait_until(|| users() == 1); let step = get("wait"); assert_eq!( step["usedBy"], serde_json::json!([{"did": owner.account.did, "handle": handle, "name": "user"}]) ); assert_eq!(list(&[("q", "wait")])["steps"][0]["useCount"], 1); // Taken down, a step's releases leave it. let index = site.app.index.clone().unwrap(); for v in step["versions"].as_array().unwrap() { let uri = v["uri"].as_str().unwrap(); let subject = serde_json::json!({"$type": "com.atproto.repo.strongRef", "uri": uri}); index .take_down("did:web:here", uri, &subject, true) .unwrap(); } assert_eq!(names(&list(&[])), ["lint"]); // With no index, there are none. let plain = serve(false); assert_eq!(plain.get(&xrpc("listSteps", &[])).status().as_u16(), 404); plain ._rt .block_on(knotless_serve::shared::follow(plain.app.clone())); // Nor languages to count, nor, with no gateway, spaces to follow: each // returns at once. Run here, not as a server starts, where it is a // task the test may end before it is run, and its lines counted or // not by chance. plain ._rt .block_on(knotless_serve::languages::follow(plain.app.clone())); let every = Duration::from_secs(60); plain ._rt .block_on(knotless_serve::spaces::follow(plain.app.clone(), every)); } /// Code search, over Zoekt: what the default branch of each public /// repository holds, found in all of them or in one, as it is pushed; and /// a repository deleted found no more. #[test] fn searching_code() { // A server that searches no code says so. let plain = serve(false); let asked = plain.status("/xrpc/dev.knotless.searchCode?q=x"); assert_eq!(asked.0, 501, "{}", asked.1); let site = serve_with(Sandbox::new(), true, true); let s = person(account(), &site); let handle = s.account.handle.clone(); // With nothing pushed, there is nothing to index. ok(&s, &["repo", "create", &s.url("unpushed")]); // Pushed before code is indexed: what has no shards yet is indexed as // indexing starts. for (name, n) in [("needle-one", 1), ("needle-two", 2)] { let work = s.work(name, 1); let lib = format!("// the haystack\nfn needle_{n}() {{}}\nfn other() {{}}\n"); write(&work, "src/lib.rs", lib.as_bytes()); s.git(&work, &["add", "-A"]); s.git(&work, &["commit", "-q", "-m", "Hide a needle"]); s.git(&work, &["push", "-q", &s.url(name), "main"]); } wait_until(|| names(&site.search("needle")["repos"]).len() == 2); let follow = knotless_serve::code::follow(site.app.clone()); site._rt.spawn(follow.expect("a code index")); let search = |q: &str| site.json(&format!("/xrpc/dev.knotless.searchCode?q={q}")); let found = |q: &str| search(q)["files"].as_array().unwrap().len(); wait_until(|| found("needle_") == 2); let one = search("needle_1"); assert_eq!( (one["fileCount"].as_u64(), one["matchCount"].as_u64()), (Some(1), Some(1)) ); // Its languages, counted across all it finds. assert_eq!( one["languages"], serde_json::json!([{"name": "Rust", "color": "#dea584", "files": 1}]) ); // Picking another language finds nothing, but still lists them all. let other = search("needle_+lang:Markdown"); assert_eq!(other["files"].as_array().unwrap().len(), 0, "{other}"); assert_eq!(other["languages"][0]["files"], 2, "{other}"); let file = &one["files"][0]; assert_eq!(file["repo"]["name"], "needle-one"); assert_eq!(file["repo"]["owner"]["handle"], handle.as_str()); assert_eq!(file["path"], "src/lib.rs"); assert_eq!(file["language"], "Rust"); let commit = s.git(&s.path("needle-one"), &["rev-parse", "HEAD"]); assert_eq!(file["commit"], commit.trim()); let chunk = &file["chunks"][0]; assert_eq!(chunk["startLine"], 1, "{chunk}"); assert_eq!( chunk["content"], "// the haystack\nfn needle_1() {}\nfn other() {}" ); assert_eq!( chunk["ranges"], serde_json::json!([{"line": 2, "byteStart": 3, "byteEnd": 11}]) ); // Painted as its file's language reads: the comment first. let painted = chunk["highlights"].as_str().unwrap(); assert!(painted.starts_with("comment:0,"), "{painted}"); // A page at a time, the best first, and the next after the cursor. let first = search("needle_&limit=1"); assert_eq!(first["files"].as_array().unwrap().len(), 1, "{first}"); assert_eq!(first["cursor"], "1", "{first}"); let next = search("needle_&limit=1&cursor=1"); assert_eq!(next["files"].as_array().unwrap().len(), 1, "{next}"); assert_ne!(next["files"][0]["repo"], first["files"][0]["repo"]); assert!(next.get("cursor").is_none(), "{next}"); // Its languages were listed with the first. assert_eq!(next["languages"], serde_json::json!([]), "{next}"); // No deeper than the pages go. let deep = search("needle_&cursor=1000"); assert_eq!(deep["files"].as_array().unwrap().len(), 0, "{deep}"); let bad = site.status("/xrpc/dev.knotless.searchCode?q=x&cursor=no"); assert_eq!(bad.0, 400, "{}", bad.1); // In one repository, by its record's URI. let within = |name: &str| { let repo = format!("at://{handle}/dev.knotless.repo/{name}"); site.json(&format!( "/xrpc/dev.knotless.searchCode?q=needle_&repo={repo}&limit=5" )) }; let two = within("needle-two"); assert_eq!(two["files"].as_array().unwrap().len(), 1, "{two}"); assert_eq!(two["files"][0]["repo"]["name"], "needle-two"); // A push is found once it is indexed again. let work = s.path("needle-two"); write(&work, "src/more.rs", b"fn needle_3() {}\n"); s.git(&work, &["add", "-A"]); s.git(&work, &["commit", "-q", "-m", "Hide another"]); s.git(&work, &["push", "-q", &s.url("needle-two"), "main"]); wait_until(|| found("needle_3") == 1); // A query Zoekt cannot read is the asker's to fix. let bad = site.status("/xrpc/dev.knotless.searchCode?q=("); assert_eq!(bad.0, 400, "{}", bad.1); // A repository deleted is found no more. // This account's, as others' repositories on the PDS are indexed too: // each named for its owner's DID, `:` written `_3a`. let theirs = s.account.did.replace(':', "_3a"); let shards = || { let dir = std::fs::read_dir(site._sandbox.path("code")).unwrap(); let names = dir.map(|e| e.unwrap().file_name().into_string().unwrap()); let mine = |n: &String| n.starts_with(&theirs) && n.ends_with(".zoekt"); names.filter(mine).count() }; // A push's new shard is found before the one it replaces is deleted. wait_until(|| shards() == 2); // Each pushed repository has its meta file, and the unpushed one none. let metas = std::fs::read_dir(site._sandbox.path("code").join("meta")).unwrap(); let metas = metas.map(|e| e.unwrap().file_name().into_string().unwrap()); assert_eq!(metas.filter(|n| n.starts_with(&theirs)).count(), 2); // `/_status` tells of the index. let (_, status) = site.status("/_status"); let status: Value = serde_json::from_str(&status).unwrap(); assert_eq!(status["code"]["ok"], true, "{status}"); assert!(status["code"]["shards"].as_u64().unwrap() >= 2, "{status}"); ok(&s, &["repo", "delete", &s.url("needle-one"), "--yes"]); wait_until(|| found("needle_1") == 0); // And its shards are deleted. wait_until(|| shards() == 1); wait_until(|| found("needle_") == 2); }