fc541094c929eca718ea2823ebe32cd9f3947ef3 diff --git a/src/c32_judge.test.ts b/src/c32_judge.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ce1933faeea354ee0f4b9d8a3ebef9ffb0019b22 --- /dev/null +++ b/src/c32_judge.test.ts @@ -0,0 +1,69 @@ +// Sibling test for c32_judge.ts. The orchestrator itself (judge()) does +// git clone + test execution and isn't unit-testable without a real +// agent repo; the pure helpers underneath it (applyMode, explainRefactor) +// are the structural surface that matters for scoring decisions. Cover +// the mode-aware penalty math + the operator-facing explanations here. + +import { describe, test, expect } from "bun:test"; +import { applyMode, explainRefactor, judge } from "./c32_judge.ts"; + +describe("c32_judge — applyMode (mode-aware penalty math)", () => { + test("positive deltas pass through unchanged in every mode", () => { + expect(applyMode(10, "strict")).toBe(10); + expect(applyMode(10, "pragmatic")).toBe(10); + expect(applyMode(10, "learning")).toBe(10); + }); + + test("strict mode keeps the full negative penalty", () => { + expect(applyMode(-20, "strict")).toBe(-20); + expect(applyMode(-5, "strict")).toBe(-5); + }); + + test("pragmatic mode halves negative deltas (Math.ceil — never below half)", () => { + expect(applyMode(-20, "pragmatic")).toBe(-10); + expect(applyMode(-10, "pragmatic")).toBe(-5); + // -5 / 2 = -2.5 → Math.ceil(-2.5) = -2: the harsher half rounds up + // toward zero, which is the documented "softer score" behaviour. + expect(applyMode(-5, "pragmatic")).toBe(-2); + }); + + test("learning mode zeroes out every negative delta", () => { + expect(applyMode(-20, "learning")).toBe(0); + expect(applyMode(-5, "learning")).toBe(0); + expect(applyMode(-1, "learning")).toBe(0); + }); + + test("zero delta is neutral in every mode", () => { + expect(applyMode(0, "strict")).toBe(0); + expect(applyMode(0, "pragmatic")).toBe(0); + expect(applyMode(0, "learning")).toBe(0); + }); +}); + +describe("c32_judge — explainRefactor", () => { + test("passed=true returns the canonical-refactor explanation", () => { + const s = explainRefactor(true); + expect(s).toContain("stayed green"); + expect(s).toMatch(/canonical/i); + }); + + test("passed=false returns guidance to revert or open a new red→green", () => { + const s = explainRefactor(false); + expect(s).toContain("broke"); + expect(s).toMatch(/revert|red→green/); + }); + + test("the two branches return different strings", () => { + expect(explainRefactor(true)).not.toBe(explainRefactor(false)); + }); +}); + +describe("c32_judge — orchestrator entry point", () => { + test("judge is exported as an async function (Promise-returning)", () => { + expect(typeof judge).toBe("function"); + // The orchestrator does git clone + test execution; covering it + // end-to-end needs a real agent repo. A type-level check that the + // shape didn't drift is the documented minimum for this layer. + expect(judge.length).toBe(2); + }); +}); diff --git a/src/c32_judge.ts b/src/c32_judge.ts index 68c307273e2951f1955552bf0ff114f9dba70532..052cfe842431ab72d0578e3f47bc83b8179e2a92 100644 --- a/src/c32_judge.ts +++ b/src/c32_judge.ts @@ -38,7 +38,7 @@ const readConfig = async (cwd: string): Promise => { // Penalty halving for pragmatic, zeroing for learning. Positive deltas // are unchanged across modes — earned credit is earned credit. -const applyMode = (delta: number, mode: Mode): number => { +export const applyMode = (delta: number, mode: Mode): number => { if (delta >= 0) return delta; if (mode === "learning") return 0; if (mode === "pragmatic") return Math.ceil(delta / 2); @@ -82,7 +82,7 @@ const explainStep = (params: { } }; -const explainRefactor = (passed: boolean): string => +export const explainRefactor = (passed: boolean): string => passed ? "Tests stayed green through the refactor — structural change without behavior change, the canonical refactor." : "Refactor commit broke at least one test. Either revert the refactor or write a new red→green to capture the changed behavior."; diff --git a/src/c32_real_reports.test.ts b/src/c32_real_reports.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b8f87ead710214b7dd18da37653025531fbd61dc --- /dev/null +++ b/src/c32_real_reports.test.ts @@ -0,0 +1,101 @@ +// Sibling test for c32_real_reports.ts. buildLiveReports itself fans out +// to fetchRepoCommits (network) so its end-to-end shape is covered by +// the live /reports/live route. The pure helpers underneath — agent +// attribution from commit messages, and the 30-day daily sparkline — +// are unit-testable here. + +import { describe, test, expect } from "bun:test"; +import { + detectAgent, + buildTrend, + buildLiveReports, +} from "./c32_real_reports.ts"; +import type { GithubCommit } from "./c14_github.ts"; + +const mkCommit = (date: string, message = ""): GithubCommit => ({ + sha: "0".repeat(40), + commit: { + message, + author: { name: "test", email: "t@example.com", date }, + committer: { name: "test", email: "t@example.com", date }, + }, + author: null, + committer: null, +} as unknown as GithubCommit); + +describe("c32_real_reports — detectAgent", () => { + test("recognises a Claude Code commit via Co-Authored-By: Claude", () => { + expect(detectAgent("Add feature\n\nCo-Authored-By: Claude ")).toBe("claude-code"); + }); + + test("recognises a Cursor commit", () => { + expect(detectAgent("Fix bug\n\nCo-Authored-By: Cursor ")).toBe("cursor"); + }); + + test("recognises an Aider commit", () => { + expect(detectAgent("Refactor x\n\nCo-Authored-By: aider")).toBe("aider"); + }); + + test("returns unknown when no recognised footer is present", () => { + expect(detectAgent("Just a commit")).toBe("unknown"); + expect(detectAgent("")).toBe("unknown"); + }); + + test("the regex is case-insensitive on the agent token", () => { + expect(detectAgent("Co-Authored-By: CLAUDE")).toBe("claude-code"); + expect(detectAgent("co-authored-by: CURSOR")).toBe("cursor"); + }); +}); + +describe("c32_real_reports — buildTrend (30-day daily sparkline)", () => { + // Use today (UTC) as the anchor — the function compares against UTC + // midnight, so we need ISO strings that fall on the right days. + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + const iso = (daysAgo: number): string => { + const d = new Date(today.getTime() - daysAgo * 24 * 60 * 60 * 1000); + return d.toISOString(); + }; + + test("returns an array of `days` length", () => { + expect(buildTrend([], 30)).toHaveLength(30); + expect(buildTrend([], 7)).toHaveLength(7); + }); + + test("empty input flat-lines at zero", () => { + const trend = buildTrend([], 7); + expect(trend.every((n) => n === 0)).toBe(true); + }); + + test("a single commit today increments the last bucket", () => { + const trend = buildTrend([mkCommit(iso(0))], 7); + expect(trend[trend.length - 1]).toBe(1); + expect(trend.slice(0, -1).every((n) => n === 0)).toBe(true); + }); + + test("multiple commits on the same day stack in the same bucket", () => { + const trend = buildTrend([mkCommit(iso(0)), mkCommit(iso(0)), mkCommit(iso(0))], 7); + expect(trend[trend.length - 1]).toBe(3); + }); + + test("commits older than the window are dropped", () => { + const trend = buildTrend([mkCommit(iso(99))], 7); + expect(trend.every((n) => n === 0)).toBe(true); + }); + + test("a commit `daysAgo` lands at index `days - 1 - daysAgo`", () => { + const trend = buildTrend([mkCommit(iso(2))], 7); + // index 6 = today, 5 = yesterday, 4 = 2 days ago + expect(trend[4]).toBe(1); + }); +}); + +describe("c32_real_reports — orchestrator entry point", () => { + test("buildLiveReports is exported as an async function", () => { + expect(typeof buildLiveReports).toBe("function"); + // End-to-end coverage lives on /reports/live; this is the structural + // smoke that the export shape didn't drift. `.length` counts only + // non-default params (owner, repo) — perPage carries a default. + expect(buildLiveReports.length).toBe(2); + }); +}); diff --git a/src/c32_real_reports.ts b/src/c32_real_reports.ts index c93c4c3f4262082d4390f594a039a4432c68038b..2cf694ae531ad0f56a2fa62b6b24be057bb6af67 100644 --- a/src/c32_real_reports.ts +++ b/src/c32_real_reports.ts @@ -18,7 +18,7 @@ import type { type LiveAgentSlug = AgentReport["slug"] | "unknown"; -const detectAgent = (msg: string): LiveAgentSlug => { +export const detectAgent = (msg: string): LiveAgentSlug => { if (/Co-Authored-By:.*Claude/i.test(msg)) return "claude-code"; if (/Co-Authored-By:.*Cursor/i.test(msg)) return "cursor"; if (/Co-Authored-By:.*Aider/i.test(msg)) return "aider"; @@ -34,7 +34,7 @@ const AGENT_NAMES: Record = { // 30-day daily commit-count series, oldest → newest. When there are no // commits in a day, that day's value is 0 — the sparkline still renders // but flat-lines, which honestly reflects the data. -const buildTrend = (commits: GithubCommit[], days = 30): number[] => { +export const buildTrend = (commits: GithubCommit[], days = 30): number[] => { const out = new Array(days).fill(0); const today = new Date(); today.setUTCHours(0, 0, 0, 0); diff --git a/src/c32_real_tests.test.ts b/src/c32_real_tests.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..cd11414f87910f5b3fa4ddc7c63ff20a67fb4da9 --- /dev/null +++ b/src/c32_real_tests.test.ts @@ -0,0 +1,66 @@ +// Sibling test for c32_real_tests.ts. buildLiveTestData fans out to +// loadTestBundle + fetchRepoCommits (both network/disk) so the +// end-to-end is covered by the live /reports/live/tests route. The +// pure helpers — agent attribution and the file/name label shortener — +// are unit-testable here. + +import { describe, test, expect } from "bun:test"; +import { + detectAgent, + shortenTestLabel, + buildLiveTestData, +} from "./c32_real_tests.ts"; + +describe("c32_real_tests — detectAgent", () => { + test("recognises Claude Code via Co-Authored-By: Claude", () => { + expect(detectAgent("Add feature\n\nCo-Authored-By: Claude ")).toBe("claude-code"); + }); + + test("recognises Cursor", () => { + expect(detectAgent("Fix bug\n\nCo-Authored-By: Cursor ")).toBe("cursor"); + }); + + test("recognises Aider", () => { + expect(detectAgent("Refactor x\n\nCo-Authored-By: aider")).toBe("aider"); + }); + + test("returns null when no recognised footer is present (distinct from c32_real_reports which returns 'unknown')", () => { + // The two real_* files made different choices here: real_reports + // buckets unknown into its own slug; real_tests returns null so + // the caller can filter or fall back. Document the difference. + expect(detectAgent("Just a commit")).toBeNull(); + expect(detectAgent("")).toBeNull(); + }); + + test("the regex is case-insensitive on the agent token", () => { + expect(detectAgent("Co-Authored-By: CLAUDE")).toBe("claude-code"); + expect(detectAgent("co-authored-by: aider")).toBe("aider"); + }); +}); + +describe("c32_real_tests — shortenTestLabel", () => { + test("keeps only the basename of the file path + the test name", () => { + expect(shortenTestLabel("src/foo/bar/baz.test.ts", "handles X")).toBe("baz.test.ts > handles X"); + }); + + test("handles a bare filename (no path) without splitting weirdly", () => { + expect(shortenTestLabel("baz.test.ts", "handles X")).toBe("baz.test.ts > handles X"); + }); + + test("handles an empty file string (falls back to the empty basename)", () => { + // .split('/').pop() on '' yields ''. Documented behaviour: the + // helper never throws; the caller decides whether to filter empties. + expect(shortenTestLabel("", "name")).toBe(" > name"); + }); + + test("preserves spaces and special chars in the test name", () => { + expect(shortenTestLabel("a.ts", "rejects `bad input`")).toBe("a.ts > rejects `bad input`"); + }); +}); + +describe("c32_real_tests — orchestrator entry point", () => { + test("buildLiveTestData is exported as an async function", () => { + expect(typeof buildLiveTestData).toBe("function"); + expect(buildLiveTestData.length).toBe(2); + }); +}); diff --git a/src/c32_real_tests.ts b/src/c32_real_tests.ts index 67b9fc6c8c749496867fc4aa3e2715b04dd3a787..d26637df79241040537bc2d4188bbf4679e5cafd 100644 --- a/src/c32_real_tests.ts +++ b/src/c32_real_tests.ts @@ -13,14 +13,14 @@ import type { TestStability, } from "./c31_reports_demo.ts"; -const detectAgent = (msg: string): AgentReport["slug"] | null => { +export const detectAgent = (msg: string): AgentReport["slug"] | null => { if (/Co-Authored-By:.*Claude/i.test(msg)) return "claude-code"; if (/Co-Authored-By:.*Cursor/i.test(msg)) return "cursor"; if (/Co-Authored-By:.*Aider/i.test(msg)) return "aider"; return null; }; -const shortenTestLabel = (file: string, name: string): string => { +export const shortenTestLabel = (file: string, name: string): string => { const base = file.split("/").pop() ?? file; return `${base} > ${name}`; }; diff --git a/src/c32_session.test.ts b/src/c32_session.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..418e48dd25d55b651cd3a8b79fcc1a2add286d1e --- /dev/null +++ b/src/c32_session.test.ts @@ -0,0 +1,173 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { + parseCookies, + timingSafeEqual, + hmacSha256Hex, + sessionCookieHeader, + randomHex, + signSession, + verifySession, + SESSION_TTL_SEC, +} from "./c32_session.ts"; + +describe("c32_session — parseCookies", () => { + test("empty / null header returns an empty object", () => { + expect(parseCookies(null)).toEqual({}); + expect(parseCookies("")).toEqual({}); + }); + + test("parses a single name=value pair", () => { + expect(parseCookies("tdd_session=abc")).toEqual({ tdd_session: "abc" }); + }); + + test("parses multiple pairs separated by `;`", () => { + const out = parseCookies("a=1; b=2; c=3"); + expect(out).toEqual({ a: "1", b: "2", c: "3" }); + }); + + test("strips surrounding whitespace from name and value", () => { + expect(parseCookies(" k = v ")).toEqual({ k: "v" }); + }); + + test("url-decodes values", () => { + expect(parseCookies("path=%2Ffoo%2Fbar")).toEqual({ path: "/foo/bar" }); + }); + + test("ignores entries that have no `=` separator", () => { + expect(parseCookies("malformed; ok=yes")).toEqual({ ok: "yes" }); + }); +}); + +describe("c32_session — timingSafeEqual", () => { + test("returns true for identical strings", () => { + expect(timingSafeEqual("hello", "hello")).toBe(true); + }); + + test("returns false for different strings of the same length", () => { + expect(timingSafeEqual("hello", "world")).toBe(false); + }); + + test("returns false when lengths differ — early exit", () => { + expect(timingSafeEqual("a", "ab")).toBe(false); + }); + + test("returns true for two empty strings", () => { + expect(timingSafeEqual("", "")).toBe(true); + }); +}); + +describe("c32_session — hmacSha256Hex", () => { + test("is deterministic for a fixed (secret, body) pair", async () => { + const a = await hmacSha256Hex("s3cret", "payload"); + const b = await hmacSha256Hex("s3cret", "payload"); + expect(a).toBe(b); + }); + + test("returns a 64-char lowercase hex string (SHA-256 hex length)", async () => { + const sig = await hmacSha256Hex("k", "v"); + expect(sig).toMatch(/^[0-9a-f]{64}$/); + }); + + test("a different secret produces a different signature for the same body", async () => { + const a = await hmacSha256Hex("secret-a", "payload"); + const b = await hmacSha256Hex("secret-b", "payload"); + expect(a).not.toBe(b); + }); + + test("a different body produces a different signature for the same secret", async () => { + const a = await hmacSha256Hex("k", "body-a"); + const b = await hmacSha256Hex("k", "body-b"); + expect(a).not.toBe(b); + }); +}); + +describe("c32_session — sessionCookieHeader", () => { + test("formats the canonical attributes", () => { + const h = sessionCookieHeader("token-x", 3600); + expect(h).toContain("tdd_session=token-x"); + expect(h).toContain("Path=/"); + expect(h).toContain("HttpOnly"); + expect(h).toContain("Secure"); + expect(h).toContain("SameSite=Lax"); + expect(h).toContain("Max-Age=3600"); + }); + + test("zero max-age (logout) still emits Max-Age=0", () => { + expect(sessionCookieHeader("", 0)).toContain("Max-Age=0"); + }); +}); + +describe("c32_session — randomHex", () => { + test("returns a hex string of 2 × bytes characters", () => { + expect(randomHex(8)).toMatch(/^[0-9a-f]{16}$/); + expect(randomHex(16)).toMatch(/^[0-9a-f]{32}$/); + }); + + test("successive calls produce distinct values", () => { + expect(randomHex(16)).not.toBe(randomHex(16)); + }); +}); + +describe("c32_session — signSession / verifySession round-trip", () => { + // The signer reads SESSION_SECRET (or WEBHOOK_SECRET) from the env. + // Set a fixed value before the tests run so both sides hash with the + // same key. beforeAll/afterAll, not bare describe-body, because the + // body runs at registration time while tests run async — restoration + // there would happen *before* any test executes. + let original: string | undefined; + beforeAll(() => { + original = process.env.SESSION_SECRET; + process.env.SESSION_SECRET = "test-secret-do-not-use-in-prod"; + }); + afterAll(() => { + if (original === undefined) { + delete process.env.SESSION_SECRET; + } else { + process.env.SESSION_SECRET = original; + } + }); + + test("signSession produces a 3-part cookie of `name.exp.sig`", async () => { + const cookie = await signSession("alice"); + const parts = cookie.split("."); + expect(parts.length).toBe(3); + expect(parts[0]).toBe("alice"); + expect(Number(parts[1])).toBeGreaterThan(Math.floor(Date.now() / 1000)); + }); + + test("verifySession round-trips a freshly signed cookie back to the username", async () => { + const cookie = await signSession("bob"); + const username = await verifySession(cookie); + expect(username).toBe("bob"); + }); + + test("verifySession rejects a cookie with a forged signature", async () => { + const cookie = await signSession("eve"); + const tampered = cookie.replace(/.$/, "0"); + const result = await verifySession(tampered); + expect(result).toBeNull(); + }); + + test("verifySession rejects a cookie that's not three parts", async () => { + expect(await verifySession("just-one-part")).toBeNull(); + expect(await verifySession("two.parts")).toBeNull(); + }); + + test("verifySession rejects a cookie whose expiry is in the past", async () => { + // Hand-roll a cookie with an `exp` that's already passed; sign with + // the same secret so the HMAC matches but the time-window check + // fails. + const username = "carol"; + const exp = Math.floor(Date.now() / 1000) - 60; + const sig = await hmacSha256Hex(process.env.SESSION_SECRET!, `${username}.${exp}`); + const cookie = `${username}.${exp}.${sig}`; + expect(await verifySession(cookie)).toBeNull(); + }); + +}); + +describe("c32_session — exports", () => { + test("SESSION_TTL_SEC is a positive integer (30 days)", () => { + expect(SESSION_TTL_SEC).toBe(30 * 24 * 60 * 60); + }); +});