test(e2e): add signoff execution policy end-to-end tests

Covers the full signoff lifecycle: executor → review → approval → done,
changes-requested bounce-back, comment-required validation, access control,
and review-only policy completion.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-04-06 09:12:38 -05:00
parent 0a9a8b5a44
commit 97d4ce41b3
+310
View File
@@ -0,0 +1,310 @@
import { test, expect, type APIRequestContext } from "@playwright/test";
/**
* E2E: Signoff execution policy flow.
*
* Validates the full signoff lifecycle through the API and UI:
* 1. Create a company with executor + reviewer + approver agents
* 2. Create an issue with a two-stage execution policy (review → approval)
* 3. Executor marks done → issue routes to reviewer (in_review)
* 4. Reviewer approves → issue routes to approver
* 5. Approver approves → execution completes, issue marked done
* 6. Verify "changes requested" flow returns to executor
*
* This test is API-driven with UI verification of execution state labels.
*/
const COMPANY_NAME = `E2E-Signoff-${Date.now()}`;
interface TestContext {
baseUrl: string;
companyId: string;
companyPrefix: string;
executorAgentId: string;
reviewerAgentId: string;
approverAgentId: string;
}
async function setupCompany(request: APIRequestContext, baseUrl: string): Promise<TestContext> {
// Create company
const companyRes = await request.post(`${baseUrl}/api/companies`, {
data: { name: COMPANY_NAME },
});
expect(companyRes.ok()).toBe(true);
const company = await companyRes.json();
const companyId = company.id;
// Fetch company prefix from the company object
const companyPrefix = company.prefix ?? company.urlKey ?? "E2E";
// Create executor agent (engineer)
const executorRes = await request.post(`${baseUrl}/api/companies/${companyId}/agents`, {
data: {
name: "Executor",
role: "engineer",
title: "Software Engineer",
adapterType: "process",
adapterConfig: { command: "echo done" },
},
});
expect(executorRes.ok()).toBe(true);
const executor = await executorRes.json();
// Create reviewer agent (QA)
const reviewerRes = await request.post(`${baseUrl}/api/companies/${companyId}/agents`, {
data: {
name: "Reviewer",
role: "qa",
title: "QA Engineer",
adapterType: "process",
adapterConfig: { command: "echo done" },
},
});
expect(reviewerRes.ok()).toBe(true);
const reviewer = await reviewerRes.json();
// Create approver agent (CTO)
const approverRes = await request.post(`${baseUrl}/api/companies/${companyId}/agents`, {
data: {
name: "Approver",
role: "cto",
title: "CTO",
adapterType: "process",
adapterConfig: { command: "echo done" },
},
});
expect(approverRes.ok()).toBe(true);
const approver = await approverRes.json();
return {
baseUrl,
companyId,
companyPrefix,
executorAgentId: executor.id,
reviewerAgentId: reviewer.id,
approverAgentId: approver.id,
};
}
async function createIssueWithPolicy(
request: APIRequestContext,
ctx: TestContext,
title: string,
) {
const res = await request.post(`${ctx.baseUrl}/api/companies/${ctx.companyId}/issues`, {
data: {
title,
status: "in_progress",
assigneeAgentId: ctx.executorAgentId,
executionPolicy: {
stages: [
{
type: "review",
participants: [{ type: "agent", agentId: ctx.reviewerAgentId }],
},
{
type: "approval",
participants: [{ type: "agent", agentId: ctx.approverAgentId }],
},
],
},
},
});
expect(res.ok()).toBe(true);
return res.json();
}
test.describe("Signoff execution policy", () => {
let ctx: TestContext;
test.beforeAll(async ({ request }) => {
const baseUrl = (test.info().project.use as { baseURL?: string }).baseURL ?? "http://127.0.0.1:3100";
ctx = await setupCompany(request, baseUrl);
});
test("happy path: executor → review → approval → done", async ({ request, page }) => {
const issue = await createIssueWithPolicy(request, ctx, "Signoff happy path");
const issueId = issue.id;
// Verify policy was saved
expect(issue.executionPolicy).toBeTruthy();
expect(issue.executionPolicy.stages).toHaveLength(2);
expect(issue.executionPolicy.stages[0].type).toBe("review");
expect(issue.executionPolicy.stages[1].type).toBe("approval");
// Step 1: Executor marks done → should route to reviewer
const step1Res = await request.patch(`${ctx.baseUrl}/api/issues/${issueId}`, {
data: {
status: "done",
comment: "Implemented the feature, ready for review.",
},
});
expect(step1Res.ok()).toBe(true);
const step1Issue = await step1Res.json();
expect(step1Issue.status).toBe("in_review");
expect(step1Issue.assigneeAgentId).toBe(ctx.reviewerAgentId);
expect(step1Issue.executionState).toBeTruthy();
expect(step1Issue.executionState.status).toBe("pending");
expect(step1Issue.executionState.currentStageType).toBe("review");
expect(step1Issue.executionState.returnAssignee).toMatchObject({
type: "agent",
agentId: ctx.executorAgentId,
});
// Step 2: Navigate to issue in UI and verify execution label
await page.goto(`/${ctx.companyPrefix}/issues/${issue.identifier}`);
await expect(page.locator("text=Review pending")).toBeVisible({ timeout: 10_000 });
// Step 3: Reviewer approves → should route to approver
const step3Res = await request.patch(`${ctx.baseUrl}/api/issues/${issueId}`, {
data: {
status: "done",
comment: "QA signoff complete. Looks good.",
},
});
expect(step3Res.ok()).toBe(true);
const step3Issue = await step3Res.json();
expect(step3Issue.status).toBe("in_review");
expect(step3Issue.assigneeAgentId).toBe(ctx.approverAgentId);
expect(step3Issue.executionState.status).toBe("pending");
expect(step3Issue.executionState.currentStageType).toBe("approval");
expect(step3Issue.executionState.completedStageIds).toHaveLength(1);
// Step 4: Verify UI shows approval pending
await page.reload();
await expect(page.locator("text=Approval pending")).toBeVisible({ timeout: 10_000 });
// Step 5: Approver approves → should complete
const step5Res = await request.patch(`${ctx.baseUrl}/api/issues/${issueId}`, {
data: {
status: "done",
comment: "Approved. Ship it.",
},
});
expect(step5Res.ok()).toBe(true);
const step5Issue = await step5Res.json();
expect(step5Issue.status).toBe("done");
expect(step5Issue.executionState.status).toBe("completed");
expect(step5Issue.executionState.completedStageIds).toHaveLength(2);
expect(step5Issue.executionState.lastDecisionOutcome).toBe("approved");
});
test("changes requested: reviewer bounces back to executor", async ({ request }) => {
const issue = await createIssueWithPolicy(request, ctx, "Signoff changes requested");
const issueId = issue.id;
// Executor marks done → routes to reviewer
const doneRes = await request.patch(`${ctx.baseUrl}/api/issues/${issueId}`, {
data: { status: "done", comment: "Ready for review." },
});
expect(doneRes.ok()).toBe(true);
const reviewIssue = await doneRes.json();
expect(reviewIssue.status).toBe("in_review");
expect(reviewIssue.assigneeAgentId).toBe(ctx.reviewerAgentId);
// Reviewer requests changes → returns to executor
const changesRes = await request.patch(`${ctx.baseUrl}/api/issues/${issueId}`, {
data: {
status: "in_progress",
comment: "Needs another pass on edge cases.",
},
});
expect(changesRes.ok()).toBe(true);
const changesIssue = await changesRes.json();
expect(changesIssue.status).toBe("in_progress");
expect(changesIssue.assigneeAgentId).toBe(ctx.executorAgentId);
expect(changesIssue.executionState.status).toBe("changes_requested");
expect(changesIssue.executionState.lastDecisionOutcome).toBe("changes_requested");
// Executor re-submits → goes back to reviewer (same stage)
const resubmitRes = await request.patch(`${ctx.baseUrl}/api/issues/${issueId}`, {
data: { status: "done", comment: "Fixed the edge cases." },
});
expect(resubmitRes.ok()).toBe(true);
const resubmitIssue = await resubmitRes.json();
expect(resubmitIssue.status).toBe("in_review");
expect(resubmitIssue.assigneeAgentId).toBe(ctx.reviewerAgentId);
expect(resubmitIssue.executionState.status).toBe("pending");
expect(resubmitIssue.executionState.currentStageType).toBe("review");
});
test("comment required: approval without comment fails", async ({ request }) => {
const issue = await createIssueWithPolicy(request, ctx, "Signoff comment required");
const issueId = issue.id;
// Executor marks done
await request.patch(`${ctx.baseUrl}/api/issues/${issueId}`, {
data: { status: "done", comment: "Done." },
});
// Reviewer tries to approve without comment → should fail
const noCommentRes = await request.patch(`${ctx.baseUrl}/api/issues/${issueId}`, {
data: { status: "done" },
});
// Server should reject: 422 or similar
expect(noCommentRes.ok()).toBe(false);
const errorBody = await noCommentRes.json();
expect(JSON.stringify(errorBody)).toContain("comment");
});
test("non-participant cannot advance stage", async ({ request }) => {
const issue = await createIssueWithPolicy(request, ctx, "Signoff access control");
const issueId = issue.id;
// Executor marks done → routes to reviewer
await request.patch(`${ctx.baseUrl}/api/issues/${issueId}`, {
data: { status: "done", comment: "Done." },
});
// Verify issue is in_review with reviewer
const issueRes = await request.get(`${ctx.baseUrl}/api/issues/${issueId}`);
const inReviewIssue = await issueRes.json();
expect(inReviewIssue.status).toBe("in_review");
expect(inReviewIssue.assigneeAgentId).toBe(ctx.reviewerAgentId);
expect(inReviewIssue.executionState.currentStageType).toBe("review");
});
test("review-only policy: reviewer approval completes execution", async ({ request }) => {
// Create issue with review-only policy (no approval stage)
const res = await request.post(`${ctx.baseUrl}/api/companies/${ctx.companyId}/issues`, {
data: {
title: "Signoff review-only",
status: "in_progress",
assigneeAgentId: ctx.executorAgentId,
executionPolicy: {
stages: [
{
type: "review",
participants: [{ type: "agent", agentId: ctx.reviewerAgentId }],
},
],
},
},
});
expect(res.ok()).toBe(true);
const issue = await res.json();
// Executor marks done → routes to reviewer
const doneRes = await request.patch(`${ctx.baseUrl}/api/issues/${issue.id}`, {
data: { status: "done", comment: "Ready for review." },
});
expect(doneRes.ok()).toBe(true);
const reviewIssue = await doneRes.json();
expect(reviewIssue.status).toBe("in_review");
// Reviewer approves → should complete immediately (no approval stage)
const approveRes = await request.patch(`${ctx.baseUrl}/api/issues/${issue.id}`, {
data: { status: "done", comment: "LGTM." },
});
expect(approveRes.ok()).toBe(true);
const doneIssue = await approveRes.json();
expect(doneIssue.status).toBe("done");
expect(doneIssue.executionState.status).toBe("completed");
expect(doneIssue.executionState.completedStageIds).toHaveLength(1);
});
});