johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
//! 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<App>,
_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> = 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<u8>, 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`, `/<owner>/<name>` 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<u8> {
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<script>alert(1)</script>\n\n[guide](docs/guide.md) [docs](docs) [x](javascript:alert(1)) \n");
write(&work, "docs/README", b"plain <words>\n");
write(
&work,
"docs/guide.md",
b"# Guide\n\n<img src=x onerror=alert(1)>\n\n[home](../README.md) [up](../../../x) [top](/src/main.rs#L2) [src](../src/) \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 [
"<h1>Browsed</h1>",
"<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<String> = 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 <words>\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 [
"<h1>Guide</h1>",
"<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("<entry>").count(), 30);
assert!(commits.contains(&format!("<title>Commits to {h}/browsed</title>")));
assert!(commits.contains(&format!("{}{repo}/commits/main\"", site.base)));
let first = &commits[commits.find("<entry>").unwrap()..];
assert!(first.contains("<title>Lay out the tree</title>"));
assert!(first.contains(&format!(
"<id>{}{repo}/commit/{}</id>",
site.base,
tip.trim()
)));
assert!(first.contains(
"<content type=\"text\">Lay out the tree\n\nWith a body that says why.</content>"
));
let tagged = feed("tags");
assert_eq!(tagged.matches("<entry>").count(), 2);
assert!(tagged.contains("<title>v1</title>"));
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!("<id>{}{repo}/tree/{tag}</id>", site.base);
assert!(tagged.contains(&id), "{tag}");
}
// With nothing released, a feed of no entries.
let none = feed("releases");
assert!(!none.contains("<entry>"));
assert!(none.contains("<updated>1970-01-01T00:00:00Z</updated>"));
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::<Value>().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 <noreply@anthropic.com>\nCo-authored-by: Me <t@example.com>";
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::<Value>().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("<feed") && !feed.contains("<entry>"));
// 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::<Value>().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 <someone@example.org>"];
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 <things>"],
);
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 <other@example.com>",
"-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 <thing>"]);
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("<strong>b</strong>"));
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"], "<p>Thanks</p>\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"<script>1</script>", "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<String> {
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 <things>");
let open = items("/issues");
assert_eq!(titles(&open), ["Broken <thing>"]);
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 <thing>");
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 <Owner>",
"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 <Owner>");
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 <Owner>");
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 <Owner>",
"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 <things>"
);
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 <thing>".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 <to> 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 <to> 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::<Vec<_>>()
};
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 <thing>"]);
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 <thing>", "subjectState": "open"}),
),
(
"commented",
json!({"subject": issue, "text": "Seen it", "title": "Broken <thing>"}),
),
(
"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 <thing>")), (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> = 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::<Value>().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 <b>this</b>.";
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::<Value>(&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"], "<p><a href=\"/b.md\">a</a></p>\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("<handle>:<app password>"), "{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<String> {
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<String> {
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("<entry>").count(), 4);
assert!(feed.contains("<title>tool 0.1.1</title>"));
assert!(feed.contains(&format!("<id>{}</id>", r["uri"].as_str().unwrap())));
assert!(feed.contains(&format!(
"{}/{handle}/shipped/releases?package=tool\"",
site.base
)));
assert!(feed.contains("<content type=\"html\"><p>See <a href=""));
assert!(feed.contains(&format!(
"<author><name>Releases of {handle}/shipped</name></author>"
)));
// 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<Account> = (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<Value> = 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<String> {
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);
}