/** * Unit tests for diff parsing, branch matching, and job claiming. * Run with: node --test tests/ */ import { strict as assert } from "node:assert"; import { test } from "node:test"; import { rmSync } from "node:fs"; import { parseUnifiedDiff, addedLineNumbers, diffLineNumbers, pickAnchorLine } from "../app/lib/diff.js"; import { branchMatches, parseRepoUrl, pullRequestMatches, promotesToManaged, isProtectedTarget, reviewsAnyPush, selectedBranches, } from "../app/lib/review.js"; import { severityAtLeast, parseList } from "../app/lib/ocr.js"; import { normalizeBaseUrl } from "../app/lib/gitea.js"; import { openDatabase, upsertRepository, enqueueJob, claimNextJob, updateJob, getBranchState, setBranchState, findJobBySha, recordDelivery, jobStats, } from "../app/lib/db.js"; // Hunk headers must agree with the body line counts: the parser advances the // new-file cursor from the header, so an inconsistent fixture would silently // shift every later line number. const SAMPLE = `diff --git a/src/app.js b/src/app.js index 1111111..2222222 100644 --- a/src/app.js +++ b/src/app.js @@ -1,4 +1,5 @@ const a = 1; -const b = 2; +const b = 3; +const c = 4; module.exports = { a, b }; @@ -20,3 +21,4 @@ function later() { const x = 1; return 1; } +const d = 5; diff --git a/new.txt b/new.txt new file mode 100644 --- /dev/null +++ b/new.txt @@ -0,0 +1,2 @@ +hello +world diff --git a/gone.txt b/gone.txt deleted file mode 100644 --- a/gone.txt +++ /dev/null @@ -1,1 +0,0 @@ -bye `; test("parseUnifiedDiff tracks paths, status and hunk line maps", () => { const files = parseUnifiedDiff(SAMPLE); assert.deepEqual([...files.keys()], ["src/app.js", "new.txt", "gone.txt"]); assert.equal(files.get("src/app.js").status, "modified"); assert.equal(files.get("new.txt").status, "added"); assert.equal(files.get("gone.txt").status, "deleted"); assert.equal(files.get("src/app.js").hunks.length, 2); }); test("addedLineNumbers only reports added new-file lines", () => { const files = parseUnifiedDiff(SAMPLE); assert.deepEqual([...addedLineNumbers(files.get("src/app.js"))].sort((a, b) => a - b), [2, 3, 24]); assert.deepEqual([...addedLineNumbers(files.get("new.txt"))].sort((a, b) => a - b), [1, 2]); assert.deepEqual([...addedLineNumbers(files.get("gone.txt"))], []); }); test("diffLineNumbers includes context lines", () => { const files = parseUnifiedDiff(SAMPLE); const lines = diffLineNumbers(files.get("src/app.js")); // Context (1, 4, 5, 21, 22, 23) and added (2, 3, 24) lines are all renderable. assert.deepEqual([...lines].sort((a, b) => a - b), [1, 2, 3, 4, 5, 21, 22, 23, 24]); // The deleted old-file line 2 has no new-file counterpart. assert.ok(!lines.has(6)); }); test("pickAnchorLine prefers added lines inside the hunk", () => { const files = parseUnifiedDiff(SAMPLE); const entry = files.get("src/app.js"); assert.deepEqual(pickAnchorLine(entry, 2, 3), { line: 2, inDiff: true }); assert.deepEqual(pickAnchorLine(entry, 3, 3), { line: 3, inDiff: true }); assert.deepEqual(pickAnchorLine(entry, 24, 24), { line: 24, inDiff: true }); }); test("pickAnchorLine falls back to context then to the start line", () => { const files = parseUnifiedDiff(SAMPLE); const entry = files.get("src/app.js"); // Line 1 is context in the hunk, so it is still rendered inline. assert.deepEqual(pickAnchorLine(entry, 1, 1), { line: 1, inDiff: true }); // Line 999 is outside every hunk: keep it, but flag it as not inline. assert.deepEqual(pickAnchorLine(entry, 999, 999), { line: 999, inDiff: false }); }); test("branchMatches reviews pushes only on the listed branches", () => { assert.ok(branchMatches("test", "test,dev"), "listed push"); assert.ok(branchMatches("refs/heads/test", "test,dev"), "refs/heads prefix is stripped"); assert.ok(!branchMatches("feature/x", "test,dev"), "unlisted push"); assert.ok(!branchMatches("prd", "test,dev"), "the managed branch is not implicitly checked"); // An empty list means "pull requests only", not "every push". assert.ok(!branchMatches("anything", "")); // Globs were never supported and a literal `*` is now the wildcard token. assert.ok(!branchMatches("release/2.0", "release/*"), "globs are not patterns"); }); test("branchMatches treats the wildcard token as every branch", () => { assert.ok(reviewsAnyPush("*")); assert.ok(reviewsAnyPush("*,test")); assert.ok(!reviewsAnyPush("test,dev")); assert.ok(!reviewsAnyPush(""), "empty list means pull requests only"); assert.ok(branchMatches("feature/whatever", "*"), "wildcard reviews any push"); assert.ok(branchMatches("feature/whatever", "*,test"), "wildcard plus explicit branches"); assert.deepEqual(selectedBranches("*,test"), ["test"], "wildcard is not a branch name"); }); test("pullRequestMatches covers checked branches and the managed branch", () => { const checks = "test,dev"; const managed = "prd"; assert.ok(pullRequestMatches("fix/x", "prd", checks, managed), "PR into the managed branch"); assert.ok(pullRequestMatches("fix/x", "test", checks, managed), "PR into a checked branch"); assert.ok(pullRequestMatches("test", "prd", checks, managed), "promotion PR test -> prd"); assert.ok(!pullRequestMatches("fix/x", "other", checks, managed), "PR into an unprotected branch"); assert.ok(pullRequestMatches("test", "other", checks, managed), "head branch explicitly listed"); // The managed branch is protected even though it is absent from the list. assert.ok(pullRequestMatches("anything", "prd", "", "prd"), "empty list still protects the managed branch"); assert.ok(pullRequestMatches("anything", "other", "", ""), "no managed branch and empty list reviews all"); }); test("promotesToManaged only promotes explicitly checked branches", () => { const checks = "test,dev"; const managed = "prd"; assert.ok(promotesToManaged("test", checks, managed), "checked branch promotes"); assert.ok(promotesToManaged("dev", checks, managed), "checked branch promotes"); assert.ok(!promotesToManaged("feature/x", checks, managed), "unlisted branch never promotes"); assert.ok(!promotesToManaged("prd", checks, managed), "the managed branch has nowhere to go"); // "任意分支" widens review but must not auto-merge arbitrary branches. assert.ok(!promotesToManaged("feature/x", "*,test", managed), "wildcard alone does not promote"); assert.ok(promotesToManaged("test", "*,test", managed), "wildcard keeps explicit promotions"); }); test("isProtectedTarget accepts the managed branch and checked branches", () => { const checks = "test,dev"; const managed = "prd"; assert.ok(isProtectedTarget("prd", checks, managed), "managed branch"); assert.ok(isProtectedTarget("test", checks, managed), "checked branch"); assert.ok(!isProtectedTarget("other", checks, managed), "unprotected branch"); assert.ok(!isProtectedTarget("", checks, managed), "empty ref"); assert.ok(isProtectedTarget("refs/heads/prd", checks, managed), "prefix is stripped"); }); test("parseRepoUrl derives owner and name from every supported URL form", () => { const cases = [ ["http://192.168.31.51/kgod/myrepo.git", "kgod", "myrepo"], ["http://192.168.31.51/kgod/myrepo", "kgod", "myrepo"], ["https://git.example.com/group/sub/repo.git", "group/sub", "repo"], ["ssh://git@git.example.com:2222/kgod/myrepo.git", "kgod", "myrepo"], ["git@git.example.com:kgod/myrepo.git", "kgod", "myrepo"], ]; for (const [url, owner, name] of cases) { const parsed = parseRepoUrl(url); assert.equal(parsed.owner, owner, url); assert.equal(parsed.name, name, url); } }); test("parseRepoUrl rejects unusable input", () => { assert.throws(() => parseRepoUrl(""), /Git 地址/); assert.throws(() => parseRepoUrl("not a url"), /无法识别|缺少/); assert.throws(() => parseRepoUrl("http://host/onlyowner"), /缺少/); }); test("severityAtLeast compares known severities and rejects unknown ones", () => { assert.ok(severityAtLeast("critical", "high")); assert.ok(severityAtLeast("high", "high")); assert.ok(!severityAtLeast("medium", "high")); assert.ok(!severityAtLeast("", "low")); assert.ok(!severityAtLeast("bogus", "low")); }); test("parseList trims and drops empties", () => { assert.deepEqual(parseList("critical, high ,,low"), ["critical", "high", "low"]); assert.deepEqual(parseList(""), []); assert.deepEqual(parseList(null), []); }); test("normalizeBaseUrl accepts root and api/v1 forms", () => { for (const input of ["http://h", "http://h/", "http://h/api/v1", "http://h/api/v1/"]) { assert.equal(normalizeBaseUrl(input), "http://h"); } }); function tempDb(name) { const path = `F:\\tmp\\cr-test-${name}-${process.pid}.db`; for (const suffix of ["", "-wal", "-shm"]) rmSync(path + suffix, { force: true }); const db = openDatabase(path); return { db, cleanup() { db.close(); for (const suffix of ["", "-wal", "-shm"]) rmSync(path + suffix, { force: true }); }, }; } test("job queue claims once and records terminal state", () => { const { db, cleanup } = tempDb("queue"); try { const repo = upsertRepository(db, { owner: "o", name: "r" }); const jobId = enqueueJob(db, { repoId: repo.id, trigger: "push", refName: "main", toSha: "a".repeat(40) }); const first = claimNextJob(db); assert.equal(first.id, jobId); assert.equal(first.status, "running"); assert.equal(first.attempts, 1); assert.equal(claimNextJob(db), null, "a claimed job is not handed out twice"); updateJob(db, jobId, { status: "succeeded", findings: 2, blocking: 1 }); assert.deepEqual(jobStats(db), { queued: 0, running: 0, succeeded: 1, failed: 0, skipped: 0 }); assert.equal(findJobBySha(db, repo.id, "a".repeat(40)), undefined, "finished jobs are not treated as in-flight"); } finally { cleanup(); } }); test("findJobBySha blocks duplicate in-flight reviews", () => { const { db, cleanup } = tempDb("dedupe"); try { const repo = upsertRepository(db, { owner: "o", name: "r" }); const sha = "b".repeat(40); enqueueJob(db, { repoId: repo.id, trigger: "push", refName: "main", toSha: sha }); assert.ok(findJobBySha(db, repo.id, sha)); const claimed = claimNextJob(db); assert.ok(findJobBySha(db, repo.id, sha), "running jobs still count"); updateJob(db, claimed.id, { status: "skipped" }); assert.equal(findJobBySha(db, repo.id, sha), undefined); } finally { cleanup(); } }); test("webhook deliveries are deduplicated by delivery id", () => { const { db, cleanup } = tempDb("delivery"); try { assert.equal(recordDelivery(db, "d-1"), true); assert.equal(recordDelivery(db, "d-1"), false); assert.equal(recordDelivery(db, "d-2"), true); } finally { cleanup(); } }); test("repository defaults and branch state round-trip", () => { const { db, cleanup } = tempDb("repo"); try { const repo = upsertRepository(db, { owner: "kgod", name: "demo" }); assert.equal(repo.managed_branch, "main"); assert.equal(repo.check_branches, "main"); assert.equal(repo.block_severity, "critical,high"); assert.equal(repo.review_scope, "both"); assert.equal(repo.create_issue, 1); const updated = upsertRepository(db, { id: repo.id, auto_merge: 1, merge_method: "rebase" }); assert.equal(updated.auto_merge, 1); assert.equal(updated.merge_method, "rebase"); assert.equal(getBranchState(db, repo.id, "main"), undefined); setBranchState(db, repo.id, "main", "c".repeat(40)); assert.equal(getBranchState(db, repo.id, "main").last_sha, "c".repeat(40)); setBranchState(db, repo.id, "main", "d".repeat(40)); assert.equal(getBranchState(db, repo.id, "main").last_sha, "d".repeat(40)); } finally { cleanup(); } }); test("upsertRepository does not clobber unset fields", () => { const { db, cleanup } = tempDb("patch"); try { const repo = upsertRepository(db, { owner: "o", name: "r", issue_labels: "review" }); upsertRepository(db, { id: repo.id, auto_merge: 1 }); const after = db.prepare("SELECT * FROM repositories WHERE id = ?").get(repo.id); assert.equal(after.issue_labels, "review"); assert.equal(after.auto_merge, 1); } finally { cleanup(); } });