recipe_repos_tests.rs 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. //! Tests for `recipe_repos` — hermetic `file://` git repos (no network).
  2. use super::{
  3. LAST_SYNC_FILE, ReposRegistry, archive_at, auth_url, load_registry, save_registry, sync_one,
  4. sync_repos, unarchive_at,
  5. };
  6. use crate::config::RecipeRepo;
  7. use std::path::PathBuf;
  8. fn git_ok(args: &[&str]) {
  9. let out = std::process::Command::new("git")
  10. .args(args)
  11. .output()
  12. .expect("run git");
  13. assert!(
  14. out.status.success(),
  15. "git {args:?} failed: {}",
  16. String::from_utf8_lossy(&out.stderr)
  17. );
  18. }
  19. /// Create a temp git repo with `files` (`rel path -> content`), committed;
  20. /// returns (dir, `file://` url).
  21. fn make_repo(tag: &str, files: &[(&str, &str)]) -> (PathBuf, String) {
  22. let dir = std::env::temp_dir().join(format!("sm121-rr-{tag}-{}", std::process::id()));
  23. let _ = std::fs::remove_dir_all(&dir);
  24. std::fs::create_dir_all(&dir).unwrap();
  25. for (rel, content) in files {
  26. let p = dir.join(rel);
  27. if let Some(parent) = p.parent() {
  28. std::fs::create_dir_all(parent).unwrap();
  29. }
  30. std::fs::write(&p, content).unwrap();
  31. }
  32. let d = dir.display().to_string();
  33. git_ok(&["-C", &d, "init", "-q"]);
  34. git_ok(&["-C", &d, "config", "user.email", "t@sm121.test"]);
  35. git_ok(&["-C", &d, "config", "user.name", "sm121-test"]);
  36. git_ok(&["-C", &d, "add", "-A"]);
  37. git_ok(&["-C", &d, "commit", "-qm", "init"]);
  38. (dir, format!("file://{}", d))
  39. }
  40. fn repo(url: String) -> RecipeRepo {
  41. RecipeRepo { url, token: None }
  42. }
  43. fn cache_base(tag: &str) -> PathBuf {
  44. let p = std::env::temp_dir().join(format!("sm121-rr-cache-{tag}-{}", std::process::id()));
  45. let _ = std::fs::remove_dir_all(&p);
  46. p
  47. }
  48. #[test]
  49. fn clone_then_pull_picks_up_new_commits() {
  50. let (_src_a, a_url) = make_repo(
  51. "a",
  52. &[
  53. (
  54. "recipes/alpha/recipe.toml",
  55. "engine = \"vllm\"\nversion = \"v1\"\n",
  56. ),
  57. ("recipes/beta/repo-note.txt", "static\n"),
  58. ],
  59. );
  60. let (src_b, b_url) = make_repo(
  61. "b",
  62. &[(
  63. "recipes/beta/recipe.toml",
  64. "engine = \"sglang\"\nversion = \"b1\"\n",
  65. )],
  66. );
  67. let base = cache_base("pull");
  68. let reg = ReposRegistry::default();
  69. // first sync: clones both, in list order
  70. let roots = sync_repos(
  71. &[repo(a_url.clone()), repo(b_url.clone())],
  72. &base,
  73. &reg,
  74. false,
  75. );
  76. assert_eq!(roots.len(), 2, "both repos synced: {roots:?}");
  77. assert!(roots[0].join("recipes/alpha/recipe.toml").is_file());
  78. assert!(roots[1].join("recipes/beta/recipe.toml").is_file());
  79. assert!(roots[0].join(LAST_SYNC_FILE).is_file(), "last-sync stamped");
  80. // new commit in B → the subsequent sync pulls it
  81. let b2 = src_b.join("recipes/gamma/recipe.toml");
  82. std::fs::create_dir_all(b2.parent().unwrap()).unwrap();
  83. std::fs::write(&b2, "engine = \"vllm\"\n").unwrap();
  84. let d = src_b.display().to_string();
  85. git_ok(&["-C", &d, "add", "-A"]);
  86. git_ok(&["-C", &d, "commit", "-qm", "gamma"]);
  87. let roots = sync_repos(&[repo(a_url), repo(b_url)], &base, &reg, false);
  88. assert!(
  89. roots[1].join("recipes/gamma/recipe.toml").is_file(),
  90. "pull landed gamma"
  91. );
  92. let _ = _src_a;
  93. }
  94. #[test]
  95. fn first_repo_shadows_later_on_name_collision() {
  96. let (_a, a_url) = make_repo(
  97. "shadow-a",
  98. &[(
  99. "recipes/dup/recipe.toml",
  100. "engine = \"vllm\"\norigin = \"a\"\n",
  101. )],
  102. );
  103. let (_b, b_url) = make_repo(
  104. "shadow-b",
  105. &[(
  106. "recipes/dup/recipe.toml",
  107. "engine = \"vllm\"\norigin = \"b\"\n",
  108. )],
  109. );
  110. let base = cache_base("shadow");
  111. let roots = sync_repos(
  112. &[repo(a_url.clone()), repo(b_url.clone())],
  113. &base,
  114. &ReposRegistry::default(),
  115. false,
  116. );
  117. assert_eq!(roots.len(), 2);
  118. // roots land in recipe_sources as their `recipes/` dirs, in list order →
  119. // the earlier (higher-priority) repo's copy is the one the search finds first
  120. let cfg: crate::config::Config = toml::from_str(&format!(
  121. "recipe_sources = [\"{}\", \"{}\"]\n",
  122. roots[0].join("recipes").display(),
  123. roots[1].join("recipes").display()
  124. ))
  125. .unwrap();
  126. let found = crate::config::recipe_file("dup", &cfg.recipe_sources)
  127. .unwrap_or_else(|| panic!("recipe 'dup' not found under the synced roots"));
  128. assert!(
  129. found.starts_with(roots[0].join("recipes").display().to_string().as_str()),
  130. "first repo shadows the later one: {found:?}"
  131. );
  132. }
  133. #[test]
  134. fn archived_repo_is_skipped() {
  135. let (_a, a_url) = make_repo(
  136. "arch-a",
  137. &[("recipes/alpha/recipe.toml", "engine = \"vllm\"\n")],
  138. );
  139. let (_b, b_url) = make_repo(
  140. "arch-b",
  141. &[("recipes/beta/recipe.toml", "engine = \"sglang\"\n")],
  142. );
  143. let base = cache_base("arch");
  144. let reg_path = base.join("repos.json");
  145. archive_at(&reg_path, &a_url).unwrap();
  146. let reg = load_registry(&reg_path);
  147. assert!(reg.is_archived(&a_url));
  148. let roots = sync_repos(
  149. &[repo(a_url.clone()), repo(b_url.clone())],
  150. &base,
  151. &reg,
  152. false,
  153. );
  154. assert_eq!(roots.len(), 1, "only the un-archived repo synced");
  155. assert!(roots[0].join("recipes/beta/recipe.toml").is_file());
  156. // unarchive → it syncs again
  157. unarchive_at(&reg_path, &a_url).unwrap();
  158. let reg = load_registry(&reg_path);
  159. assert!(!reg.is_archived(&a_url));
  160. let roots = sync_repos(&[repo(a_url), repo(b_url)], &base, &reg, false);
  161. assert_eq!(roots.len(), 2);
  162. }
  163. #[test]
  164. fn unreachable_repo_is_skipped_not_fatal() {
  165. let (_b, b_url) = make_repo(
  166. "reach-b",
  167. &[("recipes/beta/recipe.toml", "engine = \"sglang\"\n")],
  168. );
  169. let dead = format!(
  170. "file://{}/no-such-sm121-repo-{}",
  171. std::env::temp_dir().display(),
  172. std::process::id()
  173. );
  174. let base = cache_base("reach");
  175. let roots = sync_repos(
  176. &[repo(dead), repo(b_url)],
  177. &base,
  178. &ReposRegistry::default(),
  179. false,
  180. );
  181. assert_eq!(roots.len(), 1, "the live repo still syncs: {roots:?}");
  182. assert!(roots[0].join("recipes/beta/recipe.toml").is_file());
  183. }
  184. #[test]
  185. fn force_reclones_from_scratch() {
  186. let (_src, url) = make_repo(
  187. "force",
  188. &[("recipes/beta/recipe.toml", "engine = \"sglang\"\n")],
  189. );
  190. let base = cache_base("force");
  191. let reg = ReposRegistry::default();
  192. let roots = sync_repos(&[repo(url.clone())], &base, &reg, false);
  193. std::fs::write(roots[0].join("junk.txt"), "x").unwrap();
  194. let roots = sync_repos(&[repo(url)], &base, &reg, true);
  195. assert!(
  196. !roots[0].join("junk.txt").exists(),
  197. "force re-clone dropped the local junk"
  198. );
  199. assert!(roots[0].join("recipes/beta/recipe.toml").is_file());
  200. }
  201. #[test]
  202. fn registry_missing_is_fresh() {
  203. let p = cache_base("reg-missing").join("repos.json");
  204. let reg = load_registry(&p);
  205. assert!(reg.archived.is_empty());
  206. }
  207. #[test]
  208. fn registry_corrupt_is_backed_up_and_recreated() {
  209. let p = cache_base("reg-bak").join("repos.json");
  210. std::fs::create_dir_all(p.parent().unwrap()).unwrap();
  211. std::fs::write(&p, "{ not json").unwrap();
  212. let reg = load_registry(&p);
  213. assert!(
  214. reg.archived.is_empty(),
  215. "corrupt registry degrades to fresh"
  216. );
  217. assert!(
  218. p.with_extension("json.bak").exists(),
  219. "corrupt file preserved as .bak"
  220. );
  221. // and it is writable again
  222. save_registry(&p, &reg).unwrap();
  223. }
  224. #[test]
  225. fn archive_unarchive_round_trip() {
  226. let p = cache_base("reg-rt").join("repos.json");
  227. archive_at(&p, "https://git.example/x.git").unwrap();
  228. assert!(load_registry(&p).is_archived("https://git.example/x.git"));
  229. // idempotent
  230. archive_at(&p, "https://git.example/x.git").unwrap();
  231. assert_eq!(load_registry(&p).archived.len(), 1);
  232. unarchive_at(&p, "https://git.example/x.git").unwrap();
  233. assert!(!load_registry(&p).is_archived("https://git.example/x.git"));
  234. // unarchiving a never-archived url is a no-op
  235. unarchive_at(&p, "https://git.example/y.git").unwrap();
  236. }
  237. #[test]
  238. fn token_is_embedded_only_for_http_urls() {
  239. let https = RecipeRepo {
  240. url: "https://git.kilork.org/sm121/recipes-production.git".into(),
  241. token: Some("tok123".into()),
  242. };
  243. assert_eq!(
  244. auth_url(&https),
  245. "https://tok123@git.kilork.org/sm121/recipes-production.git"
  246. );
  247. let ssh = RecipeRepo {
  248. url: "git@git.kilork.org:sm121/recipes.git".into(),
  249. token: Some("ignored".into()),
  250. };
  251. assert_eq!(
  252. auth_url(&ssh),
  253. ssh.url,
  254. "ssh urls pass through (the token is a no-op)"
  255. );
  256. let no_token = RecipeRepo {
  257. url: "https://git.kilork.org/x.git".into(),
  258. token: None,
  259. };
  260. assert_eq!(auth_url(&no_token), no_token.url);
  261. }
  262. #[test]
  263. fn sync_one_stamps_last_sync() {
  264. let (_src, url) = make_repo("stamp", &[("recipes/beta/recipe.toml", "x = 1\n")]);
  265. let base = cache_base("stamp");
  266. let dir = super::repo_cache_dir(&base, &url);
  267. sync_one(&url, &dir, false).unwrap();
  268. let ts: i64 = std::fs::read_to_string(dir.join(LAST_SYNC_FILE))
  269. .unwrap()
  270. .trim()
  271. .parse()
  272. .unwrap();
  273. let now = crate::stamp::now_secs();
  274. assert!(ts > now - 60 && ts <= now, "stamp is a recent epoch: {ts}");
  275. }