diff --git a/packages/shared/src/pipeline-case-type.test.ts b/packages/shared/src/pipeline-case-type.test.ts new file mode 100644 index 00000000000..ce795bba32a --- /dev/null +++ b/packages/shared/src/pipeline-case-type.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { caseTypeMatchesPipeline, deriveCaseType } from "./pipeline-case-type.js"; + +describe("deriveCaseType", () => { + it("uses the pipeline key as the type", () => { + expect(deriveCaseType({ id: "p1", key: "content_production" })).toBe("content_production"); + }); + + it("trims whitespace", () => { + expect(deriveCaseType({ id: "p1", key: " release_coverage " })).toBe("release_coverage"); + }); + + it("falls back to the pipeline id when there is no key", () => { + expect(deriveCaseType({ id: "p1", key: null })).toBe("p1"); + expect(deriveCaseType({ id: "p1" })).toBe("p1"); + }); +}); + +describe("caseTypeMatchesPipeline", () => { + const pipeline = { id: "p1", key: "content" }; + + it("passes when nothing is declared", () => { + expect(caseTypeMatchesPipeline(undefined, pipeline)).toBe(true); + expect(caseTypeMatchesPipeline(null, pipeline)).toBe(true); + expect(caseTypeMatchesPipeline("", pipeline)).toBe(true); + }); + + it("passes when the declared type agrees with the pipeline", () => { + expect(caseTypeMatchesPipeline("content", pipeline)).toBe(true); + }); + + it("fails when the declared type disagrees", () => { + expect(caseTypeMatchesPipeline("release", pipeline)).toBe(false); + }); +}); diff --git a/packages/shared/src/validators/pipeline.test.ts b/packages/shared/src/validators/pipeline.test.ts new file mode 100644 index 00000000000..9da44de6885 --- /dev/null +++ b/packages/shared/src/validators/pipeline.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { pipelineStageConfigSchema } from "./pipeline.js"; + +describe("pipeline stage variable schema", () => { + it("validates select variables require options", () => { + expect( + pipelineStageConfigSchema.safeParse({ + variables: [{ key: "status", label: "Status", type: "select", options: ["open", "done"] }], + }).success, + ).toBe(true); + + expect( + pipelineStageConfigSchema.safeParse({ + variables: [{ key: "status", label: "Status", type: "select", options: [] }], + }).success, + ).toBe(false); + }); + + it("enforces unique variable keys in stage config", () => { + expect( + pipelineStageConfigSchema.safeParse({ + variables: [ + { key: "repo", label: "Repo", type: "text" }, + { key: "repo", label: "Repo", type: "text" }, + ], + }).success, + ).toBe(false); + }); + + it("accepts disabled and approval settings stored in stage config", () => { + expect( + pipelineStageConfigSchema.safeParse({ + variables: [ + { + key: "customer", + label: "Customer", + type: "text", + options: [], + required: true, + showInAddForm: true, + }, + ], + disabled: true, + disabledReason: "Pause intake while the team clears the queue.", + requireApproval: true, + approver: { kind: "agent", id: "agent-1" }, + whatHappensHere: "Triage every incoming item before work starts.", + }).success, + ).toBe(true); + }); + + it("requires an id when the approver is a specific user or agent", () => { + expect( + pipelineStageConfigSchema.safeParse({ + variables: [], + requireApproval: true, + approver: { kind: "user" }, + }).success, + ).toBe(false); + }); + + it("accepts a run_routine onEnter action", () => { + expect( + pipelineStageConfigSchema.safeParse({ + variables: [], + onEnter: { + type: "run_routine", + routineId: "11111111-1111-4111-8111-111111111111", + }, + }).success, + ).toBe(true); + }); + + it("accepts versioned breakdown carry-over policy", () => { + expect( + pipelineStageConfigSchema.safeParse({ + variables: [], + breakdown: { + targetPipelineId: "11111111-1111-4111-8111-111111111111", + targetStageKey: "intake", + carryOverPolicy: { + version: 1, + mode: "all_except", + excludeFields: ["title", "internalNote"], + }, + }, + }).success, + ).toBe(true); + + expect( + pipelineStageConfigSchema.safeParse({ + variables: [], + breakdown: { + targetPipelineId: "11111111-1111-4111-8111-111111111111", + targetStageKey: "intake", + carryOverPolicy: { + version: 2, + mode: "all_except", + }, + }, + }).success, + ).toBe(false); + }); + + it("rejects an onEnter run_routine action without a valid routine id", () => { + expect( + pipelineStageConfigSchema.safeParse({ + variables: [], + onEnter: { + type: "run_routine", + routineId: "not-a-uuid", + }, + }).success, + ).toBe(false); + }); + + it("still accepts the legacy reviewerKind input", () => { + expect( + pipelineStageConfigSchema.safeParse({ + variables: [], + reviewerKind: "human", + }).success, + ).toBe(true); + expect( + pipelineStageConfigSchema.safeParse({ + variables: [], + reviewerKind: "robot", + }).success, + ).toBe(false); + }); +}); diff --git a/server/src/__tests__/pipelines-aggregation.test.ts b/server/src/__tests__/pipelines-aggregation.test.ts new file mode 100644 index 00000000000..2ef67201ab0 --- /dev/null +++ b/server/src/__tests__/pipelines-aggregation.test.ts @@ -0,0 +1,502 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + activityLog, + agents, + companies, + createDb, + issueComments, + issues, + pipelineAutomationExecutions, + pipelineCaseBlockers, + pipelineCaseEvents, + pipelineCaseIssueLinks, + pipelineCases, + pipelineStages, + pipelineTransitions, + pipelines, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/error-handler.js"; +import { pipelineRoutes } from "../routes/pipelines.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres pipeline aggregation tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("pipeline aggregation routes", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + const noopHeartbeat = { wakeup: async () => null }; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-pipelines-aggregation-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(pipelineAutomationExecutions); + await db.delete(pipelineCaseBlockers); + await db.delete(pipelineCaseIssueLinks); + await db.delete(pipelineCaseEvents); + await db.delete(pipelineCases); + await db.delete(pipelineTransitions); + await db.delete(pipelineStages); + await db.delete(issueComments); + await db.delete(issues); + await db.delete(pipelines); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + function app(actor: Express.Request["actor"]) { + const instance = express(); + instance.use(express.json()); + instance.use((req, _res, next) => { + req.actor = actor; + next(); + }); + instance.use("/api", pipelineRoutes(db, { heartbeat: noopHeartbeat })); + instance.use(errorHandler); + return instance; + } + + async function seedCompany(name = "Aggregation Co") { + const [company] = await db.insert(companies).values({ + name, + issuePrefix: `P${randomUUID().replace(/-/g, "").slice(0, 6).toUpperCase()}`, + }).returning(); + return company!; + } + + async function seedAgent(companyId: string, name = "Pipeline Agent") { + const [agent] = await db.insert(agents).values({ + companyId, + name, + role: "engineer", + adapterType: "codex_local", + }).returning(); + return agent!; + } + + const boardActor: Express.Request["actor"] = { + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + }; + + function agentActor(companyId: string, agentId: string): Express.Request["actor"] { + return { + type: "agent", + agentId, + companyId, + runId: randomUUID(), + source: "agent_key", + }; + } + + const reviewStages = [ + { key: "intake", name: "Intake", kind: "open", position: 100 }, + { key: "drafting", name: "Drafting", kind: "working", position: 200 }, + { + key: "review_human", + name: "Final review", + kind: "review", + position: 300, + config: { approveToStageKey: "done", rejectToStageKey: "cancelled", requestChangesToStageKey: "drafting", requireRejectReason: true, reviewerKind: "human" }, + }, + { + key: "review_any", + name: "Open review", + kind: "review", + position: 400, + config: { approveToStageKey: "done", rejectToStageKey: "cancelled", requireRejectReason: false, reviewerKind: "any" }, + }, + { key: "done", name: "Done", kind: "done", position: 900 }, + { key: "cancelled", name: "Cancelled", kind: "cancelled", position: 1000 }, + ]; + + async function seedPipeline(http: ReturnType, companyId: string, key: string, name: string) { + const created = await http + .post(`/api/companies/${companyId}/pipelines`) + .send({ key, name, stages: reviewStages }) + .expect(201); + return created.body as { id: string; stages: Array<{ id: string; key: string }> }; + } + + it("groups attention into suggestions, reviews, and heads-up with approver filtering", async () => { + const company = await seedCompany(); + const agent = await seedAgent(company.id, "Drafting Agent"); + const board = request(app(boardActor)); + const asAgent = request(app(agentActor(company.id, agent.id))); + + const pipeline = await seedPipeline(board, company.id, "content", "Content production"); + + const suggested = await board + .post(`/api/pipelines/${pipeline.id}/cases`) + .send({ caseKey: "suggested", title: "Launch blog post" }) + .expect(201); + await asAgent + .post(`/api/cases/${suggested.body.case.id}/suggest-transition`) + .send({ toStageKey: "review_human", rationale: "Draft is ready for review", confidence: 0.9 }) + .expect(200); + + const humanReview = await board + .post(`/api/pipelines/${pipeline.id}/cases`) + .send({ caseKey: "human-review", title: "Needs a human decision" }) + .expect(201); + await board + .post(`/api/cases/${humanReview.body.case.id}/transition`) + .send({ toStageKey: "review_human", expectedVersion: 1 }) + .expect(200); + + const anyReview = await board + .post(`/api/pipelines/${pipeline.id}/cases`) + .send({ caseKey: "any-review", title: "Anyone can decide" }) + .expect(201); + await board + .post(`/api/cases/${anyReview.body.case.id}/transition`) + .send({ toStageKey: "review_any", expectedVersion: 1 }) + .expect(200); + + const upstream = await board + .post(`/api/pipelines/${pipeline.id}/cases`) + .send({ caseKey: "upstream", title: "Release plan" }) + .expect(201); + const dependent = await board + .post(`/api/pipelines/${pipeline.id}/cases`) + .send({ caseKey: "dependent", title: "Dependent draft", blockedByCaseIds: [upstream.body.case.id] }) + .expect(201); + const [workIssue] = await db.insert(issues).values({ + companyId: company.id, + title: "Write dependent draft", + status: "in_progress", + priority: "medium", + assigneeAgentId: agent.id, + }).returning(); + await board + .post(`/api/cases/${dependent.body.case.id}/issue-links`) + .send({ issueId: workIssue!.id, role: "work" }) + .expect(201); + await board + .patch(`/api/cases/${upstream.body.case.id}`) + .send({ fields: { releaseNotes: "Scope changed" }, expectedVersion: 1 }) + .expect(200); + + const boardFeed = await board.get(`/api/companies/${company.id}/pipelines-attention`).expect(200); + + expect(boardFeed.body.counts).toEqual({ suggestions: 1, reviews: 2, headsUp: 1 }); + const suggestion = boardFeed.body.suggestions[0]; + expect(suggestion.case.id).toBe(suggested.body.case.id); + expect(suggestion.case.pipeline.name).toBe("Content production"); + expect(suggestion.suggestion.toStageKey).toBe("review_human"); + expect(suggestion.suggestion.toStageName).toBe("Final review"); + expect(suggestion.suggestion.rationale).toBe("Draft is ready for review"); + expect(suggestion.suggestion.suggestedBy).toEqual({ agentId: agent.id, agentName: "Drafting Agent" }); + + const reviewCaseIds = boardFeed.body.reviews.map((entry: { case: { id: string } }) => entry.case.id); + expect(reviewCaseIds).toEqual(expect.arrayContaining([humanReview.body.case.id, anyReview.body.case.id])); + const humanEntry = boardFeed.body.reviews.find((entry: { case: { id: string } }) => entry.case.id === humanReview.body.case.id); + expect(humanEntry.review).toMatchObject({ + reviewerKind: "human", + approveToStageKey: "done", + rejectToStageKey: "cancelled", + requestChangesToStageKey: "drafting", + requireRejectReason: true, + expectedVersion: 2, + }); + + const headsUp = boardFeed.body.headsUp[0]; + expect(headsUp.case.id).toBe(dependent.body.case.id); + expect(headsUp.drift.upstream).toMatchObject({ + caseId: upstream.body.case.id, + caseKey: "upstream", + title: "Release plan", + pipelineId: pipeline.id, + pipelineName: "Content production", + }); + expect(headsUp.drift.previousVersion).toBe(1); + expect(headsUp.drift.version).toBe(2); + expect(headsUp.workIssue).toMatchObject({ issueId: workIssue!.id, status: "in_progress" }); + expect(headsUp.activeWork).toMatchObject({ issueId: workIssue!.id, agentId: agent.id, agentName: "Drafting Agent" }); + + // Agent callers only await review stages with reviewerKind "any". + const agentFeed = await asAgent.get(`/api/companies/${company.id}/pipelines-attention`).expect(200); + expect(agentFeed.body.reviews.map((entry: { case: { id: string } }) => entry.case.id)).toEqual([anyReview.body.case.id]); + + // Touching the dependent case does not resolve the drift notice; it must be acknowledged. + await board + .patch(`/api/cases/${dependent.body.case.id}`) + .send({ title: "Dependent draft v2", expectedVersion: 1 }) + .expect(200); + const stillDriftedFeed = await board.get(`/api/companies/${company.id}/pipelines-attention`).expect(200); + expect(stillDriftedFeed.body.headsUp).toHaveLength(1); + + await board + .post(`/api/cases/${dependent.body.case.id}/acknowledge-drift`) + .send({ expectedVersion: 2 }) + .expect(200); + const resolvedFeed = await board.get(`/api/companies/${company.id}/pipelines-attention`).expect(200); + expect(resolvedFeed.body.headsUp).toEqual([]); + + // Terminal cases drop out of the suggestions group. + await board + .post(`/api/cases/${anyReview.body.case.id}/review`) + .send({ decision: "approve", expectedVersion: 2 }) + .expect(200); + const afterApprove = await board.get(`/api/companies/${company.id}/pipelines-attention`).expect(200); + expect(afterApprove.body.reviews.map((entry: { case: { id: string } }) => entry.case.id)).toEqual([humanReview.body.case.id]); + }); + + it("serves the company-wide learnings feed with joins, filtering, and pagination", async () => { + const company = await seedCompany(); + const board = request(app(boardActor)); + const pipeline = await seedPipeline(board, company.id, "content", "Content production"); + + const first = await board + .post(`/api/pipelines/${pipeline.id}/cases`) + .send({ caseKey: "first", title: "First item" }) + .expect(201); + await board + .post(`/api/cases/${first.body.case.id}/transition`) + .send({ toStageKey: "review_human", expectedVersion: 1 }) + .expect(200); + await board + .post(`/api/cases/${first.body.case.id}/review`) + .send({ decision: "reject", reason: "Wrong audience", expectedVersion: 2 }) + .expect(200); + + const second = await board + .post(`/api/pipelines/${pipeline.id}/cases`) + .send({ caseKey: "second", title: "Second item" }) + .expect(201); + await board + .post(`/api/cases/${second.body.case.id}/transition`) + .send({ toStageKey: "review_human", expectedVersion: 1 }) + .expect(200); + await board + .post(`/api/cases/${second.body.case.id}/review`) + .send({ decision: "request_changes", reason: "Tighten the intro", expectedVersion: 2 }) + .expect(200); + + const feed = await board + .get(`/api/companies/${company.id}/case-events?types=review_decided`) + .expect(200); + expect(feed.body.items).toHaveLength(2); + expect(feed.body.items.every((item: { type: string }) => item.type === "review_decided")).toBe(true); + // Newest first. + expect(feed.body.items[0].case.caseKey).toBe("second"); + expect(feed.body.items[0].payload).toMatchObject({ decision: "request_changes", reason: "Tighten the intro" }); + expect(feed.body.items[0].pipeline).toMatchObject({ id: pipeline.id, name: "Content production" }); + expect(feed.body.items[0].fromStage).toMatchObject({ key: "review_human" }); + expect(feed.body.items[0].toStage).toMatchObject({ key: "drafting" }); + expect(feed.body.items[1].case.caseKey).toBe("first"); + expect(feed.body.items[1].payload).toMatchObject({ decision: "reject", reason: "Wrong audience" }); + + const page = await board + .get(`/api/companies/${company.id}/case-events?types=review_decided,transition_forced&limit=1`) + .expect(200); + expect(page.body.items).toHaveLength(1); + expect(page.body.pagination).toMatchObject({ limit: 1, offset: 0, nextOffset: 1, hasMore: true }); + const nextPage = await board + .get(`/api/companies/${company.id}/case-events?types=review_decided&limit=1&offset=1`) + .expect(200); + expect(nextPage.body.items[0].case.caseKey).toBe("first"); + expect(nextPage.body.pagination.hasMore).toBe(false); + + await board.get(`/api/companies/${company.id}/case-events?types=Bad%20Type!`).expect(400); + await board.get(`/api/companies/${company.id}/case-events?limit=0`).expect(400); + }); + + it("returns the recursive children tree with per-node rollups and a flat summary", async () => { + const company = await seedCompany(); + const board = request(app(boardActor)); + const release = await seedPipeline(board, company.id, "release", "Release train"); + const content = await seedPipeline(board, company.id, "content", "Content production"); + + const parent = await board + .post(`/api/pipelines/${release.id}/cases`) + .send({ caseKey: "launch", title: "Launch v2" }) + .expect(201); + const doneChild = await board + .post(`/api/pipelines/${content.id}/cases`) + .send({ caseKey: "blog", title: "Blog post", parentCaseId: parent.body.case.id }) + .expect(201); + await board + .post(`/api/cases/${doneChild.body.case.id}/transition`) + .send({ toStageKey: "done", expectedVersion: 1 }) + .expect(200); + const openChild = await board + .post(`/api/pipelines/${content.id}/cases`) + .send({ caseKey: "video", title: "Video", parentCaseId: parent.body.case.id }) + .expect(201); + const droppedGrandchild = await board + .post(`/api/pipelines/${content.id}/cases`) + .send({ caseKey: "short", title: "Short clip", parentCaseId: openChild.body.case.id }) + .expect(201); + await board + .post(`/api/cases/${droppedGrandchild.body.case.id}/transition`) + .send({ toStageKey: "cancelled", expectedVersion: 1 }) + .expect(200); + + const tree = await board.get(`/api/cases/${parent.body.case.id}/children/tree`).expect(200); + expect(tree.body.rollup).toEqual({ total: 3, done: 1, dropped: 1, inMotion: 1 }); + expect(tree.body.truncated).toBe(false); + expect(tree.body.totalNodes).toBe(4); + expect(tree.body.case.pipeline).toMatchObject({ id: release.id, name: "Release train" }); + expect(tree.body.childGroups).toHaveLength(1); + const group = tree.body.childGroups[0]; + expect(group.pipeline).toMatchObject({ id: content.id, name: "Content production" }); + expect(group.cases.map((node: { caseKey: string }) => node.caseKey)).toEqual(["blog", "video"]); + const blogNode = group.cases[0]; + expect(blogNode.terminalKind).toBe("done"); + expect(blogNode.stage).toMatchObject({ key: "done", kind: "done" }); + expect(blogNode.rollup).toEqual({ total: 0, done: 0, dropped: 0, inMotion: 0 }); + const videoNode = group.cases[1]; + expect(videoNode.rollup).toEqual({ total: 1, done: 0, dropped: 1, inMotion: 0 }); + expect(videoNode.childGroups[0].cases[0]).toMatchObject({ caseKey: "short", terminalKind: "cancelled" }); + + const detail = await board.get(`/api/cases/${parent.body.case.id}`).expect(200); + expect(detail.body.childrenSummary).toMatchObject({ total: 2, done: 1, dropped: 0, inMotion: 1 }); + + await board.get(`/api/cases/${randomUUID()}/children`).expect(404); + }); + + it("enriches case detail and pipeline case lists with activeWork", async () => { + const company = await seedCompany(); + const agent = await seedAgent(company.id, "Working Agent"); + const board = request(app(boardActor)); + const pipeline = await seedPipeline(board, company.id, "content", "Content production"); + + const idleCase = await board + .post(`/api/pipelines/${pipeline.id}/cases`) + .send({ caseKey: "idle", title: "Idle item" }) + .expect(201); + const workedCase = await board + .post(`/api/pipelines/${pipeline.id}/cases`) + .send({ caseKey: "worked", title: "Worked item" }) + .expect(201); + + const [todoIssue] = await db.insert(issues).values({ + companyId: company.id, + title: "Not started yet", + status: "todo", + priority: "medium", + assigneeAgentId: agent.id, + }).returning(); + const [activeIssue] = await db.insert(issues).values({ + companyId: company.id, + title: "Write the draft", + identifier: "AGG-1", + status: "in_progress", + priority: "medium", + assigneeAgentId: agent.id, + startedAt: new Date("2026-06-10T12:00:00Z"), + }).returning(); + await board + .post(`/api/cases/${workedCase.body.case.id}/issue-links`) + .send({ issueId: todoIssue!.id, role: "work" }) + .expect(201); + await board + .post(`/api/cases/${workedCase.body.case.id}/issue-links`) + .send({ issueId: activeIssue!.id, role: "work" }) + .expect(201); + + const detail = await board.get(`/api/cases/${workedCase.body.case.id}`).expect(200); + expect(detail.body.activeWork).toMatchObject({ + issueId: activeIssue!.id, + issueIdentifier: "AGG-1", + issueTitle: "Write the draft", + agentId: agent.id, + agentName: "Working Agent", + }); + expect(detail.body.activeWork.startedAt).toBeTruthy(); + + const idleDetail = await board.get(`/api/cases/${idleCase.body.case.id}`).expect(200); + expect(idleDetail.body.activeWork).toBeNull(); + + const list = await board.get(`/api/pipelines/${pipeline.id}/cases`).expect(200); + const listedWorked = list.body.find((row: { case: { id: string } }) => row.case.id === workedCase.body.case.id); + const listedIdle = list.body.find((row: { case: { id: string } }) => row.case.id === idleCase.body.case.id); + expect(listedWorked.activeWork).toMatchObject({ issueId: activeIssue!.id, agentName: "Working Agent" }); + expect(listedIdle.activeWork).toBeNull(); + }); + + it("derives pipeline connections from observed cross-pipeline case parentage", async () => { + const company = await seedCompany(); + const board = request(app(boardActor)); + const release = await seedPipeline(board, company.id, "release", "Release train"); + const content = await seedPipeline(board, company.id, "content", "Content production"); + const assets = await seedPipeline(board, company.id, "assets", "Asset production"); + const lonely = await seedPipeline(board, company.id, "lonely", "Unconnected"); + + const releaseCase = await board + .post(`/api/pipelines/${release.id}/cases`) + .send({ caseKey: "launch", title: "Launch" }) + .expect(201); + const contentCase = await board + .post(`/api/pipelines/${content.id}/cases`) + .send({ caseKey: "blog", title: "Blog", parentCaseId: releaseCase.body.case.id }) + .expect(201); + await board + .post(`/api/pipelines/${assets.id}/cases`) + .send({ caseKey: "thumb", title: "Thumbnail", parentCaseId: contentCase.body.case.id }) + .expect(201); + // Same-pipeline parentage must not create a self connection. + await board + .post(`/api/pipelines/${content.id}/cases`) + .send({ caseKey: "blog-child", title: "Blog child", parentCaseId: contentCase.body.case.id }) + .expect(201); + + // Move one case into a working stage and one into a review stage so the + // list response exposes board-facing activity counts. + await board + .post(`/api/cases/${releaseCase.body.case.id}/transition`) + .send({ toStageKey: "drafting", expectedVersion: releaseCase.body.case.version }) + .expect(200); + await board + .post(`/api/cases/${contentCase.body.case.id}/transition`) + .send({ toStageKey: "review_human", expectedVersion: contentCase.body.case.version }) + .expect(200); + + const listed = await board.get(`/api/companies/${company.id}/pipelines`).expect(200); + const byId = new Map(listed.body.map((row: { id: string }) => [row.id, row])); + expect(byId.get(release.id)).toMatchObject({ openCaseCount: 1, attentionCount: 0, inMotionCount: 1 }); + expect(byId.get(content.id)).toMatchObject({ openCaseCount: 2, attentionCount: 1, inMotionCount: 1 }); + expect(byId.get(assets.id)).toMatchObject({ openCaseCount: 1, attentionCount: 0, inMotionCount: 1 }); + expect(byId.get(lonely.id)).toMatchObject({ openCaseCount: 0, attentionCount: 0, inMotionCount: 0, lastActivityAt: null }); + expect((byId.get(content.id) as { lastActivityAt: unknown }).lastActivityAt).toBeTruthy(); + expect((byId.get(release.id) as { connections: unknown }).connections).toEqual({ + upstreamPipelineIds: [], + downstreamPipelineIds: [content.id], + }); + expect((byId.get(content.id) as { connections: unknown }).connections).toEqual({ + upstreamPipelineIds: [release.id], + downstreamPipelineIds: [assets.id], + }); + expect((byId.get(assets.id) as { connections: unknown }).connections).toEqual({ + upstreamPipelineIds: [content.id], + downstreamPipelineIds: [], + }); + expect((byId.get(lonely.id) as { connections: unknown }).connections).toEqual({ + upstreamPipelineIds: [], + downstreamPipelineIds: [], + }); + }); +}); diff --git a/ui/src/components/PipelineLivenessBanner.test.tsx b/ui/src/components/PipelineLivenessBanner.test.tsx new file mode 100644 index 00000000000..a0edac6f5c3 --- /dev/null +++ b/ui/src/components/PipelineLivenessBanner.test.tsx @@ -0,0 +1,125 @@ +// @vitest-environment jsdom + +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import type { AnchorHTMLAttributes, ReactElement } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PipelineCaseLiveness } from "@paperclipai/shared"; +import { PipelineLivenessBanner } from "./PipelineLivenessBanner"; + +vi.mock("@/lib/router", () => ({ + Link: ({ children, to, ...props }: AnchorHTMLAttributes & { to: string }) => ( + {children} + ), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function act(callback: () => T): T { + let result: T | undefined; + flushSync(() => { + result = callback(); + }); + return result as T; +} + +let root: ReturnType | null = null; +let container: HTMLDivElement | null = null; + +afterEach(() => { + if (root) act(() => root?.unmount()); + root = null; + container?.remove(); + container = null; +}); + +function render(element: ReactElement) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => root?.render(element)); + return container; +} + +function click(element: Element | null) { + if (!element) throw new Error("Expected element to exist"); + act(() => { + element.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +function liveness(overrides: Partial): PipelineCaseLiveness { + return { state: "attention", reason: "no_action_path", message: "stub", ...overrides } as PipelineCaseLiveness; +} + +describe("PipelineLivenessBanner", () => { + it("renders nothing for a running item", () => { + const node = render(); + expect(node.querySelector("section")).toBeNull(); + }); + + it("renders a blocked banner with a blocking-issue link and no retry", () => { + const node = render( + {}} + />, + ); + const section = node.querySelector("section"); + expect(section?.getAttribute("aria-label")).toMatch(/Automation paused/); + const links = Array.from(node.querySelectorAll("a")).map((a) => a.getAttribute("href")); + expect(links).toContain("/issues/blk-1"); + expect(links).toContain("/issues/PAP-900"); + expect(node.querySelector("button")).toBeNull(); + }); + + it("invokes onRetry with the automation kind for restored permission", () => { + const onRetry = vi.fn(); + const node = render( + , + ); + const button = node.querySelector("button"); + expect(button?.textContent).toMatch(/Retry now/); + click(button); + expect(onRetry).toHaveBeenCalledWith("automation"); + }); + + it("surfaces a retry error inline via role=alert", () => { + const node = render( + {}} + retryError="Forbidden: missing pipelines:write" + />, + ); + const alert = node.querySelector('[role="alert"]'); + expect(alert?.textContent).toMatch(/Forbidden/); + }); + + it("disables the retry button while pending", () => { + const node = render( + {}} + retryPending + />, + ); + const button = node.querySelector("button") as HTMLButtonElement | null; + expect(button?.disabled).toBe(true); + expect(button?.textContent).toMatch(/Retrying/); + }); +}); diff --git a/ui/src/components/StageSecretsPanel.test.tsx b/ui/src/components/StageSecretsPanel.test.tsx new file mode 100644 index 00000000000..0993a8624ed --- /dev/null +++ b/ui/src/components/StageSecretsPanel.test.tsx @@ -0,0 +1,193 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CompanySecret, RoutineEnvConfig } from "@paperclipai/shared"; +import { StageSecretsPanel } from "./StageSecretsPanel"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function secret(partial: Partial & { id: string; name: string }): CompanySecret { + return { + companyId: "company-1", + key: partial.name.toLowerCase(), + provider: "local_encrypted", + status: "active", + managedMode: "paperclip_managed", + externalRef: null, + providerConfigId: null, + providerMetadata: null, + latestVersion: 1, + description: null, + lastResolvedAt: null, + lastRotatedAt: null, + deletedAt: null, + createdByAgentId: null, + createdByUserId: "user-1", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + ...partial, + } as CompanySecret; +} + +const SECRETS: CompanySecret[] = [ + secret({ id: "secret-gh", name: "GH_TOKEN", latestVersion: 2 }), + secret({ id: "secret-disabled", name: "OLD_KEY", status: "disabled" }), +]; + +const noop = () => {}; +const asyncNoop = async () => SECRETS[0]!; + +describe("StageSecretsPanel", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + function render(node: React.ReactNode) { + const root = createRoot(container); + flushSync(() => { + root.render(node as React.ReactElement); + }); + return root; + } + + it("shows the no-automation empty state with a jump-to-automation action", () => { + const onSetup = vi.fn(); + render( + , + ); + expect(container.textContent).toContain("Secrets are available only to step automation"); + const button = container.querySelector("button"); + expect(button?.textContent).toContain("Set up automation"); + flushSync(() => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(onSetup).toHaveBeenCalledTimes(1); + // It must NOT render the env editor when there is no automation. + expect(container.querySelector('input[placeholder="KEY"]')).toBeNull(); + }); + + it("renders the configured state with the agent name, precedence copy, and env editor", () => { + const value: RoutineEnvConfig = { + GH_TOKEN: { type: "secret_ref", secretId: "secret-gh", version: "latest" }, + }; + render( + , + ); + expect(container.textContent).toContain("Releasebot"); + expect(container.textContent).toContain("override matching project and agent env"); + // EnvVarEditor mounts a KEY input. + expect(container.querySelector('input[placeholder="KEY"]')).not.toBeNull(); + // Save disabled while not dirty. + const saveButton = Array.from(container.querySelectorAll("button")).find((b) => + b.textContent?.includes("Save secrets"), + ); + expect(saveButton).toBeTruthy(); + expect((saveButton as HTMLButtonElement).disabled).toBe(true); + }); + + it("surfaces a warning for a disabled/missing secret binding", () => { + const value: RoutineEnvConfig = { + OLD_KEY: { type: "secret_ref", secretId: "secret-disabled", version: "latest" }, + GONE: { type: "secret_ref", secretId: "does-not-exist", version: "latest" }, + }; + render( + , + ); + expect(container.textContent).toContain("need attention"); + expect(container.textContent).toContain("disabled"); + expect(container.textContent).toContain("Missing secret"); + }); + + it("enables Save when dirty and fires onSave", () => { + const onSave = vi.fn(); + render( + , + ); + const saveButton = Array.from(container.querySelectorAll("button")).find((b) => + b.textContent?.includes("Save secrets"), + ) as HTMLButtonElement; + expect(saveButton.disabled).toBe(false); + expect(container.textContent).toContain("Unsaved changes"); + flushSync(() => { + saveButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(onSave).toHaveBeenCalledTimes(1); + }); + + it("shows a loading message instead of the editor while secrets load", () => { + render( + , + ); + expect(container.textContent).toContain("Loading secrets"); + expect(container.querySelector('input[placeholder="KEY"]')).toBeNull(); + }); +}); diff --git a/ui/src/lib/pipeline-breakdown.test.ts b/ui/src/lib/pipeline-breakdown.test.ts new file mode 100644 index 00000000000..aaa97c1e9a4 --- /dev/null +++ b/ui/src/lib/pipeline-breakdown.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; +import { + breakdownMechanicsBullets, + breakdownSummarySentence, + joinWithAnd, + pieceNounPlural, + readStageBreakdown, + type BreakdownCopyNames, + type StageBreakdownConfig, +} from "./pipeline-breakdown"; + +describe("readStageBreakdown", () => { + it("returns null when there is no breakdown block", () => { + expect(readStageBreakdown({ config: {} })).toBeNull(); + expect(readStageBreakdown({ config: null })).toBeNull(); + expect(readStageBreakdown(null)).toBeNull(); + }); + + it("reads a full breakdown config and trims/defaults fields", () => { + const config = readStageBreakdown({ + config: { + breakdown: { + targetPipelineId: " pipe-1 ", + targetStageKey: " intake ", + pieceNoun: " feature ", + inheritFields: ["release", "", " ", "owner"], + advanceTo: "review", + waitForPieces: true, + whenFinishedMoveTo: "ship", + }, + }, + }); + expect(config).toEqual({ + targetPipelineId: "pipe-1", + targetStageKey: "intake", + pieceNoun: "feature", + inheritFields: ["release", "owner"], + carryOverPolicy: { + version: 1, + mode: "only", + includeFields: ["release", "owner"], + excludeFields: [], + }, + advanceTo: "review", + waitForPieces: true, + whenFinishedMoveTo: "ship", + } satisfies StageBreakdownConfig); + }); + + it("reads versioned all-except carry-over policy", () => { + const config = readStageBreakdown({ + config: { + breakdown: { + targetPipelineId: "pipe-1", + targetStageKey: "intake", + carryOverPolicy: { version: 1, mode: "all_except", excludeFields: ["owner"] }, + }, + }, + }); + expect(config?.carryOverPolicy).toEqual({ + version: 1, + mode: "all_except", + includeFields: [], + excludeFields: ["owner"], + }); + }); + + it("defaults the noun to 'piece' when absent", () => { + const config = readStageBreakdown({ config: { breakdown: { targetPipelineId: "p", targetStageKey: "s" } } }); + expect(config?.pieceNoun).toBe("piece"); + expect(config?.waitForPieces).toBe(false); + expect(config?.carryOverPolicy).toEqual({ + version: 1, + mode: "only", + includeFields: [], + excludeFields: [], + }); + }); +}); + +describe("pieceNounPlural", () => { + it("appends s and falls back to piece", () => { + expect(pieceNounPlural("feature")).toBe("features"); + expect(pieceNounPlural(" ")).toBe("pieces"); + }); +}); + +describe("joinWithAnd", () => { + it("joins with commas and a trailing and", () => { + expect(joinWithAnd(["a"])).toBe("a"); + expect(joinWithAnd(["a", "b"])).toBe("a and b"); + expect(joinWithAnd(["a", "b", "c"])).toBe("a, b and c"); + expect(joinWithAnd([])).toBe(""); + }); +}); + +const baseConfig: StageBreakdownConfig = { + targetPipelineId: "pipe-1", + targetStageKey: "intake", + pieceNoun: "feature", + inheritFields: ["release", "owner"], + carryOverPolicy: { + version: 1, + mode: "only", + includeFields: ["release", "owner"], + excludeFields: [], + }, + advanceTo: "review", + waitForPieces: true, + whenFinishedMoveTo: "ship", +}; + +const names: BreakdownCopyNames = { + targetPipelineName: "Features", + entryStageName: "Intake", + advanceToName: "Review", + whenFinishedName: "Ship", + inheritedFieldLabels: ["Release", "Owner"], +}; + +describe("breakdownSummarySentence", () => { + it("composes the full sentence including the wait clause", () => { + expect(breakdownSummarySentence(baseConfig, names)).toBe( + "Paperclip will create one feature per item in Features → Intake, carry over Release and Owner, move this case to Review, then wait until every feature is finished before moving it to Ship.", + ); + }); + + it("omits the wait clause when waiting is off", () => { + const sentence = breakdownSummarySentence({ ...baseConfig, waitForPieces: false }, names); + expect(sentence).toBe( + "Paperclip will create one feature per item in Features → Intake, carry over Release and Owner, move this case to Review.", + ); + }); + + it("returns null when the target is not picked yet", () => { + expect(breakdownSummarySentence({ ...baseConfig, targetPipelineId: "" }, names)).toBeNull(); + expect(breakdownSummarySentence(baseConfig, { ...names, targetPipelineName: "" })).toBeNull(); + }); +}); + +describe("breakdownMechanicsBullets", () => { + it("includes the empty-list bullet only when waiting is on", () => { + const waiting = breakdownMechanicsBullets(baseConfig, names); + expect(waiting.some((b) => b.includes("empty list"))).toBe(true); + expect(waiting.some((b) => b.startsWith("Waits until every feature"))).toBe(true); + + const noWait = breakdownMechanicsBullets({ ...baseConfig, waitForPieces: false }, names); + expect(noWait.some((b) => b.includes("empty list"))).toBe(false); + }); + + it("drops the carry-over bullet when there are no inherited fields", () => { + const bullets = breakdownMechanicsBullets( + { ...baseConfig, inheritFields: [] }, + { ...names, inheritedFieldLabels: [] }, + ); + expect(bullets.some((b) => b.startsWith("Carries over"))).toBe(false); + }); +}); diff --git a/ui/src/lib/pipeline-item-detail.test.ts b/ui/src/lib/pipeline-item-detail.test.ts new file mode 100644 index 00000000000..a01f4ab90c1 --- /dev/null +++ b/ui/src/lib/pipeline-item-detail.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from "vitest"; +import type { PipelineCase, PipelineCaseEvent, PipelineStage } from "../api/pipelines"; +import { + changedNoticeFromEvents, + displayPipelineItemFields, + formatPipelineItemEvent, + getPendingTransitionBannerState, + humanizePipelineItemStatus, + INTERNAL_FIELD_KEYS, + itemHasChangedNotice, + normalizePipelineChildRows, + splitPipelineItemFields, +} from "./pipeline-item-detail"; + +const stages: PipelineStage[] = [ + { id: "stage-intake", pipelineId: "pipeline-1", key: "intake", name: "Intake", kind: "working", position: 100 }, + { id: "stage-review", pipelineId: "pipeline-1", key: "review", name: "Review", kind: "review", position: 200 }, + { id: "stage-done", pipelineId: "pipeline-1", key: "done", name: "Done", kind: "done", position: 900 }, +]; + +function item(overrides: Partial): PipelineCase { + return { + id: "item-1", + pipelineId: "pipeline-1", + stageId: "stage-intake", + title: "Draft launch post", + fields: {}, + ...overrides, + }; +} + +function event(type: string, payload: Record = {}, overrides: Partial = {}): PipelineCaseEvent { + return { + id: `${type}-event`, + companyId: "company-1", + caseId: "item-1", + type, + actorType: "system", + payload, + createdAt: "2026-06-10T12:00:00.000Z", + updatedAt: "2026-06-10T12:00:00.000Z", + ...overrides, + }; +} + +describe("pipeline item detail helpers", () => { + it("shows and hides the pending transition banner from item state", () => { + expect(getPendingTransitionBannerState(item({ + pendingSuggestion: { + id: "suggestion-1", + toStageKey: "review", + rationale: "Ready for review", + createdAt: "2026-06-10T12:00:00.000Z", + }, + }), stages)).toMatchObject({ + visible: true, + suggestionId: "suggestion-1", + stageName: "Review", + }); + + expect(getPendingTransitionBannerState(item({ fields: { suggestionResolution: "dismiss" } }), stages)).toEqual({ + visible: false, + reason: "resolved", + }); + expect(getPendingTransitionBannerState(item({ fields: {} }), stages)).toEqual({ + visible: false, + reason: "no_next_stage", + }); + expect(getPendingTransitionBannerState(item({ fields: { nextSuggestedStageId: "stage-done" } }), stages)).toMatchObject({ + visible: true, + stageName: "Done", + }); + expect(getPendingTransitionBannerState(item({ fields: { nextSuggestedStageId: "missing-stage" } }), stages)).toMatchObject({ + visible: true, + stageName: "the next stage", + }); + }); + + it("detects changed items until acknowledged", () => { + expect(itemHasChangedNotice(item({ fields: { changeAcknowledgedAt: "2026-06-10T12:00:00.000Z", upstreamDrift: true } }))).toBeNull(); + expect(itemHasChangedNotice({ ...item({ fields: {} }), thisChanged: true })).toMatchObject({ title: "This changed" }); + expect(itemHasChangedNotice(item({ fields: { upstreamChanged: true } }))).toMatchObject({ title: "This changed" }); + expect(itemHasChangedNotice(item({ fields: { upstreamDrift: true } }))).toMatchObject({ title: "This changed" }); + expect(itemHasChangedNotice(item({ fields: {} }))).toBeNull(); + expect(changedNoticeFromEvents([ + event("upstream_drift", {}, { createdAt: "2026-06-10T12:00:00.000Z" }), + ])).toMatchObject({ title: "This changed" }); + expect(changedNoticeFromEvents([ + event("upstream_drift", {}, { createdAt: "2026-06-10T12:00:00.000Z" }), + event("drift_acknowledged", {}, { createdAt: "2026-06-10T12:01:00.000Z" }), + ])).toBeNull(); + }); + + it("formats every supported activity kind as prose", () => { + const cases: Array<[PipelineCaseEvent, string]> = [ + [event("case.ingested"), "Item added."], + [event("case.updated"), "Item details updated."], + [event("case.transitioned", { reason: "Start content intake", transitionClass: "manual" }, { + fromStageId: "stage-intake", + toStageId: "stage-review", + actorType: "agent", + actorAgent: { id: "agent-1", name: "Dotta" }, + }), "Moved from Intake to Review — Dotta: 'Start content intake'."], + [event("case.transitioned", { reason: "children_terminal", transitionClass: "auto" }, { + toStageId: "stage-done", + actorType: "system", + }), "Moved to Done — automatic (all child items done)."], + [event("case.transitioned", { reason: "children_terminal", transitionClass: "manual" }, { + toStageId: "stage-done", + actorType: "system", + }), "Moved to Done — automatic (all child items done)."], + [event("case.suggested", { suggestion: { toStageKey: "review" } }), "Suggested moving to Review."], + [event("case.suggestion_resolved", { decision: "accept" }), "Suggestion approved."], + [event("case.suggestion_resolved", { decision: "dismiss" }), "Suggestion dismissed."], + [event("case.reviewed", { decision: "request_changes" }), "Review requested changes."], + [event("case.reviewed", { decision: "drop" }), "Review removed this item."], + [event("case.reviewed", { decision: "approve" }), "Review approved this item."], + [event("upstream_drift", { upstreamCaseKey: "BLOG-12" }), "Upstream change detected from BLOG-12."], + [event("upstream_drift"), "Upstream change detected."], + [event("drift_acknowledged"), "Upstream change acknowledged."], + [event("automation_executed", {}, { + automation: { + routine: { id: "routine-1", title: "Draft announcement" }, + issue: { id: "issue-1", identifier: "PAP-42", title: "Draft the announcement", status: "todo" }, + routineRunId: "run-1", + }, + }), "Automation completed — ran Draft announcement -> PAP-42."], + [event("automation_failed", { error: "automation_not_configured" }), "Automation needs attention — automation not configured."], + [event("case.unknown_kind"), "Activity recorded."], + ]; + + for (const [input, expected] of cases) { + expect(formatPipelineItemEvent(input, stages)).toBe(expected); + } + }); + + it("humanizes status values", () => { + expect(humanizePipelineItemStatus(null)).toBe("Open"); + expect(humanizePipelineItemStatus("open")).toBe("Open"); + expect(humanizePipelineItemStatus("done")).toBe("Done"); + expect(humanizePipelineItemStatus("cancelled")).toBe("Removed"); + expect(humanizePipelineItemStatus("in_review")).toBe("In review"); + expect(humanizePipelineItemStatus("in_progress")).toBe("In progress"); + expect(humanizePipelineItemStatus("needs_qa")).toBe("Needs qa"); + }); + + it("filters internal fields and formats display values", () => { + const fields = { + audience: ["Founders", "Operators"], + owner: { label: "Launch team" }, + backupOwner: { name: "Growth" }, + source: { title: "Launch brief" }, + metadata: { nested: true }, + title: "Announcement", + nextSuggestedStageId: "review", + suggestionResolution: "accept", + upstreamDrift: true, + changeAcknowledgedAt: "2026-06-10T12:00:00.000Z", + thisChanged: true, + }; + + const displayed = displayPipelineItemFields(fields); + expect(displayed).toEqual([ + { key: "audience", label: "Audience", value: "Founders, Operators" }, + { key: "owner", label: "Owner", value: "Launch team" }, + { key: "backupOwner", label: "Backup Owner", value: "Growth" }, + { key: "source", label: "Source", value: "Launch brief" }, + { key: "metadata", label: "Metadata", value: "Added details" }, + { key: "title", label: "Title", value: "Announcement" }, + ]); + for (const key of INTERNAL_FIELD_KEYS) { + expect(displayed.some((field) => field.key === key)).toBe(false); + } + }); + + it("splits short sidebar fields from long main-pane fields without relying on key names", () => { + const displayed = displayPipelineItemFields({ + owner: "Launch team", + reviewerOpenQuestions: "1. Does the headline need a source?\n2. Should we include the rollout caveat?", + arbitraryLongText: "This is a long field from a dynamic pipeline schema. ".repeat(5), + }); + + expect(splitPipelineItemFields(displayed)).toEqual({ + shortFields: [ + { key: "owner", label: "Owner", value: "Launch team" }, + ], + longFields: [ + { + key: "reviewerOpenQuestions", + label: "Reviewer Open Questions", + value: "1. Does the headline need a source?\n2. Should we include the rollout caveat?", + }, + { + key: "arbitraryLongText", + label: "Arbitrary Long Text", + value: "This is a long field from a dynamic pipeline schema. ".repeat(5), + }, + ], + }); + }); + + it("normalizes rollup-tree children responses into direct child rows", () => { + const rows = normalizePipelineChildRows({ + case: { + id: "release-1", + title: "Release v0.42", + pipeline: { id: "release-pipeline", key: "release", name: "Release" }, + stage: { id: "release-producing", key: "producing", name: "Producing", kind: "working" }, + childGroups: [ + { + pipeline: { id: "feature-pipeline", key: "feature", name: "Feature" }, + cases: [ + { + id: "feature-1", + caseKey: "v0.42-pipelines-ui", + title: "Feature: Pipelines UI", + terminalKind: "done", + pipeline: { id: "feature-pipeline", key: "feature", name: "Feature" }, + stage: { id: "feature-covered", key: "covered", name: "Covered", kind: "done" }, + rollup: { total: 6, done: 3, dropped: 3, inMotion: 0 }, + childGroups: [], + }, + ], + }, + ], + }, + }); + + expect(rows).toHaveLength(1); + expect(rows[0].case).toMatchObject({ + id: "feature-1", + pipelineId: "feature-pipeline", + stageId: "feature-covered", + title: "Feature: Pipelines UI", + terminalKind: "done", + childCount: 6, + }); + expect(rows[0].stage).toMatchObject({ + id: "feature-covered", + pipelineId: "feature-pipeline", + key: "covered", + name: "Covered", + kind: "done", + }); + }); +}); diff --git a/ui/src/lib/pipeline-learnings.test.ts b/ui/src/lib/pipeline-learnings.test.ts new file mode 100644 index 00000000000..12ec44a16e1 --- /dev/null +++ b/ui/src/lib/pipeline-learnings.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import type { PipelineCompanyCaseEvent } from "../api/pipelines"; +import { formatLearningEvent, groupLearningEventsByDay, learningDayKey } from "./pipeline-learnings"; + +function event(overrides: Partial): PipelineCompanyCaseEvent { + return { + id: "event-1", + companyId: "company-1", + caseId: "item-1", + type: "review_decided", + actorType: "user", + createdAt: "2026-06-10T12:00:00.000Z", + updatedAt: "2026-06-10T12:00:00.000Z", + payload: {}, + case: { id: "item-1", caseKey: "CASE-1", title: "Launch tweet" }, + pipeline: { id: "pipeline-1", key: "content", name: "Content production" }, + fromStage: null, + toStage: null, + actorAgent: null, + ...overrides, + }; +} + +describe("formatLearningEvent", () => { + it("translates review decisions with a target stage", () => { + const result = formatLearningEvent(event({ + type: "review_decided", + toStage: { id: "stage-2", key: "publish", name: "Publish", kind: "done" }, + payload: { actorName: "Dotta", decision: "approve" }, + })); + + expect(result.kind).toBe("review"); + expect(result.sentence).toBe("Dotta approved 'Launch tweet' moving to Publish."); + expect(result.sentence).not.toContain("review_decided"); + }); + + it("translates review decisions without optional context", () => { + const result = formatLearningEvent(event({ + type: "review_decided", + payload: { decision: "request_changes" }, + })); + + expect(result.sentence).toBe("Someone sent back 'Launch tweet'."); + expect(result.sentence).not.toContain("review_decided"); + }); + + it("prefers the acting agent's name and renders reject decisions", () => { + const result = formatLearningEvent(event({ + type: "review_decided", + actorType: "agent", + actorAgent: { id: "agent-1", name: "Drafting agent" }, + payload: { decision: "reject", reason: "Off brand" }, + })); + + expect(result.sentence).toBe("Drafting agent declined 'Launch tweet' - note: Off brand."); + }); + + it("translates forced moves with a reason", () => { + const result = formatLearningEvent(event({ + type: "transition_forced", + fromStage: { id: "stage-1", key: "drafting", name: "Drafting", kind: "working" }, + toStage: { id: "stage-3", key: "done", name: "Done", kind: "done" }, + payload: { reason: "Ready for the campaign" }, + })); + + expect(result.kind).toBe("forced_move"); + expect(result.sentence).toBe("'Launch tweet' was moved by hand from Drafting to Done - reason: Ready for the campaign."); + expect(result.sentence).not.toContain("transition_forced"); + }); + + it("translates forced moves without a reason", () => { + const result = formatLearningEvent(event({ + type: "transition_forced", + toStage: { id: "stage-3", key: "done", name: "Done", kind: "done" }, + payload: {}, + })); + + expect(result.sentence).toBe("'Launch tweet' was moved by hand to Done."); + expect(result.sentence).not.toContain("transition_forced"); + }); + + it("falls back to neutral copy for unknown event types", () => { + const result = formatLearningEvent(event({ type: "blockers_set" })); + + expect(result.kind).toBe("unknown"); + expect(result.sentence).toBe("'Launch tweet' changed."); + expect(result.sentence).not.toContain("blockers_set"); + }); +}); + +describe("groupLearningEventsByDay", () => { + it("groups consecutive events by calendar day", () => { + const groups = groupLearningEventsByDay([ + event({ id: "a", createdAt: "2026-06-10T12:00:00.000Z" }), + event({ id: "b", createdAt: "2026-06-10T08:00:00.000Z" }), + event({ id: "c", createdAt: "2026-06-09T22:00:00.000Z" }), + ]); + + expect(groups).toHaveLength(2); + expect(groups[0].events.map((item) => item.id)).toEqual(["a", "b"]); + expect(groups[1].events.map((item) => item.id)).toEqual(["c"]); + }); + + it("buckets invalid timestamps under Unknown", () => { + expect(learningDayKey("not-a-date")).toBe("Unknown"); + }); +}); diff --git a/ui/src/lib/pipeline-liveness.test.ts b/ui/src/lib/pipeline-liveness.test.ts new file mode 100644 index 00000000000..a3359b24541 --- /dev/null +++ b/ui/src/lib/pipeline-liveness.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vitest"; +import type { PipelineCaseLiveness } from "@paperclipai/shared"; +import { + derivePipelineLivenessBanner, + shouldDisableRerunForPermission, +} from "./pipeline-liveness"; + +function liveness(overrides: Partial): PipelineCaseLiveness { + return { + state: "attention", + reason: "no_action_path", + message: "stub", + ...overrides, + } as PipelineCaseLiveness; +} + +describe("derivePipelineLivenessBanner", () => { + it("returns null for non-stuck states", () => { + for (const reason of [ + "terminal", + "lease_active", + "linked_issue_active", + "linked_issue_waiting", + "children_waiting", + "review_waiting", + ] as const) { + expect(derivePipelineLivenessBanner(liveness({ reason }))).toBeNull(); + } + expect(derivePipelineLivenessBanner(null)).toBeNull(); + expect(derivePipelineLivenessBanner(undefined)).toBeNull(); + }); + + it("renders an amber blocked banner with a blocking-issue link", () => { + const view = derivePipelineLivenessBanner( + liveness({ + state: "blocked", + reason: "linked_issue_blocked", + message: "Linked automation issue is blocked.", + issue: { id: "auto-1", identifier: "PAP-900", title: "Build the thing", status: "blocked" }, + blocker: { issueId: "blk-1", title: "Waiting on legal", status: "in_progress" }, + }), + ); + expect(view).not.toBeNull(); + expect(view!.tone).toBe("blocked"); + expect(view!.showRetry).toBe(false); + expect(view!.blockerLink).toEqual({ issueId: "blk-1", title: "Waiting on legal" }); + expect(view!.automationLink).toEqual({ issueId: "auto-1", identifier: "PAP-900", title: "Build the thing" }); + expect(view!.helperNote).toMatch(/automatically/i); + }); + + it("renders a purple permission banner and surfaces the permission key", () => { + const view = derivePipelineLivenessBanner( + liveness({ + state: "blocked", + reason: "permission_preflight_failed", + message: "Pipeline automation is blocked until the assignee can write to the target pipeline.", + automation: { + automationId: "auto-2", + fingerprint: "case-1:stage-1:auto-2:target-pipe:agent-9:pipelines:write", + }, + }), + ); + expect(view!.tone).toBe("permission"); + expect(view!.permissionKey).toBe("pipelines:write"); + expect(view!.showRetry).toBe(false); + }); + + it("renders an indigo ready-to-retry banner for restored permission", () => { + const view = derivePipelineLivenessBanner( + liveness({ + state: "attention", + reason: "automation_failed", + message: "Pipeline automation permission has been restored; retry the failed automation ledger.", + automation: { automationId: "auto-3" }, + }), + ); + expect(view!.tone).toBe("retry"); + expect(view!.showRetry).toBe(true); + expect(view!.retryKind).toBe("automation"); + expect(view!.retryLabel).toBe("Retry now"); + }); + + it("renders an attention banner with a stage-rerun retry for generic failure", () => { + const view = derivePipelineLivenessBanner( + liveness({ + state: "attention", + reason: "automation_failed", + message: "Pipeline automation failed and needs retry or recovery.", + automation: { automationId: null }, + }), + ); + expect(view!.tone).toBe("attention"); + expect(view!.showRetry).toBe(true); + expect(view!.retryKind).toBe("stage"); + }); + + it("counts missing breakdown pieces in the body", () => { + const view = derivePipelineLivenessBanner( + liveness({ + state: "blocked", + reason: "breakdown_incomplete", + message: "Breakdown evidence does not match created child cases.", + breakdown: { missingRequestKeys: ["a", "b"] }, + }), + ); + expect(view!.tone).toBe("blocked"); + expect(view!.body).toMatch(/2 expected pieces are still missing/); + }); + + it("treats no_action_path as a stuck attention banner with a retry", () => { + const view = derivePipelineLivenessBanner(liveness({ reason: "no_action_path" })); + expect(view!.tone).toBe("attention"); + expect(view!.showRetry).toBe(true); + expect(view!.retryKind).toBe("stage"); + }); + + it("uses prosumer copy for no_action_path, never the raw server vocabulary (PAP-11259)", () => { + const view = derivePipelineLivenessBanner( + liveness({ + reason: "no_action_path", + message: "No lease, linked work, blocker, automation retry, review, or breakdown action path is visible.", + }), + ); + expect(view!.title).toBe("This item is stuck"); + expect(view!.body).not.toMatch(/lease|linked work|action path/i); + expect(view!.body).toMatch(/Re-run the stage/); + }); +}); + +describe("shouldDisableRerunForPermission", () => { + it("disables only for the permission preflight reason", () => { + expect(shouldDisableRerunForPermission(liveness({ reason: "permission_preflight_failed" }))).toBe(true); + expect(shouldDisableRerunForPermission(liveness({ reason: "automation_failed" }))).toBe(false); + expect(shouldDisableRerunForPermission(null)).toBe(false); + }); +}); diff --git a/ui/src/lib/pipeline-references.test.ts b/ui/src/lib/pipeline-references.test.ts new file mode 100644 index 00000000000..2c391dafc8c --- /dev/null +++ b/ui/src/lib/pipeline-references.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { extractWorkReferences, referenceFieldKeys, type WorkReference } from "./pipeline-references"; + +function kinds(refs: WorkReference[]) { + return refs.map((ref) => ref.kind); +} + +describe("extractWorkReferences", () => { + it("returns nothing for a case with no references", () => { + expect(extractWorkReferences({ fields: { topic: "Launch", count: 3 } })).toEqual([]); + }); + + it("surfaces the workspaceRef column as a workspace reference", () => { + const refs = extractWorkReferences({ + workspaceRef: { path: "/content/blog", branch: "feature/blog" }, + fields: {}, + }); + expect(refs).toHaveLength(1); + expect(refs[0]).toMatchObject({ kind: "workspace", path: "/content/blog", branch: "feature/blog" }); + }); + + it("detects a bare URL string field", () => { + const refs = extractWorkReferences({ fields: { draft_link: "https://docs.example.com/x" } }); + expect(refs[0]).toMatchObject({ kind: "url", url: "https://docs.example.com/x", label: "Draft link" }); + }); + + it("ignores non-http strings", () => { + expect(extractWorkReferences({ fields: { note: "just text" } })).toEqual([]); + }); + + it("detects an explicit url record", () => { + const refs = extractWorkReferences({ + fields: { asset: { kind: "url", url: "https://cdn.example.com/i.png", label: "Hero image" } }, + }); + expect(refs[0]).toMatchObject({ kind: "url", url: "https://cdn.example.com/i.png", label: "Hero image" }); + }); + + it("detects an issue reference record", () => { + const refs = extractWorkReferences({ + fields: { work: { issueId: "issue-1", identifier: "PAP-99", title: "Write blog" } }, + }); + expect(refs[0]).toMatchObject({ kind: "issue", issueId: "issue-1", identifier: "PAP-99", label: "Write blog" }); + }); + + it("detects a workspace-shaped field record", () => { + const refs = extractWorkReferences({ fields: { folder: { path: "/assets", kind: "workspace" } } }); + expect(refs[0]).toMatchObject({ kind: "workspace", path: "/assets" }); + }); + + it("combines workspaceRef and field references in order", () => { + const refs = extractWorkReferences({ + workspaceRef: { path: "/root" }, + fields: { + link: "https://example.com", + work: { issueIdentifier: "PAP-1" }, + }, + }); + expect(kinds(refs)).toEqual(["workspace", "url", "issue"]); + }); + + it("reports reference field keys for exclusion from the plain list", () => { + const fields = { link: "https://example.com", topic: "Launch", work: { issueId: "i1" } }; + const keys = referenceFieldKeys(fields); + expect(keys.has("link")).toBe(true); + expect(keys.has("work")).toBe(true); + expect(keys.has("topic")).toBe(false); + }); +}); diff --git a/ui/src/lib/pipeline-settings.test.ts b/ui/src/lib/pipeline-settings.test.ts new file mode 100644 index 00000000000..e1d438ebb3a --- /dev/null +++ b/ui/src/lib/pipeline-settings.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { pipelineStageConfigSchema } from "@paperclipai/shared"; + +describe("pipeline settings stage config", () => { + it("accepts disable and approval settings stored in stage config", () => { + const parsed = pipelineStageConfigSchema.safeParse({ + variables: [ + { + key: "customer", + label: "Customer", + type: "text", + options: [], + required: true, + showInAddForm: true, + }, + ], + disabled: true, + disabledReason: "Pause intake while the team clears the queue.", + requireApproval: true, + approver: { + kind: "agent", + id: "agent-1", + }, + whatHappensHere: "Triage every incoming item before work starts.", + }); + + expect(parsed.success).toBe(true); + }); + + it("requires a concrete approver when approval targets a specific person or agent", () => { + const parsed = pipelineStageConfigSchema.safeParse({ + variables: [], + requireApproval: true, + approver: { + kind: "user", + }, + }); + + expect(parsed.success).toBe(false); + }); +}); diff --git a/ui/src/pages/PipelineSettings.test.tsx b/ui/src/pages/PipelineSettings.test.tsx new file mode 100644 index 00000000000..12793a8dad8 --- /dev/null +++ b/ui/src/pages/PipelineSettings.test.tsx @@ -0,0 +1,1549 @@ +// @vitest-environment jsdom + +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Agent } from "@paperclipai/shared"; +import type { CompanyUserDirectoryResponse } from "../api/access"; +import type { PipelineDetail } from "../api/pipelines"; +import { agentsApi } from "../api/agents"; +import { accessApi } from "../api/access"; +import { pipelinesApi } from "../api/pipelines"; +import { ApiError } from "../api/client"; +import { PipelineSettings } from "./PipelineSettings"; + +// MarkdownEditor pulls in heavy Lexical/sandpack deps that crash jsdom at import. +// Mock it with a controllable textarea so tests can drive the instructions body +// (and the real RoutineVariablesEditor still syncs against it). +vi.mock("../components/MarkdownEditor", async () => { + const React = await import("react"); + return { + MarkdownEditor: React.forwardRef(({ + value, + onChange, + placeholder, + }: { + value: string; + onChange: (value: string) => void; + placeholder?: string; + }, ref) => { + const textareaRef = React.useRef(null); + React.useImperativeHandle(ref, () => ({ + focus: () => textareaRef.current?.focus(), + insertMarkdown: (markdown: string) => { + const textarea = textareaRef.current; + const start = textarea?.selectionStart ?? value.length; + const end = textarea?.selectionEnd ?? start; + onChange(`${value.slice(0, start)}${markdown}${value.slice(end)}`); + }, + }), [onChange, value]); + return ( +