// Run with: bun test (from this directory) import { describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { importIssue, type JiraIssue, parseSrt, sanitize } from "./jira-import"; import { readData, type Ticket, writeData } from "./ticket-data"; const issue = (over: Partial = {}, rendered: Record = {}): JiraIssue => ({ key: "KACP-1", fields: { summary: "Add canonical IDs", issuetype: { name: "Story" }, status: { name: "To Do" }, priority: { name: "Medium" }, parent: { key: "KACP-9", fields: { summary: "Engagement epic" } }, attachment: [{ id: "42830", filename: "shot.png" }], ...over }, renderedFields: { description: "

Do the thing

", ...rendered }, names: { description: "Description", customfield_1: "Risk Mitigation", customfield_2: "Some Oddly Named Field", customfield_3: "Developer Review Instructions" }, }); describe("sanitize", () => { test("drops anything executable and points attachments at the downloaded copy", () => { const out = sanitize( `

hi

x` + `shot.png`, new Map([["42830", "resources/shot.png"]])); expect(out).not.toMatch(/onclick| { test("keeps every rich field, reader order first and unexpected names after", () => { const t = importIssue(issue({}, { customfield_2: "

odd

", customfield_1: "
r
", customfield_3: "

dev

" }), "https://j"); expect(t.source.sections.map(s => s.title)).toEqual(["Description", "Developer Review Instructions", "Risk Mitigation", "Some Oddly Named Field"]); expect(t.epic).toEqual({ key: "KACP-9", title: "Engagement epic" }); }); }); test("parseSrt skips cues with no words", () => { const segs = parseSrt("1\n00:00:01,500 --> 00:00:03,000\n Hello there\n\n2\n00:00:03,000 --> 00:00:04,000\n I\n\n3\n00:01:02,000 --> 00:01:05,000\nBye"); expect(segs).toEqual([{ t: 1.5, text: "Hello there" }, { t: 62, text: "Bye" }]); }); test("writeData refuses a plan that names a missing step and leaves the file as it was", () => { const dir = mkdtempSync(join(tmpdir(), "tp-")); const file = join(dir, "ticket.data.js"); const base = { schema: 1, key: "KACP-1", title: "t", type: "Story", status: "To Do", priority: "", epic: null, jira: "j", links: [], source: { importedAt: "", fields: [], sections: [] }, attachments: [], plan: null, proof: [], review: { findings: [], preMr: null }, jiraFields: [], mr: null, events: [], updatedAt: "" } satisfies Ticket; writeData(file, "ticket", base); const before = readFileSync(file, "utf8"); const broken: Ticket = { ...base, plan: { writtenAt: "", criteria: [{ id: "AC1", text: "x", steps: [2], status: "todo" }], questions: [], approach: "", decisions: [], steps: [{ n: 1, text: "s", kind: "code", status: "todo", commits: [] }], risks: [], tests: [], mocks: [] } }; expect(() => writeData(file, "ticket", broken)).toThrow(/AC1 names step 2/); expect(readFileSync(file, "utf8")).toBe(before); }); describe("the CLI, end to end", () => { const docs = mkdtempSync(join(tmpdir(), "tp-docs-")); const cli = (...args: string[]) => spawnSync("bun", [join(import.meta.dir, "ticket-page.ts"), ...args], { encoding: "utf8", env: { ...process.env, TICKET_DOCS: docs } }); const scratch = mkdtempSync(join(tmpdir(), "tp-in-")); const issuePath = join(scratch, "issue.json"); writeFileSync(issuePath, JSON.stringify(issue())); const dir = join(docs, "KACP-9", "KACP-1"); const ticket = () => readData(join(dir, "ticket.data.js"), "ticket"); const epic = () => readData(join(docs, "KACP-9", "epic.data.js"), "epic"); test("init files the ticket under its epic and writes both pages", () => { expect(cli("init", "KACP-1", "--issue", issuePath).status).toBe(0); for (const f of ["index.html", "ticket.data.js"]) expect(existsSync(join(dir, f))).toBe(true); expect(existsSync(join(docs, "KACP-9", "index.html"))).toBe(true); expect(readFileSync(join(dir, "index.html"), "utf8")).toMatch(/href="..\/..\/_site\/site.css\?v=[0-9a-f]{10}"/); expect(epic().stories.map(s => [s.key, s.status])).toEqual([["KACP-1", "todo"]]); }); test("a step is done only once its commit exists, and the epic row follows", () => { const plan = join(scratch, "plan.json"); writeFileSync(plan, JSON.stringify({ approach: "

a

", criteria: [{ id: "AC1", text: "c", steps: [1] }], steps: [{ n: 1, text: "s" }, { n: 2, text: "t" }] })); expect(cli("plan", "KACP-1", "--file", plan).status).toBe(0); expect(epic().stories[0].status).toBe("planned"); cli("approve", "KACP-1"); const refused = cli("step", "KACP-1", "1", "done"); expect(refused.status).toBe(2); expect(refused.stderr).toMatch(/--commit/); expect(cli("step", "KACP-1", "1", "done", "--commit", "abc1234").status).toBe(0); expect(ticket().plan!.steps[0]).toMatchObject({ status: "done", commits: ["abc1234"] }); expect(epic().stories[0]).toMatchObject({ status: "building", progress: { steps: [1, 2] } }); }); test("text proof is embedded, because a file:// page cannot fetch it", () => { mkdirSync(join(dir, "proof"), { recursive: true }); writeFileSync(join(dir, "proof", "counts.txt"), "70 of 70 codes stored"); expect(cli("proof", "KACP-1", "--file", "proof/counts.txt", "--proves", "AC1", "--caption", "Counts").status).toBe(0); expect(ticket().proof[0]).toMatchObject({ kind: "text", text: "70 of 70 codes stored" }); expect(cli("proof", "KACP-1", "--file", "proof/counts.txt", "--proves", "AC7", "--caption", "x").status).toBe(1); }); test("--amend keeps progress for the steps that remain, a rewrite starts over", () => { const plan = join(scratch, "plan2.json"); const exported = JSON.parse(cli("plan-json", "KACP-1").stdout); exported.steps.push({ n: 3, text: "a review fix" }); writeFileSync(plan, JSON.stringify(exported)); expect(cli("plan", "KACP-1", "--file", plan, "--amend").status).toBe(0); expect(ticket().plan!.steps.map(s => s.status)).toEqual(["done", "todo", "todo"]); expect(ticket().plan!.approvedAt).toBeDefined(); expect(ticket().proof).toHaveLength(1); expect(cli("plan", "KACP-1", "--file", plan).status).toBe(0); expect(ticket().plan!.steps.every(s => s.status === "todo")).toBe(true); expect(ticket().proof).toHaveLength(0); }); test("re-importing keeps the plan unless --reset", () => { cli("init", "KACP-1", "--issue", issuePath); expect(ticket().plan).not.toBeNull(); cli("init", "KACP-1", "--issue", issuePath, "--reset"); expect(ticket().plan).toBeNull(); }); });