Krolyxon
Tag v0.2.0

ferreniumtestsindex.rs

288 lines · 10.4 KB · Rust

1//! Discovery, metadata precedence, hiding, the index page and live updates.
2
3mod common;
4
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7
8use axum::body::{Body, to_bytes};
9use axum::http::{Request, StatusCode};
10use common::{Work, bare, git, repo_with};
11use ferrenium::config::Config;
12use ferrenium::index::Refresh;
13use ferrenium::server;
14use ferrenium::state::{AppState, Overrides};
15use tower::ServiceExt;
16
17/// `repos` holds extra `[repos]` keys; `extra` any further tables.
18fn config(scan_path: &std::path::Path, repos: &str, extra: &str) -> Config {
19 let toml = format!(
20 "[repos]\nscan_path = {:?}\n{repos}\n[watch]\ndebounce_ms = 100\n{extra}",
21 scan_path.to_str().unwrap()
22 );
23 let mut config = Config::parse(&toml).expect("parse");
24 config.finish().expect("valid");
25 config
26}
27
28fn state(config: Config) -> Arc<AppState> {
29 let state = AppState::new(config, Overrides::default());
30 let config = state.config.load_full();
31 state.index.full_scan(&config);
32 state
33}
34
35async fn page(state: &Arc<AppState>, uri: &str) -> String {
36 let res = server::app(state.clone())
37 .oneshot(Request::get(uri).body(Body::empty()).unwrap())
38 .await
39 .unwrap();
40 assert_eq!(res.status(), StatusCode::OK);
41 String::from_utf8(
42 to_bytes(res.into_body(), usize::MAX)
43 .await
44 .unwrap()
45 .to_vec(),
46 )
47 .unwrap()
48}
49
50#[test]
51fn metadata_precedence_and_hiding() {
52 let root = tempfile::tempdir().unwrap();
53 let r = root.path();
54
55 // description file only
56 let (a, a_head) = repo_with(r, "alpha.git", &[("README.md", "# alpha\n")]);
57 std::fs::write(a.join("description"), "From the description file\n").unwrap();
58 // git config beats description file; gitweb.* is honored
59 let (b, _) = repo_with(r, "forks/beta.git", &[("readme.txt", "beta\n")]);
60 std::fs::write(b.join("description"), "ignored\n").unwrap();
61 git(&b, &["config", "gitweb.description", "From gitweb"]);
62 git(&b, &["config", "gitweb.owner", "bob"]);
63 // ferrenium.* beats gitweb.*; TOML beats everything
64 let (c, _) = repo_with(r, "gamma.git", &[("x.rs", "fn main() {}\n")]);
65 git(
66 &c,
67 &["config", "ferrenium.description", "From ferrenium config"],
68 );
69 git(&c, &["config", "gitweb.description", "loses"]);
70 git(&c, &["config", "ferrenium.section", "tools"]);
71 // empty repo with git's placeholder description
72 bare(r, "empty.git", &[]);
73 // hidden three ways
74 repo_with(r, "private/secret.git", &[("a", "a")]);
75 let (h2, _) = repo_with(r, "byconfig.git", &[("a", "a")]);
76 git(&h2, &["config", "ferrenium.hidden", "true"]);
77 repo_with(r, "bytoml.git", &[("a", "a")]);
78
79 let state = state(config(
80 r,
81 "hide = [\"private/*\"]\nsection_from_path = true",
82 r#"
83 [[repo]]
84 name = "gamma"
85 description = "From TOML"
86 [[repo]]
87 name = "bytoml"
88 hidden = true
89 [[repo]]
90 name = "nonexistent"
91 "#,
92 ));
93 let index = state.index.load();
94
95 let names: Vec<_> = index.repos.keys().map(String::as_str).collect();
96 assert_eq!(names, ["alpha", "empty", "forks/beta", "gamma"]);
97 let hidden: Vec<_> = index.hidden.iter().map(String::as_str).collect();
98 assert_eq!(hidden, ["byconfig", "bytoml", "private/secret"]);
99
100 let alpha = &index.repos["alpha"].meta;
101 assert_eq!(
102 alpha.description.as_deref(),
103 Some("From the description file")
104 );
105 assert_eq!(alpha.default_branch.as_deref(), Some("main"));
106 assert_eq!(alpha.head.unwrap().to_string(), a_head);
107 assert_eq!(alpha.branches, 1);
108 assert_eq!(alpha.readme.as_ref().unwrap().path, "README.md");
109 assert!(alpha.last_activity.is_some());
110 assert_eq!(alpha.section, None);
111
112 let beta = &index.repos["forks/beta"].meta;
113 assert_eq!(beta.description.as_deref(), Some("From gitweb"));
114 assert_eq!(beta.owner.as_deref(), Some("bob"));
115 assert_eq!(beta.section.as_deref(), Some("forks")); // section_from_path
116 assert_eq!(beta.readme.as_ref().unwrap().path, "readme.txt");
117
118 let gamma = &index.repos["gamma"].meta;
119 assert_eq!(gamma.description.as_deref(), Some("From TOML"));
120 assert_eq!(gamma.section.as_deref(), Some("tools"));
121 assert_eq!(gamma.readme, None);
122
123 let empty = &index.repos["empty"].meta;
124 assert_eq!(empty.description, None); // placeholder ignored
125 assert_eq!(empty.head, None);
126 assert_eq!(empty.default_branch.as_deref(), Some("main"));
127 assert_eq!(empty.last_activity, None);
128
129 assert!(
130 index.warnings.iter().any(|w| w.contains("\"nonexistent\"")),
131 "{:?}",
132 index.warnings
133 );
134}
135
136#[test]
137fn reftable_repos_are_skipped_with_a_warning() {
138 let root = tempfile::tempdir().unwrap();
139 repo_with(root.path(), "normal.git", &[("a", "a")]);
140 bare(root.path(), "rt.git", &["--ref-format=reftable"]);
141 let state = state(config(root.path(), "", ""));
142 let index = state.index.load();
143 assert_eq!(index.repos.keys().collect::<Vec<_>>(), ["normal"]);
144 assert!(
145 index
146 .warnings
147 .iter()
148 .any(|w| w.starts_with("rt:") && w.contains("reftable")),
149 "{:?}",
150 index.warnings
151 );
152}
153
154#[test]
155fn unchanged_repos_keep_their_generation() {
156 let root = tempfile::tempdir().unwrap();
157 let (a, _) = repo_with(root.path(), "a.git", &[("f", "1")]);
158 repo_with(root.path(), "b.git", &[("f", "1")]);
159 let state = state(config(root.path(), "", ""));
160 let cfg = state.config.load_full();
161 let before = state.index.load();
162
163 let stats = state.index.full_scan(&cfg);
164 assert!(!stats.any_change(), "{stats:?}");
165 let after = state.index.load();
166 assert!(Arc::ptr_eq(&before.repos["a"], &after.repos["a"]));
167
168 let work = Work::clone_of(&a);
169 work.commit("f", "2", "change");
170 work.push("main");
171 assert_eq!(state.index.refresh(&cfg, &a), Refresh::Updated("a".into()));
172 let refreshed = state.index.load();
173 assert!(refreshed.repos["a"].generation > after.repos["a"].generation);
174 assert!(Arc::ptr_eq(&after.repos["b"], &refreshed.repos["b"]));
175 assert_eq!(state.index.refresh(&cfg, &a), Refresh::Unchanged);
176}
177
178#[tokio::test]
179async fn index_page_lists_sections_filter_and_sort() {
180 let root = tempfile::tempdir().unwrap();
181 let r = root.path();
182 let (a, _) = repo_with(r, "zeta.git", &[("f", "1")]);
183 git(
184 &a,
185 &["config", "ferrenium.description", "Last <b>letter</b>"],
186 );
187 git(&a, &["config", "ferrenium.section", "greek"]);
188 repo_with(r, "alpha.git", &[("f", "1")]);
189 repo_with(r, "with space.git", &[("f", "1")]);
190 bare(r, "empty.git", &[]);
191 let state = state(config(r, "", ""));
192
193 // Default order: by activity, one list.
194 let default = page(&state, "/").await;
195 assert!(!default.contains("[ greek ]"));
196 assert!(!default.contains(r#"name="sort""#));
197
198 let html = page(&state, "/?sort=name").await;
199 assert!(html.contains("4 Repositories"));
200 assert!(html.contains(r#"href="/alpha""#));
201 assert!(html.contains(r#"href="/with%20space""#));
202 assert!(html.contains("Last &#60;b&#62;letter&#60;/b&#62;") || html.contains("Last &lt;b&gt;"));
203 assert!(html.contains("[ greek ]"));
204 // Unsectioned repos come first, in name order.
205 let pos = |s: &str| html.find(s).unwrap_or_else(|| panic!("{s} missing"));
206 assert!(pos("/alpha\"") < pos("/empty\"") && pos("/empty\"") < pos("[ greek ]"));
207 // Just pushed: fresh dot. Empty repo: no age.
208 assert!(html.contains(r#"class="dot""#));
209 assert!(html.contains(">Empty<"));
210
211 let filtered = page(&state, "/?q=LETTER").await;
212 assert!(filtered.contains(r#"href="/zeta""#));
213 assert!(!filtered.contains(r#"href="/alpha""#));
214 assert!(filtered.contains(r#"value="LETTER""#));
215
216 let none = page(&state, "/?q=nothing-matches").await;
217 assert!(none.contains("[No matches]"));
218 assert!(!none.contains("data-filter-empty hidden"));
219
220 assert!(html.contains(r#"name="sort" value="name""#));
221 let by_activity = page(&state, "/?sort=activity").await;
222 assert!(!by_activity.contains("[ greek ]"));
223 assert!(!by_activity.contains(r#"name="sort""#));
224
225 // `ui.index_sort = "name"` makes name order the default.
226 let state = state_for_name_sort(r);
227 let html = page(&state, "/").await;
228 assert!(html.contains("[ greek ]"));
229 assert!(!html.contains(r#"name="sort""#));
230 assert!(html.contains(r#"href="/?sort=activity""#));
231}
232
233fn state_for_name_sort(root: &std::path::Path) -> Arc<AppState> {
234 state(config(root, "", "[ui]\nindex_sort = \"name\"\n"))
235}
236
237#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
238async fn push_updates_the_index_quickly() {
239 let root = tempfile::tempdir().unwrap();
240 let (repo, first) = repo_with(root.path(), "live.git", &[("f", "1")]);
241 let state = state(config(root.path(), "", ""));
242 tokio::spawn(ferrenium::index::watch::run(
243 state.clone(),
244 Default::default(),
245 ));
246 // Let the watcher install its watches.
247 tokio::time::sleep(Duration::from_millis(200)).await;
248
249 let wait_for = |pred: &dyn Fn(&ferrenium::index::RepoIndex) -> bool| {
250 let deadline = Instant::now() + Duration::from_secs(5);
251 loop {
252 if pred(&state.index.load()) {
253 return Instant::now();
254 }
255 assert!(Instant::now() < deadline, "index did not update in time");
256 std::thread::sleep(Duration::from_millis(20));
257 }
258 };
259
260 // A push to an existing repo.
261 let work = Work::clone_of(&repo);
262 let second = work.commit("f", "2", "second");
263 assert_ne!(first, second);
264 let pushed = Instant::now();
265 work.push("main");
266 let seen =
267 wait_for(&|idx| idx.repos["live"].meta.head.map(|h| h.to_string()) == Some(second.clone()));
268 let latency = seen - pushed;
269 assert!(latency < Duration::from_secs(1), "took {latency:?}");
270
271 // A new branch counts too.
272 git(
273 work.path(),
274 &["push", "-q", "origin", "main:refs/heads/feature/x"],
275 );
276 wait_for(&|idx| idx.repos["live"].meta.branches == 2);
277
278 // A new repository appears, a deleted one disappears.
279 repo_with(root.path(), "nested/new.git", &[("f", "1")]);
280 wait_for(&|idx| idx.repos.contains_key("nested/new"));
281 std::fs::remove_dir_all(&repo).unwrap();
282 wait_for(&|idx| !idx.repos.contains_key("live"));
283
284 // Hiding via git config takes effect without a restart.
285 let new_dir = root.path().join("nested/new.git");
286 git(&new_dir, &["config", "ferrenium.hidden", "true"]);
287 wait_for(&|idx| idx.hidden.contains("nested/new"));
288}