Add workflow engine with acceptance gates, test recording, and review queue. Frontend displays gate status (blocked/ready), test summaries, failing badges, and warnings. Proceed action is disabled when gates are not met. Includes 13 unit tests (Vitest) and 9 E2E tests (Playwright) covering all five acceptance criteria. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
345 lines
9.3 KiB
TypeScript
345 lines
9.3 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
import type {
|
|
AcceptanceResponse,
|
|
ReviewListResponse,
|
|
} from "../../src/api/workflow";
|
|
|
|
/**
|
|
* Helper: mock all API routes needed to render the Chat view.
|
|
* Accepts overrides for the workflow-specific endpoints.
|
|
*/
|
|
function mockChatApis(
|
|
page: import("@playwright/test").Page,
|
|
overrides: {
|
|
acceptance?: AcceptanceResponse;
|
|
reviewQueue?: ReviewListResponse;
|
|
ensureAcceptance?: { status: number; body: unknown };
|
|
} = {},
|
|
) {
|
|
const acceptance: AcceptanceResponse = overrides.acceptance ?? {
|
|
can_accept: false,
|
|
reasons: ["No test results recorded for the story."],
|
|
warning: null,
|
|
summary: { total: 0, passed: 0, failed: 0 },
|
|
missing_categories: ["unit", "integration"],
|
|
};
|
|
|
|
const reviewQueue: ReviewListResponse = overrides.reviewQueue ?? {
|
|
stories: [],
|
|
};
|
|
|
|
const ensureResp = overrides.ensureAcceptance ?? {
|
|
status: 200,
|
|
body: true,
|
|
};
|
|
|
|
return Promise.all([
|
|
// Selection screen APIs
|
|
page.route("**/api/projects", (route) =>
|
|
route.fulfill({ json: ["/tmp/test-project"] }),
|
|
),
|
|
page.route("**/api/io/fs/home", (route) => route.fulfill({ json: "/tmp" })),
|
|
page.route("**/api/project", (route) => {
|
|
if (route.request().method() === "POST") {
|
|
return route.fulfill({ json: "/tmp/test-project" });
|
|
}
|
|
if (route.request().method() === "DELETE") {
|
|
return route.fulfill({ json: true });
|
|
}
|
|
return route.fulfill({ json: null });
|
|
}),
|
|
// Chat init APIs
|
|
page.route("**/api/ollama/models**", (route) =>
|
|
route.fulfill({ json: ["llama3.1"] }),
|
|
),
|
|
page.route("**/api/anthropic/key/exists", (route) =>
|
|
route.fulfill({ json: false }),
|
|
),
|
|
page.route("**/api/anthropic/models", (route) =>
|
|
route.fulfill({ json: [] }),
|
|
),
|
|
page.route("**/api/model", (route) => {
|
|
if (route.request().method() === "POST") {
|
|
return route.fulfill({ json: true });
|
|
}
|
|
return route.fulfill({ json: null });
|
|
}),
|
|
// Workflow APIs
|
|
page.route("**/api/workflow/acceptance", (route) => {
|
|
if (route.request().url().includes("/ensure")) return route.fallback();
|
|
return route.fulfill({ json: acceptance });
|
|
}),
|
|
page.route("**/api/workflow/review/all", (route) =>
|
|
route.fulfill({ json: reviewQueue }),
|
|
),
|
|
page.route("**/api/workflow/acceptance/ensure", (route) =>
|
|
route.fulfill({
|
|
status: ensureResp.status,
|
|
json: ensureResp.body,
|
|
}),
|
|
),
|
|
page.route("**/api/io/fs/list/absolute**", (route) =>
|
|
route.fulfill({ json: [] }),
|
|
),
|
|
]);
|
|
}
|
|
|
|
/** Navigate past the selection screen into the Chat view. */
|
|
async function openProject(page: import("@playwright/test").Page) {
|
|
await page.goto("/");
|
|
await page.getByPlaceholder("/path/to/project").fill("/tmp/test-project");
|
|
await page.getByRole("button", { name: "Open Project" }).click();
|
|
// Wait for the Chat view to appear
|
|
await expect(page.getByText("Workflow Gates", { exact: true })).toBeVisible();
|
|
}
|
|
|
|
test.describe("TDD gate panel (AC1/AC3)", () => {
|
|
test("shows Blocked status with reasons when no test plan is approved", async ({
|
|
page,
|
|
}) => {
|
|
await mockChatApis(page, {
|
|
acceptance: {
|
|
can_accept: false,
|
|
reasons: ["No approved test plan for the story."],
|
|
warning: null,
|
|
summary: { total: 0, passed: 0, failed: 0 },
|
|
missing_categories: ["unit", "integration"],
|
|
},
|
|
});
|
|
|
|
await openProject(page);
|
|
|
|
await expect(
|
|
page.getByText("Workflow Gates", { exact: true }),
|
|
).toBeVisible();
|
|
await expect(page.getByText("Blocked").first()).toBeVisible();
|
|
await expect(
|
|
page.getByText("No approved test plan for the story."),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByText("Missing: unit, integration").first(),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test("shows Ready to accept when all gates pass", async ({ page }) => {
|
|
await mockChatApis(page, {
|
|
acceptance: {
|
|
can_accept: true,
|
|
reasons: [],
|
|
warning: null,
|
|
summary: { total: 5, passed: 5, failed: 0 },
|
|
missing_categories: [],
|
|
},
|
|
});
|
|
|
|
await openProject(page);
|
|
|
|
await expect(page.getByText("Ready to accept")).toBeVisible();
|
|
await expect(page.getByText(/5\/5 passing, 0 failing/)).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe("Acceptance blocked by failing tests (AC4)", () => {
|
|
test("Proceed button is disabled and shows Blocked when tests fail", async ({
|
|
page,
|
|
}) => {
|
|
await mockChatApis(page, {
|
|
acceptance: {
|
|
can_accept: false,
|
|
reasons: ["Tests are failing."],
|
|
warning: null,
|
|
summary: { total: 4, passed: 2, failed: 2 },
|
|
missing_categories: [],
|
|
},
|
|
reviewQueue: {
|
|
stories: [
|
|
{
|
|
story_id: "26_establish_tdd_workflow_and_gates",
|
|
can_accept: false,
|
|
reasons: ["Tests are failing."],
|
|
warning: null,
|
|
summary: { total: 4, passed: 2, failed: 2 },
|
|
missing_categories: [],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
await openProject(page);
|
|
|
|
const blockedButton = page.getByRole("button", { name: "Blocked" });
|
|
await expect(blockedButton).toBeVisible();
|
|
await expect(blockedButton).toBeDisabled();
|
|
|
|
await expect(page.getByText("Tests are failing.").first()).toBeVisible();
|
|
await expect(page.getByText(/2\/4 passing/).first()).toBeVisible();
|
|
});
|
|
|
|
test("Proceed button is disabled when missing test categories", async ({
|
|
page,
|
|
}) => {
|
|
await mockChatApis(page, {
|
|
reviewQueue: {
|
|
stories: [
|
|
{
|
|
story_id: "26_establish_tdd_workflow_and_gates",
|
|
can_accept: false,
|
|
reasons: ["Missing required test categories."],
|
|
warning: null,
|
|
summary: { total: 0, passed: 0, failed: 0 },
|
|
missing_categories: ["unit", "integration"],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
await openProject(page);
|
|
|
|
const blockedButton = page.getByRole("button", { name: "Blocked" });
|
|
await expect(blockedButton).toBeDisabled();
|
|
await expect(page.getByText("Missing").first()).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe("Red test count and warnings (AC5)", () => {
|
|
test("shows Failing badge with count when tests fail", async ({ page }) => {
|
|
await mockChatApis(page, {
|
|
reviewQueue: {
|
|
stories: [
|
|
{
|
|
story_id: "26_establish_tdd_workflow_and_gates",
|
|
can_accept: false,
|
|
reasons: ["3 tests are failing."],
|
|
warning: "Multiple tests failing — fix one at a time.",
|
|
summary: { total: 5, passed: 2, failed: 3 },
|
|
missing_categories: [],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
await openProject(page);
|
|
|
|
await expect(page.getByText("Failing 3")).toBeVisible();
|
|
await expect(page.getByText("Warning")).toBeVisible();
|
|
await expect(
|
|
page.getByText("Multiple tests failing — fix one at a time."),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test("gate panel shows warning for multiple failing tests", async ({
|
|
page,
|
|
}) => {
|
|
await mockChatApis(page, {
|
|
acceptance: {
|
|
can_accept: false,
|
|
reasons: ["2 tests are failing."],
|
|
warning: "Multiple tests failing — fix one at a time.",
|
|
summary: { total: 4, passed: 2, failed: 2 },
|
|
missing_categories: [],
|
|
},
|
|
});
|
|
|
|
await openProject(page);
|
|
|
|
await expect(
|
|
page.getByText("Multiple tests failing — fix one at a time."),
|
|
).toBeVisible();
|
|
await expect(page.getByText(/2\/4 passing, 2 failing/)).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe("Blocked actions do not execute (E2E)", () => {
|
|
test("clicking a blocked Proceed button does not call ensureAcceptance", async ({
|
|
page,
|
|
}) => {
|
|
let ensureCalled = false;
|
|
|
|
await mockChatApis(page, {
|
|
reviewQueue: {
|
|
stories: [
|
|
{
|
|
story_id: "26_establish_tdd_workflow_and_gates",
|
|
can_accept: false,
|
|
reasons: ["Tests are failing."],
|
|
warning: null,
|
|
summary: { total: 3, passed: 1, failed: 2 },
|
|
missing_categories: [],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
// Override the ensure route to track calls
|
|
await page.route("**/api/workflow/acceptance/ensure", (route) => {
|
|
ensureCalled = true;
|
|
return route.fulfill({ json: true });
|
|
});
|
|
|
|
await openProject(page);
|
|
|
|
const blockedButton = page.getByRole("button", { name: "Blocked" });
|
|
await expect(blockedButton).toBeDisabled();
|
|
|
|
// Force-click to attempt bypass — should still not fire
|
|
await blockedButton.click({ force: true });
|
|
|
|
// Give a moment for any potential network call
|
|
await page.waitForTimeout(500);
|
|
|
|
expect(ensureCalled).toBe(false);
|
|
});
|
|
|
|
test("ready story proceeds successfully", async ({ page }) => {
|
|
let ensureCalled = false;
|
|
|
|
await mockChatApis(page, {
|
|
acceptance: {
|
|
can_accept: true,
|
|
reasons: [],
|
|
warning: null,
|
|
summary: { total: 3, passed: 3, failed: 0 },
|
|
missing_categories: [],
|
|
},
|
|
reviewQueue: {
|
|
stories: [
|
|
{
|
|
story_id: "26_establish_tdd_workflow_and_gates",
|
|
can_accept: true,
|
|
reasons: [],
|
|
warning: null,
|
|
summary: { total: 3, passed: 3, failed: 0 },
|
|
missing_categories: [],
|
|
},
|
|
],
|
|
},
|
|
ensureAcceptance: { status: 200, body: true },
|
|
});
|
|
|
|
// Intercept ensure to track it was called
|
|
await page.route("**/api/workflow/acceptance/ensure", (route) => {
|
|
ensureCalled = true;
|
|
return route.fulfill({ json: true });
|
|
});
|
|
|
|
await openProject(page);
|
|
|
|
const proceedButton = page.getByRole("button", { name: "Proceed" });
|
|
await expect(proceedButton).toBeEnabled();
|
|
|
|
// Before clicking, swap review queue to return empty on next fetch
|
|
await page.unroute("**/api/workflow/review/all");
|
|
await page.route("**/api/workflow/review/all", (route) =>
|
|
route.fulfill({ json: { stories: [] } }),
|
|
);
|
|
|
|
await proceedButton.click();
|
|
|
|
// After proceed, the review queue refreshes — now returns empty
|
|
await expect(
|
|
page.getByText("No stories waiting for review."),
|
|
).toBeVisible();
|
|
expect(ensureCalled).toBe(true);
|
|
});
|
|
});
|