| 1 | import { test } from "node:test"; |
| 2 | import assert from "node:assert/strict"; |
| 3 | import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; |
| 4 | import { dirname, join, relative, resolve, sep } from "node:path"; |
| 5 | import { fileURLToPath } from "node:url"; |
| 6 | import { DESKTOP, EVERYTHING, READS, affected, needs, partCrates, workspace } from "./ci-affected.mjs"; |
| 7 | |
| 8 | const ROOT = fileURLToPath(new URL("../..", import.meta.url)); |
| 9 | |
| 10 | // contracts <- kit <- api, and work, which also takes rules; sshd alone. |
| 11 | const CRATES = [ |
| 12 | { name: "g1t-contracts", dir: "crates/contracts", deps: [] }, |
| 13 | { name: "g1t-kit", dir: "crates/kit", deps: ["g1t-contracts"] }, |
| 14 | { name: "g1t-rules", dir: "crates/rules", deps: ["g1t-contracts"] }, |
| 15 | { name: "g1t-api", dir: "apps/api", deps: ["g1t-contracts", "g1t-kit"] }, |
| 16 | { name: "g1t-work", dir: "services/work", deps: ["g1t-kit", "g1t-rules"] }, |
| 17 | { name: "g1t-sshd", dir: "crates/sshd", deps: [] }, |
| 18 | { name: "g1t-actions", dir: "crates/actions", deps: [] }, |
| 19 | ]; |
| 20 | |
| 21 | test("a crate's change tests it and every crate that depends on it, however far", () => { |
| 22 | assert.deepEqual(affected(CRATES, ["crates/sshd/src/main.rs"]), ["g1t-sshd"]); |
| 23 | assert.deepEqual(affected(CRATES, ["crates/kit/src/lib.rs"]), ["g1t-api", "g1t-kit", "g1t-work"]); |
| 24 | assert.deepEqual(affected(CRATES, ["crates/contracts/src/lib.rs"]), ["g1t-api", "g1t-contracts", "g1t-kit", "g1t-rules", "g1t-work"]); |
| 25 | assert.deepEqual(affected(CRATES, ["crates/rules/Cargo.toml", "crates/sshd/README.md"]), ["g1t-rules", "g1t-sshd", "g1t-work"]); |
| 26 | // A folder that only starts like a crate's is not it. |
| 27 | assert.deepEqual(affected(CRATES, ["crates/kitchen/x.rs"]), []); |
| 28 | }); |
| 29 | |
| 30 | test("the workspace's own files, the toolchain and this workflow test every crate", () => { |
| 31 | for (const file of [ |
| 32 | "Cargo.lock", |
| 33 | "Cargo.toml", |
| 34 | "rust-toolchain.toml", |
| 35 | ".cargo/config.toml", |
| 36 | ".g1t/workflows/ci.yml", |
| 37 | "scripts/sccache.sh", |
| 38 | "scripts/deploy/ci-cache.mjs", |
| 39 | ]) { |
| 40 | assert.equal(affected(CRATES, ["crates/sshd/src/main.rs", file]), null, file); |
| 41 | } |
| 42 | // A crate's own manifest is only the crate's. |
| 43 | assert.deepEqual(affected(CRATES, ["crates/sshd/Cargo.toml"]), ["g1t-sshd"]); |
| 44 | }); |
| 45 | |
| 46 | test("a file outside the crates tests those that read it, and nothing when none does", () => { |
| 47 | assert.deepEqual(affected(CRATES, ["apps/web/src/app.tsx", "docs/notes.md", "services/chat/src/index.ts", "package-lock.json"]), []); |
| 48 | assert.deepEqual(affected(CRATES, [".g1t/workflows/deploy.yml"]), ["g1t-actions"]); |
| 49 | assert.deepEqual(affected(CRATES, ["packages/contracts/src/scopes.ts"]), ["g1t-api", "g1t-contracts", "g1t-kit", "g1t-rules", "g1t-work"]); |
| 50 | assert.deepEqual(affected(CRATES, []), []); |
| 51 | }); |
| 52 | |
| 53 | test("the desktop job runs for its own files and the build's, and for nothing else", () => { |
| 54 | for (const file of [ |
| 55 | "apps/desktop/src-tauri/src/main.rs", |
| 56 | "apps/desktop/package.json", |
| 57 | "scripts/cli-release.test.mjs", |
| 58 | "scripts/apt-install.sh", |
| 59 | ".g1t/workflows/ci.yml", |
| 60 | ]) { |
| 61 | assert.equal(needs("desktop", ["crates/kit/src/lib.rs", file]), true, file); |
| 62 | } |
| 63 | assert.equal(needs("desktop", ["crates/kit/src/lib.rs", "apps/web/app/root.tsx", "Cargo.lock", "apps/desktopper/x.ts"]), false); |
| 64 | }); |
| 65 | |
| 66 | test("the site, sudo and the docs build for any change but Rust source alone", () => { |
| 67 | assert.equal(needs("build", ["crates/kit/src/lib.rs", "services/work/src/items.rs"]), false); |
| 68 | assert.equal(needs("build", ["crates/kit/src/lib.rs", "crates/kit/Cargo.toml"]), true); |
| 69 | assert.equal(needs("build", ["apps/docs/src/content/docs/index.md"]), true); |
| 70 | assert.equal(needs("build", []), false); |
| 71 | }); |
| 72 | |
| 73 | test("every file the desktop job names is there", () => { |
| 74 | // The toolchain's files, like EVERYTHING's, are there only when pinned. |
| 75 | for (const entry of DESKTOP.filter((e) => !EVERYTHING.includes(e))) assert.ok(existsSync(join(ROOT, entry)), entry); |
| 76 | // Its tests run each release script's test file. |
| 77 | const ci = readFileSync(join(ROOT, ".g1t/workflows/ci.yml"), "utf8"); |
| 78 | const tests = ci.match(/node --test scripts\/desktop-release\.test\.mjs.*/)[0]; |
| 79 | for (const test of tests.match(/scripts\/[\w-]+\.test\.mjs/g)) assert.ok(DESKTOP.includes(test), test); |
| 80 | // Outside the workspace, with no path dependency on it. |
| 81 | assert.doesNotMatch(readFileSync(join(ROOT, "apps/desktop/src-tauri/Cargo.toml"), "utf8"), /\bpath\s*=/); |
| 82 | }); |
| 83 | |
| 84 | test("a part's crates are read from its -p arguments", () => { |
| 85 | assert.deepEqual(partCrates(["-p", "g1t-kit", "-p", "g1t"]), ["g1t-kit", "g1t"]); |
| 86 | assert.throws(() => partCrates(["-p", "g1t-kit", "g1t"])); |
| 87 | assert.throws(() => partCrates(["--workspace"])); |
| 88 | }); |
| 89 | |
| 90 | test("the workspace comes from cargo metadata: its members, their folders and their path dependencies", () => { |
| 91 | const root = join(ROOT, "w"); |
| 92 | const pkg = (name, dir, deps) => ({ |
| 93 | id: `path+file://${name}`, |
| 94 | name, |
| 95 | manifest_path: join(root, dir, "Cargo.toml"), |
| 96 | dependencies: deps.map(([dep, path]) => ({ name: dep, kind: path === "dev" ? "dev" : null, ...(path ? { path: join(root, "x") } : {}) })), |
| 97 | }); |
| 98 | const metadata = { |
| 99 | workspace_root: root, |
| 100 | workspace_members: ["path+file://g1t-kit", "path+file://g1t-api"], |
| 101 | packages: [ |
| 102 | pkg("g1t-kit", "crates/kit", [["serde"]]), |
| 103 | pkg("g1t-api", "apps/api", [ |
| 104 | ["g1t-kit", true], |
| 105 | ["g1t-kit", "dev"], |
| 106 | ["outside", true], |
| 107 | ]), |
| 108 | pkg("outside", "apps/desktop/src-tauri", []), |
| 109 | ], |
| 110 | }; |
| 111 | assert.deepEqual(workspace(metadata), [ |
| 112 | { name: "g1t-kit", dir: "crates/kit", deps: [] }, |
| 113 | { name: "g1t-api", dir: "apps/api", deps: ["g1t-kit"] }, |
| 114 | ]); |
| 115 | }); |
| 116 | |
| 117 | /** The real workspace's crates, from Cargo.toml's members: names and folders. */ |
| 118 | function members() { |
| 119 | const manifest = readFileSync(join(ROOT, "Cargo.toml"), "utf8"); |
| 120 | const line = manifest.split("\n").find((l) => l.trimStart().startsWith("members")); |
| 121 | const dirs = []; |
| 122 | for (const member of [...line.matchAll(/"([^"]+)"/g)].map((m) => m[1])) { |
| 123 | if (member.endsWith("/*")) { |
| 124 | const parent = member.slice(0, -2); |
| 125 | for (const name of readdirSync(join(ROOT, parent)).sort()) if (existsSync(join(ROOT, parent, name, "Cargo.toml"))) dirs.push(`${parent}/${name}`); |
| 126 | } else dirs.push(member); |
| 127 | } |
| 128 | return dirs.map((dir) => ({ name: readFileSync(join(ROOT, dir, "Cargo.toml"), "utf8").match(/^name = "([^"]+)"/m)[1], dir, deps: [] })); |
| 129 | } |
| 130 | |
| 131 | function rustFiles(dir) { |
| 132 | const out = []; |
| 133 | for (const name of readdirSync(dir)) { |
| 134 | if (name === "target" || name === "node_modules") continue; |
| 135 | const path = join(dir, name); |
| 136 | if (statSync(path).isDirectory()) out.push(...rustFiles(path)); |
| 137 | else if (name.endsWith(".rs")) out.push(path); |
| 138 | } |
| 139 | return out; |
| 140 | } |
| 141 | |
| 142 | test("every file a crate names outside its own folder tests that crate when it changes", () => { |
| 143 | const crates = members(); |
| 144 | // include_str!/include_bytes! from the file's folder; CARGO_MANIFEST_DIR |
| 145 | // joined or concatenated, and other joins, from the crate's. |
| 146 | const named = [ |
| 147 | [/include_(?:str|bytes)!\(\s*"([^"]+)"/g, "file"], |
| 148 | [/CARGO_MANIFEST_DIR"\),\s*"\/?([^"]+)"/g, "crate"], |
| 149 | [/\.join\(\s*"(\.\.[^"]*)"/g, "crate"], |
| 150 | ]; |
| 151 | let checked = 0; |
| 152 | for (const crate of crates) { |
| 153 | const at = join(ROOT, crate.dir); |
| 154 | for (const file of rustFiles(at)) { |
| 155 | const source = readFileSync(file, "utf8"); |
| 156 | for (const [pattern, from] of named) { |
| 157 | for (const [, path] of source.matchAll(pattern)) { |
| 158 | const target = resolve(from === "file" ? dirname(file) : at, path); |
| 159 | const rel = relative(ROOT, target).split(sep).join("/"); |
| 160 | // Inside the crate, missing, or the whole repository (docs.rs |
| 161 | // walks named folders under it, in READS by hand). |
| 162 | if (!rel || rel.startsWith("..") || !relative(at, target).startsWith("..") || !existsSync(target)) continue; |
| 163 | const changed = statSync(target).isDirectory() ? `${rel}/x` : rel; |
| 164 | const tested = affected(crates, [changed]); |
| 165 | assert.ok(tested === null || tested.includes(crate.name), `${relative(ROOT, file)} reads ${rel}: add it to READS for ${crate.name}`); |
| 166 | checked++; |
| 167 | } |
| 168 | } |
| 169 | } |
| 170 | } |
| 171 | assert.ok(checked >= 20, `${checked}`); |
| 172 | }); |
| 173 | |
| 174 | test("what READS and EVERYTHING name is there, and read by crates of the workspace", () => { |
| 175 | const names = new Set(members().map((c) => c.name)); |
| 176 | for (const [path, readers] of READS) { |
| 177 | assert.ok(existsSync(join(ROOT, path)), path); |
| 178 | for (const name of readers) assert.ok(names.has(name), `${path}: ${name}`); |
| 179 | } |
| 180 | for (const path of EVERYTHING) { |
| 181 | if (/^(rust-toolchain|\.cargo\/)/.test(path)) continue; |
| 182 | assert.ok(existsSync(join(ROOT, path)), path); |
| 183 | } |
| 184 | }); |