Story 26: Establish TDD workflow and quality gates
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>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -30,3 +30,4 @@ server/target
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
/test-results/.last-run.json
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ This project is a standalone Rust **web server binary** that serves a Vite/React
|
|||||||
* **Framework:** Poem HTTP server with WebSocket support for streaming; HTTP APIs should use Poem OpenAPI (Swagger) for non-streaming endpoints.
|
* **Framework:** Poem HTTP server with WebSocket support for streaming; HTTP APIs should use Poem OpenAPI (Swagger) for non-streaming endpoints.
|
||||||
* **Frontend:** TypeScript + React
|
* **Frontend:** TypeScript + React
|
||||||
* **Build Tool:** Vite
|
* **Build Tool:** Vite
|
||||||
|
* **Package Manager:** pnpm (required)
|
||||||
* **Styling:** CSS Modules or Tailwind (TBD - Defaulting to CSS Modules)
|
* **Styling:** CSS Modules or Tailwind (TBD - Defaulting to CSS Modules)
|
||||||
* **State Management:** React Context / Hooks
|
* **State Management:** React Context / Hooks
|
||||||
* **Chat UI:** Rendered Markdown with syntax highlighting.
|
* **Chat UI:** Rendered Markdown with syntax highlighting.
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
name: Establish the TDD Workflow and Gates
|
||||||
|
test_plan: approved
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 26: Establish the TDD Workflow and Gates
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
As a user, I want a clear, enforceable TDD workflow with quality gates, so development is test-first and regressions are blocked.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- [ ] A test-first workflow is defined and enforced before implementation begins.
|
||||||
|
- [ ] Each story requires both unit tests and integration tests (standard Rust `tests/` layout).
|
||||||
|
- [ ] A test plan is produced and approved before any code changes.
|
||||||
|
- [ ] Stories cannot be accepted unless all required tests pass.
|
||||||
|
- [ ] The system warns when multiple tests fail and blocks acceptance until all required tests pass.
|
||||||
|
|
||||||
|
## Test Plan (Approved)
|
||||||
|
|
||||||
|
|
||||||
|
### Backend (Rust) — Unit + Integration
|
||||||
|
- AC1/AC3: Block write/exec when no approved test plan exists.
|
||||||
|
- AC2: Enforce presence of both unit + integration test categories before a story can proceed.
|
||||||
|
- AC4: Block story acceptance unless all required test results are passing.
|
||||||
|
- AC5: Allow only one failing test at a time (reject registering a second failure).
|
||||||
|
|
||||||
|
**Integration coverage:**
|
||||||
|
- Attempt to write before test plan approval → expect rejection.
|
||||||
|
- Add/approve test plan → write succeeds.
|
||||||
|
- Attempt acceptance with failing/missing tests → expect rejection.
|
||||||
|
- Acceptance with all passing tests → expect success.
|
||||||
|
- Register second failing test while one is red → expect rejection.
|
||||||
|
|
||||||
|
### Frontend (React) — Vitest + Playwright
|
||||||
|
- AC1/AC3: Gate status shown in story view; tools blocked until test plan approved.
|
||||||
|
- AC4: Acceptance action disabled when required tests are failing or missing.
|
||||||
|
- AC5: UI surfaces “red test count” and blocks when more than one failing test is present.
|
||||||
|
- E2E: Attempt blocked actions show a visible banner/toast and do not execute.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
- Backfilling tests for legacy code (covered by a separate story).
|
||||||
|
- Adding new test frameworks beyond those defined in `specs/tech/STACK.md`.
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# Story 26: Establish the TDD Workflow and Gates
|
|
||||||
|
|
||||||
## User Story
|
|
||||||
As a user, I want a clear, enforceable TDD workflow with quality gates, so development is test-first and regressions are blocked.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
- [ ] A test-first workflow is defined and enforced before implementation begins.
|
|
||||||
- [ ] Each story requires both unit tests and integration tests (standard Rust `tests/` layout).
|
|
||||||
- [ ] A test plan is produced and approved before any code changes.
|
|
||||||
- [ ] Stories cannot be accepted unless all required tests pass.
|
|
||||||
- [ ] Only one failing test is allowed at a time during red-green-refactor.
|
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
- Backfilling tests for legacy code (covered by a separate story).
|
|
||||||
- Adding new test frameworks beyond those defined in `specs/tech/STACK.md`.
|
|
||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1894,6 +1894,7 @@ dependencies = [
|
|||||||
"rust-embed",
|
"rust-embed",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_yaml",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"server": "cargo run --manifest-path server/Cargo.toml",
|
"server": "cargo run --manifest-path server/Cargo.toml",
|
||||||
"test": "jest"
|
"test": "vitest run",
|
||||||
|
"test:unit": "vitest run",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
@@ -18,15 +20,20 @@
|
|||||||
"react-syntax-highlighter": "^16.1.0"
|
"react-syntax-highlighter": "^16.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2.4.2",
|
||||||
|
"@playwright/test": "^1.47.2",
|
||||||
"@testing-library/jest-dom": "^6.0.0",
|
"@testing-library/jest-dom": "^6.0.0",
|
||||||
"@testing-library/react": "^14.0.0",
|
"@testing-library/react": "^14.0.0",
|
||||||
"@testing-library/user-event": "^14.4.3",
|
"@testing-library/user-event": "^14.4.3",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"@types/node": "^25.0.0",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
"jest": "^29.0.0",
|
"jest": "^29.0.0",
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
"ts-jest": "^29.0.0",
|
"ts-jest": "^29.0.0",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^7.0.4"
|
"vite": "^5.4.21",
|
||||||
|
"vitest": "^2.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
27
frontend/playwright.config.ts
Normal file
27
frontend/playwright.config.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { defineConfig } from "@playwright/test";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const configDir = dirname(fileURLToPath(new URL(import.meta.url)));
|
||||||
|
const frontendRoot = resolve(configDir, ".");
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests/e2e",
|
||||||
|
fullyParallel: true,
|
||||||
|
timeout: 30_000,
|
||||||
|
expect: {
|
||||||
|
timeout: 5_000,
|
||||||
|
},
|
||||||
|
use: {
|
||||||
|
baseURL: "http://127.0.0.1:41700",
|
||||||
|
trace: "on-first-retry",
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command:
|
||||||
|
"pnpm exec vite --config vite.config.ts --host 127.0.0.1 --port 41700 --strictPort",
|
||||||
|
url: "http://127.0.0.1:41700/@vite/client",
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 120_000,
|
||||||
|
cwd: frontendRoot,
|
||||||
|
},
|
||||||
|
});
|
||||||
1094
frontend/pnpm-lock.yaml
generated
1094
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
101
frontend/src/api/workflow.ts
Normal file
101
frontend/src/api/workflow.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
export type TestStatus = "pass" | "fail";
|
||||||
|
|
||||||
|
export interface TestCasePayload {
|
||||||
|
name: string;
|
||||||
|
status: TestStatus;
|
||||||
|
details?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordTestsPayload {
|
||||||
|
story_id: string;
|
||||||
|
unit: TestCasePayload[];
|
||||||
|
integration: TestCasePayload[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcceptanceRequest {
|
||||||
|
story_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestRunSummaryResponse {
|
||||||
|
total: number;
|
||||||
|
passed: number;
|
||||||
|
failed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcceptanceResponse {
|
||||||
|
can_accept: boolean;
|
||||||
|
reasons: string[];
|
||||||
|
warning?: string | null;
|
||||||
|
summary: TestRunSummaryResponse;
|
||||||
|
missing_categories: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewStory {
|
||||||
|
story_id: string;
|
||||||
|
can_accept: boolean;
|
||||||
|
reasons: string[];
|
||||||
|
warning?: string | null;
|
||||||
|
summary: TestRunSummaryResponse;
|
||||||
|
missing_categories: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewListResponse {
|
||||||
|
stories: ReviewStory[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_API_BASE = "/api";
|
||||||
|
|
||||||
|
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
|
||||||
|
return `${baseUrl}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
baseUrl = DEFAULT_API_BASE,
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await fetch(buildApiUrl(path, baseUrl), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(options.headers ?? {}),
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(text || `Request failed (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const workflowApi = {
|
||||||
|
recordTests(payload: RecordTestsPayload, baseUrl?: string) {
|
||||||
|
return requestJson<boolean>(
|
||||||
|
"/workflow/tests/record",
|
||||||
|
{ method: "POST", body: JSON.stringify(payload) },
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getAcceptance(payload: AcceptanceRequest, baseUrl?: string) {
|
||||||
|
return requestJson<AcceptanceResponse>(
|
||||||
|
"/workflow/acceptance",
|
||||||
|
{ method: "POST", body: JSON.stringify(payload) },
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getReviewQueue(baseUrl?: string) {
|
||||||
|
return requestJson<ReviewListResponse>("/workflow/review", {}, baseUrl);
|
||||||
|
},
|
||||||
|
getReviewQueueAll(baseUrl?: string) {
|
||||||
|
return requestJson<ReviewListResponse>("/workflow/review/all", {}, baseUrl);
|
||||||
|
},
|
||||||
|
ensureAcceptance(payload: AcceptanceRequest, baseUrl?: string) {
|
||||||
|
return requestJson<boolean>(
|
||||||
|
"/workflow/acceptance/ensure",
|
||||||
|
{ method: "POST", body: JSON.stringify(payload) },
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
393
frontend/src/components/Chat.test.tsx
Normal file
393
frontend/src/components/Chat.test.tsx
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import type { ReviewStory } from "../api/workflow";
|
||||||
|
import { workflowApi } from "../api/workflow";
|
||||||
|
import { Chat } from "./Chat";
|
||||||
|
|
||||||
|
vi.mock("../api/client", () => {
|
||||||
|
const api = {
|
||||||
|
getOllamaModels: vi.fn(),
|
||||||
|
getAnthropicApiKeyExists: vi.fn(),
|
||||||
|
getAnthropicModels: vi.fn(),
|
||||||
|
getModelPreference: vi.fn(),
|
||||||
|
setModelPreference: vi.fn(),
|
||||||
|
cancelChat: vi.fn(),
|
||||||
|
setAnthropicApiKey: vi.fn(),
|
||||||
|
};
|
||||||
|
class ChatWebSocket {
|
||||||
|
connect() {}
|
||||||
|
close() {}
|
||||||
|
sendChat() {}
|
||||||
|
cancel() {}
|
||||||
|
}
|
||||||
|
return { api, ChatWebSocket };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../api/workflow", () => {
|
||||||
|
return {
|
||||||
|
workflowApi: {
|
||||||
|
getAcceptance: vi.fn(),
|
||||||
|
getReviewQueue: vi.fn(),
|
||||||
|
getReviewQueueAll: vi.fn(),
|
||||||
|
ensureAcceptance: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockedApi = {
|
||||||
|
getOllamaModels: vi.mocked(api.getOllamaModels),
|
||||||
|
getAnthropicApiKeyExists: vi.mocked(api.getAnthropicApiKeyExists),
|
||||||
|
getAnthropicModels: vi.mocked(api.getAnthropicModels),
|
||||||
|
getModelPreference: vi.mocked(api.getModelPreference),
|
||||||
|
setModelPreference: vi.mocked(api.setModelPreference),
|
||||||
|
cancelChat: vi.mocked(api.cancelChat),
|
||||||
|
setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockedWorkflow = {
|
||||||
|
getAcceptance: vi.mocked(workflowApi.getAcceptance),
|
||||||
|
getReviewQueue: vi.mocked(workflowApi.getReviewQueue),
|
||||||
|
getReviewQueueAll: vi.mocked(workflowApi.getReviewQueueAll),
|
||||||
|
ensureAcceptance: vi.mocked(workflowApi.ensureAcceptance),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Chat review panel", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedApi.getOllamaModels.mockResolvedValue(["llama3.1"]);
|
||||||
|
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(true);
|
||||||
|
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
||||||
|
mockedApi.getModelPreference.mockResolvedValue(null);
|
||||||
|
mockedApi.setModelPreference.mockResolvedValue(true);
|
||||||
|
mockedApi.cancelChat.mockResolvedValue(true);
|
||||||
|
mockedApi.setAnthropicApiKey.mockResolvedValue(true);
|
||||||
|
|
||||||
|
mockedWorkflow.getAcceptance.mockResolvedValue({
|
||||||
|
can_accept: false,
|
||||||
|
reasons: ["No test results recorded for the story."],
|
||||||
|
warning: null,
|
||||||
|
summary: { total: 0, passed: 0, failed: 0 },
|
||||||
|
missing_categories: ["unit", "integration"],
|
||||||
|
});
|
||||||
|
mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] });
|
||||||
|
mockedWorkflow.ensureAcceptance.mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an empty review queue state", async () => {
|
||||||
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Stories Awaiting Review"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText("0 ready / 0 total")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
await screen.findByText("No stories waiting for review."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
const updatedLabels = await screen.findAllByText(/Updated/i);
|
||||||
|
expect(updatedLabels.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders review stories and proceeds", async () => {
|
||||||
|
const story: ReviewStory = {
|
||||||
|
story_id: "26_establish_tdd_workflow_and_gates",
|
||||||
|
can_accept: true,
|
||||||
|
reasons: [],
|
||||||
|
warning: null,
|
||||||
|
summary: { total: 3, passed: 3, failed: 0 },
|
||||||
|
missing_categories: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockedWorkflow.getReviewQueueAll
|
||||||
|
.mockResolvedValueOnce({ stories: [story] })
|
||||||
|
.mockResolvedValueOnce({ stories: [] });
|
||||||
|
|
||||||
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText(story.story_id)).toBeInTheDocument();
|
||||||
|
|
||||||
|
const proceedButton = screen.getByRole("button", { name: "Proceed" });
|
||||||
|
await userEvent.click(proceedButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedWorkflow.ensureAcceptance).toHaveBeenCalledWith({
|
||||||
|
story_id: story.story_id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("No stories waiting for review."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a review error when the queue fails to load", async () => {
|
||||||
|
mockedWorkflow.getReviewQueueAll.mockRejectedValueOnce(
|
||||||
|
new Error("Review queue failed"),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText(/Review queue failed/i)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
await screen.findByText(/Use Refresh to try again\./i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("button", { name: "Retry" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes the review queue when clicking refresh", async () => {
|
||||||
|
mockedWorkflow.getReviewQueueAll
|
||||||
|
.mockResolvedValueOnce({ stories: [] })
|
||||||
|
.mockResolvedValueOnce({ stories: [] });
|
||||||
|
|
||||||
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||||
|
|
||||||
|
const refreshButtons = await screen.findAllByRole("button", {
|
||||||
|
name: "Refresh",
|
||||||
|
});
|
||||||
|
const refreshButton = refreshButtons[0];
|
||||||
|
|
||||||
|
await userEvent.click(refreshButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedWorkflow.getReviewQueueAll).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables proceed when a story is blocked", async () => {
|
||||||
|
const story: ReviewStory = {
|
||||||
|
story_id: "26_establish_tdd_workflow_and_gates",
|
||||||
|
can_accept: false,
|
||||||
|
reasons: ["Missing unit tests"],
|
||||||
|
warning: null,
|
||||||
|
summary: { total: 1, passed: 0, failed: 1 },
|
||||||
|
missing_categories: ["unit"],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({
|
||||||
|
stories: [story],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText(story.story_id)).toBeInTheDocument();
|
||||||
|
|
||||||
|
const blockedButton = screen.getByRole("button", { name: "Blocked" });
|
||||||
|
expect(blockedButton).toBeDisabled();
|
||||||
|
|
||||||
|
expect(await screen.findByText("Missing: unit")).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText("Missing unit tests")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows gate panel blocked status with reasons (AC1/AC3)", async () => {
|
||||||
|
mockedWorkflow.getAcceptance.mockResolvedValueOnce({
|
||||||
|
can_accept: false,
|
||||||
|
reasons: ["No approved test plan for the story."],
|
||||||
|
warning: null,
|
||||||
|
summary: { total: 0, passed: 0, failed: 0 },
|
||||||
|
missing_categories: ["unit", "integration"],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Blocked")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
await screen.findByText("No approved test plan for the story."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Missing: unit, integration"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
await screen.findByText(/0\/0 passing, 0 failing/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows gate panel ready status when all tests pass (AC1/AC3)", async () => {
|
||||||
|
mockedWorkflow.getAcceptance.mockResolvedValueOnce({
|
||||||
|
can_accept: true,
|
||||||
|
reasons: [],
|
||||||
|
warning: null,
|
||||||
|
summary: { total: 5, passed: 5, failed: 0 },
|
||||||
|
missing_categories: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Ready to accept")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
await screen.findByText(/5\/5 passing, 0 failing/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows failing badge and count in review panel (AC4/AC5)", async () => {
|
||||||
|
const story: ReviewStory = {
|
||||||
|
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: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({
|
||||||
|
stories: [story],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Failing 3")).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText("Warning")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Multiple tests failing — fix one at a time."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText("3 tests are failing.")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
await screen.findByText(/2\/5 passing, 3 failing/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
const blockedButton = screen.getByRole("button", { name: "Blocked" });
|
||||||
|
expect(blockedButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows gate warning when multiple tests fail (AC5)", async () => {
|
||||||
|
mockedWorkflow.getAcceptance.mockResolvedValueOnce({
|
||||||
|
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: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Blocked")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Multiple tests failing — fix one at a time."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
await screen.findByText(/2\/4 passing, 2 failing/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText("2 tests are failing.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call ensureAcceptance when clicking a blocked proceed button (AC4)", async () => {
|
||||||
|
const story: ReviewStory = {
|
||||||
|
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: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({
|
||||||
|
stories: [story],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||||
|
|
||||||
|
const blockedButton = await screen.findByRole("button", {
|
||||||
|
name: "Blocked",
|
||||||
|
});
|
||||||
|
expect(blockedButton).toBeDisabled();
|
||||||
|
|
||||||
|
// Clear any prior calls then attempt click on disabled button
|
||||||
|
mockedWorkflow.ensureAcceptance.mockClear();
|
||||||
|
await userEvent.click(blockedButton);
|
||||||
|
|
||||||
|
expect(mockedWorkflow.ensureAcceptance).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows proceed error when ensureAcceptance fails", async () => {
|
||||||
|
const story: ReviewStory = {
|
||||||
|
story_id: "26_establish_tdd_workflow_and_gates",
|
||||||
|
can_accept: true,
|
||||||
|
reasons: [],
|
||||||
|
warning: null,
|
||||||
|
summary: { total: 3, passed: 3, failed: 0 },
|
||||||
|
missing_categories: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({
|
||||||
|
stories: [story],
|
||||||
|
});
|
||||||
|
mockedWorkflow.ensureAcceptance.mockRejectedValueOnce(
|
||||||
|
new Error("Acceptance blocked: tests still failing"),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||||
|
|
||||||
|
const proceedButton = await screen.findByRole("button", {
|
||||||
|
name: "Proceed",
|
||||||
|
});
|
||||||
|
await userEvent.click(proceedButton);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Acceptance blocked: tests still failing"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows gate error when acceptance endpoint fails", async () => {
|
||||||
|
mockedWorkflow.getAcceptance.mockRejectedValueOnce(
|
||||||
|
new Error("Server unreachable"),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Server unreachable")).toBeInTheDocument();
|
||||||
|
|
||||||
|
const retryButtons = await screen.findAllByRole("button", {
|
||||||
|
name: "Retry",
|
||||||
|
});
|
||||||
|
expect(retryButtons.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes gate status after proceeding on the current story", async () => {
|
||||||
|
const story: ReviewStory = {
|
||||||
|
story_id: "26_establish_tdd_workflow_and_gates",
|
||||||
|
can_accept: true,
|
||||||
|
reasons: [],
|
||||||
|
warning: null,
|
||||||
|
summary: { total: 2, passed: 2, failed: 0 },
|
||||||
|
missing_categories: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockedWorkflow.getAcceptance
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
can_accept: false,
|
||||||
|
reasons: ["No test results recorded for the story."],
|
||||||
|
warning: null,
|
||||||
|
summary: { total: 0, passed: 0, failed: 0 },
|
||||||
|
missing_categories: ["unit", "integration"],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
can_accept: true,
|
||||||
|
reasons: [],
|
||||||
|
warning: null,
|
||||||
|
summary: { total: 2, passed: 2, failed: 0 },
|
||||||
|
missing_categories: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
mockedWorkflow.getReviewQueueAll
|
||||||
|
.mockResolvedValueOnce({ stories: [story] })
|
||||||
|
.mockResolvedValueOnce({ stories: [] });
|
||||||
|
|
||||||
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||||
|
|
||||||
|
const proceedButton = await screen.findByRole("button", {
|
||||||
|
name: "Proceed",
|
||||||
|
});
|
||||||
|
await userEvent.click(proceedButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedWorkflow.ensureAcceptance).toHaveBeenCalledWith({
|
||||||
|
story_id: story.story_id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText("Ready to accept")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,12 @@ import Markdown from "react-markdown";
|
|||||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||||
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||||
import { api, ChatWebSocket } from "../api/client";
|
import { api, ChatWebSocket } from "../api/client";
|
||||||
|
import type { ReviewStory } from "../api/workflow";
|
||||||
|
import { workflowApi } from "../api/workflow";
|
||||||
import type { Message, ProviderConfig, ToolCall } from "../types";
|
import type { Message, ProviderConfig, ToolCall } from "../types";
|
||||||
|
import { ChatHeader } from "./ChatHeader";
|
||||||
|
import { GatePanel } from "./GatePanel";
|
||||||
|
import { ReviewPanel } from "./ReviewPanel";
|
||||||
|
|
||||||
const { useCallback, useEffect, useRef, useState } = React;
|
const { useCallback, useEffect, useRef, useState } = React;
|
||||||
|
|
||||||
@@ -12,6 +17,18 @@ interface ChatProps {
|
|||||||
onCloseProject: () => void;
|
onCloseProject: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GateState {
|
||||||
|
canAccept: boolean;
|
||||||
|
reasons: string[];
|
||||||
|
warning: string | null;
|
||||||
|
summary: {
|
||||||
|
total: number;
|
||||||
|
passed: number;
|
||||||
|
failed: number;
|
||||||
|
};
|
||||||
|
missingCategories: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
@@ -24,6 +41,31 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
const [showApiKeyDialog, setShowApiKeyDialog] = useState(false);
|
const [showApiKeyDialog, setShowApiKeyDialog] = useState(false);
|
||||||
const [apiKeyInput, setApiKeyInput] = useState("");
|
const [apiKeyInput, setApiKeyInput] = useState("");
|
||||||
const [hasAnthropicKey, setHasAnthropicKey] = useState(false);
|
const [hasAnthropicKey, setHasAnthropicKey] = useState(false);
|
||||||
|
const [gateState, setGateState] = useState<GateState | null>(null);
|
||||||
|
const [gateError, setGateError] = useState<string | null>(null);
|
||||||
|
const [isGateLoading, setIsGateLoading] = useState(false);
|
||||||
|
const [reviewQueue, setReviewQueue] = useState<ReviewStory[]>([]);
|
||||||
|
const [reviewError, setReviewError] = useState<string | null>(null);
|
||||||
|
const [isReviewLoading, setIsReviewLoading] = useState(false);
|
||||||
|
const [proceedingStoryId, setProceedingStoryId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [proceedError, setProceedError] = useState<string | null>(null);
|
||||||
|
const [proceedSuccess, setProceedSuccess] = useState<string | null>(null);
|
||||||
|
const [lastReviewRefresh, setLastReviewRefresh] = useState<Date | null>(null);
|
||||||
|
const [lastGateRefresh, setLastGateRefresh] = useState<Date | null>(null);
|
||||||
|
|
||||||
|
const storyId = "26_establish_tdd_workflow_and_gates";
|
||||||
|
const gateStatusColor = isGateLoading
|
||||||
|
? "#aaa"
|
||||||
|
: gateState?.canAccept
|
||||||
|
? "#7ee787"
|
||||||
|
: "#ff7b72";
|
||||||
|
const gateStatusLabel = isGateLoading
|
||||||
|
? "Checking..."
|
||||||
|
: gateState?.canAccept
|
||||||
|
? "Ready to accept"
|
||||||
|
: "Blocked";
|
||||||
|
|
||||||
const wsRef = useRef<ChatWebSocket | null>(null);
|
const wsRef = useRef<ChatWebSocket | null>(null);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -76,12 +118,6 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
|
|
||||||
const contextUsage = calculateContextUsage();
|
const contextUsage = calculateContextUsage();
|
||||||
|
|
||||||
const getContextEmoji = (percentage: number): string => {
|
|
||||||
if (percentage >= 90) return "🔴";
|
|
||||||
if (percentage >= 75) return "🟡";
|
|
||||||
return "🟢";
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api
|
api
|
||||||
.getOllamaModels()
|
.getOllamaModels()
|
||||||
@@ -134,6 +170,146 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
setIsGateLoading(true);
|
||||||
|
setGateError(null);
|
||||||
|
|
||||||
|
workflowApi
|
||||||
|
.getAcceptance({ story_id: storyId })
|
||||||
|
.then((response) => {
|
||||||
|
if (!active) return;
|
||||||
|
setGateState({
|
||||||
|
canAccept: response.can_accept,
|
||||||
|
reasons: response.reasons,
|
||||||
|
warning: response.warning ?? null,
|
||||||
|
summary: response.summary,
|
||||||
|
missingCategories: response.missing_categories,
|
||||||
|
});
|
||||||
|
setLastGateRefresh(new Date());
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!active) return;
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to load workflow gates.";
|
||||||
|
setGateError(message);
|
||||||
|
setGateState(null);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (active) {
|
||||||
|
setIsGateLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [storyId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
setIsReviewLoading(true);
|
||||||
|
setReviewError(null);
|
||||||
|
|
||||||
|
workflowApi
|
||||||
|
.getReviewQueueAll()
|
||||||
|
.then((response) => {
|
||||||
|
if (!active) return;
|
||||||
|
setReviewQueue(response.stories);
|
||||||
|
setLastReviewRefresh(new Date());
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!active) return;
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to load review queue.";
|
||||||
|
setReviewError(message);
|
||||||
|
setReviewQueue([]);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (active) {
|
||||||
|
setIsReviewLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshGateState = async (targetStoryId: string = storyId) => {
|
||||||
|
setIsGateLoading(true);
|
||||||
|
setGateError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await workflowApi.getAcceptance({
|
||||||
|
story_id: targetStoryId,
|
||||||
|
});
|
||||||
|
setGateState({
|
||||||
|
canAccept: response.can_accept,
|
||||||
|
reasons: response.reasons,
|
||||||
|
warning: response.warning ?? null,
|
||||||
|
summary: response.summary,
|
||||||
|
missingCategories: response.missing_categories,
|
||||||
|
});
|
||||||
|
setLastGateRefresh(new Date());
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to load workflow gates.";
|
||||||
|
setGateError(message);
|
||||||
|
setGateState(null);
|
||||||
|
} finally {
|
||||||
|
setIsGateLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshReviewQueue = async () => {
|
||||||
|
setIsReviewLoading(true);
|
||||||
|
setReviewError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await workflowApi.getReviewQueueAll();
|
||||||
|
setReviewQueue(response.stories);
|
||||||
|
setLastReviewRefresh(new Date());
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to load review queue.";
|
||||||
|
setReviewError(message);
|
||||||
|
setReviewQueue([]);
|
||||||
|
} finally {
|
||||||
|
setIsReviewLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProceed = async (storyIdToProceed: string) => {
|
||||||
|
setProceedingStoryId(storyIdToProceed);
|
||||||
|
setProceedError(null);
|
||||||
|
setProceedSuccess(null);
|
||||||
|
try {
|
||||||
|
await workflowApi.ensureAcceptance({
|
||||||
|
story_id: storyIdToProceed,
|
||||||
|
});
|
||||||
|
setProceedSuccess(`Proceeding with ${storyIdToProceed}.`);
|
||||||
|
await refreshReviewQueue();
|
||||||
|
if (storyIdToProceed === storyId) {
|
||||||
|
await refreshGateState(storyId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to proceed with review.";
|
||||||
|
setProceedError(message);
|
||||||
|
} finally {
|
||||||
|
setProceedingStoryId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ws = new ChatWebSocket();
|
const ws = new ChatWebSocket();
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
@@ -321,209 +497,61 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
color: "#ececec",
|
color: "#ececec",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<ChatHeader
|
||||||
|
projectPath={projectPath}
|
||||||
|
onCloseProject={onCloseProject}
|
||||||
|
contextUsage={contextUsage}
|
||||||
|
onClearSession={clearSession}
|
||||||
|
model={model}
|
||||||
|
availableModels={availableModels}
|
||||||
|
claudeModels={claudeModels}
|
||||||
|
hasAnthropicKey={hasAnthropicKey}
|
||||||
|
onModelChange={(newModel) => {
|
||||||
|
setModel(newModel);
|
||||||
|
api.setModelPreference(newModel).catch(console.error);
|
||||||
|
}}
|
||||||
|
enableTools={enableTools}
|
||||||
|
onToggleTools={setEnableTools}
|
||||||
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "12px 24px",
|
maxWidth: "768px",
|
||||||
borderBottom: "1px solid #333",
|
margin: "0 auto",
|
||||||
display: "flex",
|
width: "100%",
|
||||||
alignItems: "center",
|
padding: "12px 24px 0",
|
||||||
justifyContent: "space-between",
|
|
||||||
background: "#171717",
|
|
||||||
flexShrink: 0,
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
color: "#ececec",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
flexDirection: "column",
|
||||||
gap: "12px",
|
gap: "12px",
|
||||||
overflow: "hidden",
|
|
||||||
flex: 1,
|
|
||||||
marginRight: "20px",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<ReviewPanel
|
||||||
title={projectPath}
|
reviewQueue={reviewQueue}
|
||||||
style={{
|
isReviewLoading={isReviewLoading}
|
||||||
whiteSpace: "nowrap",
|
reviewError={reviewError}
|
||||||
overflow: "hidden",
|
proceedingStoryId={proceedingStoryId}
|
||||||
textOverflow: "ellipsis",
|
storyId={storyId}
|
||||||
fontWeight: "500",
|
isGateLoading={isGateLoading}
|
||||||
color: "#aaa",
|
proceedError={proceedError}
|
||||||
direction: "rtl",
|
proceedSuccess={proceedSuccess}
|
||||||
textAlign: "left",
|
lastReviewRefresh={lastReviewRefresh}
|
||||||
fontFamily: "monospace",
|
onRefresh={refreshReviewQueue}
|
||||||
fontSize: "0.85em",
|
onProceed={handleProceed}
|
||||||
}}
|
/>
|
||||||
>
|
|
||||||
{projectPath}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onCloseProject}
|
|
||||||
style={{
|
|
||||||
background: "transparent",
|
|
||||||
border: "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
color: "#999",
|
|
||||||
fontSize: "0.8em",
|
|
||||||
padding: "4px 8px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
}}
|
|
||||||
onMouseOver={(e) => {
|
|
||||||
e.currentTarget.style.background = "#333";
|
|
||||||
}}
|
|
||||||
onMouseOut={(e) => {
|
|
||||||
e.currentTarget.style.background = "transparent";
|
|
||||||
}}
|
|
||||||
onFocus={(e) => {
|
|
||||||
e.currentTarget.style.background = "#333";
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
e.currentTarget.style.background = "transparent";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
|
<GatePanel
|
||||||
<div
|
gateState={gateState}
|
||||||
style={{
|
gateStatusLabel={gateStatusLabel}
|
||||||
fontSize: "0.9em",
|
gateStatusColor={gateStatusColor}
|
||||||
color: "#ccc",
|
isGateLoading={isGateLoading}
|
||||||
whiteSpace: "nowrap",
|
gateError={gateError}
|
||||||
}}
|
lastGateRefresh={lastGateRefresh}
|
||||||
title={`Context: ${contextUsage.used.toLocaleString()} / ${contextUsage.total.toLocaleString()} tokens (${contextUsage.percentage}%)`}
|
onRefresh={() => refreshGateState(storyId)}
|
||||||
>
|
/>
|
||||||
{getContextEmoji(contextUsage.percentage)} {contextUsage.percentage}
|
|
||||||
%
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={clearSession}
|
|
||||||
style={{
|
|
||||||
padding: "6px 12px",
|
|
||||||
borderRadius: "99px",
|
|
||||||
border: "none",
|
|
||||||
fontSize: "0.85em",
|
|
||||||
backgroundColor: "#2f2f2f",
|
|
||||||
color: "#888",
|
|
||||||
cursor: "pointer",
|
|
||||||
outline: "none",
|
|
||||||
transition: "all 0.2s",
|
|
||||||
}}
|
|
||||||
onMouseOver={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "#3f3f3f";
|
|
||||||
e.currentTarget.style.color = "#ccc";
|
|
||||||
}}
|
|
||||||
onMouseOut={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "#2f2f2f";
|
|
||||||
e.currentTarget.style.color = "#888";
|
|
||||||
}}
|
|
||||||
onFocus={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "#3f3f3f";
|
|
||||||
e.currentTarget.style.color = "#ccc";
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "#2f2f2f";
|
|
||||||
e.currentTarget.style.color = "#888";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
🔄 New Session
|
|
||||||
</button>
|
|
||||||
{availableModels.length > 0 || claudeModels.length > 0 ? (
|
|
||||||
<select
|
|
||||||
value={model}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newModel = e.target.value;
|
|
||||||
setModel(newModel);
|
|
||||||
api.setModelPreference(newModel).catch(console.error);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
padding: "6px 32px 6px 16px",
|
|
||||||
borderRadius: "99px",
|
|
||||||
border: "none",
|
|
||||||
fontSize: "0.9em",
|
|
||||||
backgroundColor: "#2f2f2f",
|
|
||||||
color: "#ececec",
|
|
||||||
cursor: "pointer",
|
|
||||||
outline: "none",
|
|
||||||
appearance: "none",
|
|
||||||
WebkitAppearance: "none",
|
|
||||||
backgroundImage: `url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23ececec%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E")`,
|
|
||||||
backgroundRepeat: "no-repeat",
|
|
||||||
backgroundPosition: "right 12px center",
|
|
||||||
backgroundSize: "10px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(claudeModels.length > 0 || !hasAnthropicKey) && (
|
|
||||||
<optgroup label="Anthropic">
|
|
||||||
{claudeModels.length > 0 ? (
|
|
||||||
claudeModels.map((m: string) => (
|
|
||||||
<option key={m} value={m}>
|
|
||||||
{m}
|
|
||||||
</option>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<option value="" disabled>
|
|
||||||
Add Anthropic API key to load models
|
|
||||||
</option>
|
|
||||||
)}
|
|
||||||
</optgroup>
|
|
||||||
)}
|
|
||||||
{availableModels.length > 0 && (
|
|
||||||
<optgroup label="Ollama">
|
|
||||||
{availableModels.map((m: string) => (
|
|
||||||
<option key={m} value={m}>
|
|
||||||
{m}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
value={model}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newModel = e.target.value;
|
|
||||||
setModel(newModel);
|
|
||||||
api.setModelPreference(newModel).catch(console.error);
|
|
||||||
}}
|
|
||||||
placeholder="Model"
|
|
||||||
style={{
|
|
||||||
padding: "6px 12px",
|
|
||||||
borderRadius: "99px",
|
|
||||||
border: "none",
|
|
||||||
fontSize: "0.9em",
|
|
||||||
background: "#2f2f2f",
|
|
||||||
color: "#ececec",
|
|
||||||
outline: "none",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "6px",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "0.9em",
|
|
||||||
color: "#aaa",
|
|
||||||
}}
|
|
||||||
title="Allow the Agent to read/write files"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={enableTools}
|
|
||||||
onChange={(e) => setEnableTools(e.target.checked)}
|
|
||||||
style={{ accentColor: "#000" }}
|
|
||||||
/>
|
|
||||||
<span>Tools</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
242
frontend/src/components/ChatHeader.tsx
Normal file
242
frontend/src/components/ChatHeader.tsx
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
interface ContextUsage {
|
||||||
|
used: number;
|
||||||
|
total: number;
|
||||||
|
percentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatHeaderProps {
|
||||||
|
projectPath: string;
|
||||||
|
onCloseProject: () => void;
|
||||||
|
contextUsage: ContextUsage;
|
||||||
|
onClearSession: () => void;
|
||||||
|
model: string;
|
||||||
|
availableModels: string[];
|
||||||
|
claudeModels: string[];
|
||||||
|
hasAnthropicKey: boolean;
|
||||||
|
onModelChange: (model: string) => void;
|
||||||
|
enableTools: boolean;
|
||||||
|
onToggleTools: (enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getContextEmoji = (percentage: number): string => {
|
||||||
|
if (percentage >= 90) return "🔴";
|
||||||
|
if (percentage >= 75) return "🟡";
|
||||||
|
return "🟢";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChatHeader({
|
||||||
|
projectPath,
|
||||||
|
onCloseProject,
|
||||||
|
contextUsage,
|
||||||
|
onClearSession,
|
||||||
|
model,
|
||||||
|
availableModels,
|
||||||
|
claudeModels,
|
||||||
|
hasAnthropicKey,
|
||||||
|
onModelChange,
|
||||||
|
enableTools,
|
||||||
|
onToggleTools,
|
||||||
|
}: ChatHeaderProps) {
|
||||||
|
const hasModelOptions = availableModels.length > 0 || claudeModels.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "12px 24px",
|
||||||
|
borderBottom: "1px solid #333",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
background: "#171717",
|
||||||
|
flexShrink: 0,
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
color: "#ececec",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "12px",
|
||||||
|
overflow: "hidden",
|
||||||
|
flex: 1,
|
||||||
|
marginRight: "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
title={projectPath}
|
||||||
|
style={{
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
fontWeight: "500",
|
||||||
|
color: "#aaa",
|
||||||
|
direction: "rtl",
|
||||||
|
textAlign: "left",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "0.85em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{projectPath}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCloseProject}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "#999",
|
||||||
|
fontSize: "0.8em",
|
||||||
|
padding: "4px 8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => {
|
||||||
|
e.currentTarget.style.background = "#333";
|
||||||
|
}}
|
||||||
|
onMouseOut={(e) => {
|
||||||
|
e.currentTarget.style.background = "transparent";
|
||||||
|
}}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.currentTarget.style.background = "#333";
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.currentTarget.style.background = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.9em",
|
||||||
|
color: "#ccc",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
title={`Context: ${contextUsage.used.toLocaleString()} / ${contextUsage.total.toLocaleString()} tokens (${contextUsage.percentage}%)`}
|
||||||
|
>
|
||||||
|
{getContextEmoji(contextUsage.percentage)} {contextUsage.percentage}%
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClearSession}
|
||||||
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
|
borderRadius: "99px",
|
||||||
|
border: "none",
|
||||||
|
fontSize: "0.85em",
|
||||||
|
backgroundColor: "#2f2f2f",
|
||||||
|
color: "#888",
|
||||||
|
cursor: "pointer",
|
||||||
|
outline: "none",
|
||||||
|
transition: "all 0.2s",
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#3f3f3f";
|
||||||
|
e.currentTarget.style.color = "#ccc";
|
||||||
|
}}
|
||||||
|
onMouseOut={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#2f2f2f";
|
||||||
|
e.currentTarget.style.color = "#888";
|
||||||
|
}}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#3f3f3f";
|
||||||
|
e.currentTarget.style.color = "#ccc";
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#2f2f2f";
|
||||||
|
e.currentTarget.style.color = "#888";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔄 New Session
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{hasModelOptions ? (
|
||||||
|
<select
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => onModelChange(e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: "6px 32px 6px 16px",
|
||||||
|
borderRadius: "99px",
|
||||||
|
border: "none",
|
||||||
|
fontSize: "0.9em",
|
||||||
|
backgroundColor: "#2f2f2f",
|
||||||
|
color: "#ececec",
|
||||||
|
cursor: "pointer",
|
||||||
|
outline: "none",
|
||||||
|
appearance: "none",
|
||||||
|
WebkitAppearance: "none",
|
||||||
|
backgroundImage: `url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23ececec%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E")`,
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
|
backgroundPosition: "right 12px center",
|
||||||
|
backgroundSize: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(claudeModels.length > 0 || !hasAnthropicKey) && (
|
||||||
|
<optgroup label="Anthropic">
|
||||||
|
{claudeModels.length > 0 ? (
|
||||||
|
claudeModels.map((m: string) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{m}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<option value="" disabled>
|
||||||
|
Add Anthropic API key to load models
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
{availableModels.length > 0 && (
|
||||||
|
<optgroup label="Ollama">
|
||||||
|
{availableModels.map((m: string) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{m}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => onModelChange(e.target.value)}
|
||||||
|
placeholder="Model"
|
||||||
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
|
borderRadius: "99px",
|
||||||
|
border: "none",
|
||||||
|
fontSize: "0.9em",
|
||||||
|
background: "#2f2f2f",
|
||||||
|
color: "#ececec",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.9em",
|
||||||
|
color: "#aaa",
|
||||||
|
}}
|
||||||
|
title="Allow the Agent to read/write files"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enableTools}
|
||||||
|
onChange={(e) => onToggleTools(e.target.checked)}
|
||||||
|
style={{ accentColor: "#000" }}
|
||||||
|
/>
|
||||||
|
<span>Tools</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
182
frontend/src/components/GatePanel.tsx
Normal file
182
frontend/src/components/GatePanel.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
interface GateState {
|
||||||
|
canAccept: boolean;
|
||||||
|
reasons: string[];
|
||||||
|
warning: string | null;
|
||||||
|
summary: {
|
||||||
|
total: number;
|
||||||
|
passed: number;
|
||||||
|
failed: number;
|
||||||
|
};
|
||||||
|
missingCategories: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GatePanelProps {
|
||||||
|
gateState: GateState | null;
|
||||||
|
gateStatusLabel: string;
|
||||||
|
gateStatusColor: string;
|
||||||
|
isGateLoading: boolean;
|
||||||
|
gateError: string | null;
|
||||||
|
lastGateRefresh: Date | null;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTimestamp = (value: Date | null): string => {
|
||||||
|
if (!value) return "—";
|
||||||
|
return value.toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GatePanel({
|
||||||
|
gateState,
|
||||||
|
gateStatusLabel,
|
||||||
|
gateStatusColor,
|
||||||
|
isGateLoading,
|
||||||
|
gateError,
|
||||||
|
lastGateRefresh,
|
||||||
|
onRefresh,
|
||||||
|
}: GatePanelProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid #333",
|
||||||
|
borderRadius: "10px",
|
||||||
|
padding: "12px 16px",
|
||||||
|
background: "#1f1f1f",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600 }}>Workflow Gates</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={isGateLoading}
|
||||||
|
style={{
|
||||||
|
padding: "4px 10px",
|
||||||
|
borderRadius: "999px",
|
||||||
|
border: "1px solid #333",
|
||||||
|
background: isGateLoading ? "#2a2a2a" : "#2f2f2f",
|
||||||
|
color: isGateLoading ? "#777" : "#aaa",
|
||||||
|
cursor: isGateLoading ? "not-allowed" : "pointer",
|
||||||
|
fontSize: "0.75em",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
gap: "2px",
|
||||||
|
fontSize: "0.85em",
|
||||||
|
color: gateStatusColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>{gateStatusLabel}</div>
|
||||||
|
<div style={{ fontSize: "0.8em", color: "#777" }}>
|
||||||
|
Updated {formatTimestamp(lastGateRefresh)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isGateLoading ? (
|
||||||
|
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
|
||||||
|
Loading workflow gates...
|
||||||
|
</div>
|
||||||
|
) : gateError ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.85em",
|
||||||
|
color: "#ff7b72",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "8px",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{gateError}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={isGateLoading}
|
||||||
|
style={{
|
||||||
|
padding: "4px 10px",
|
||||||
|
borderRadius: "999px",
|
||||||
|
border: "1px solid #333",
|
||||||
|
background: isGateLoading ? "#2a2a2a" : "#2f2f2f",
|
||||||
|
color: isGateLoading ? "#777" : "#aaa",
|
||||||
|
cursor: isGateLoading ? "not-allowed" : "pointer",
|
||||||
|
fontSize: "0.75em",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : gateState ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
|
||||||
|
Summary: {gateState.summary.passed}/{gateState.summary.total}{" "}
|
||||||
|
passing, {gateState.summary.failed} failing
|
||||||
|
</div>
|
||||||
|
{gateState.missingCategories.length > 0 && (
|
||||||
|
<div style={{ fontSize: "0.85em", color: "#ffb86c" }}>
|
||||||
|
Missing: {gateState.missingCategories.join(", ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{gateState.warning && (
|
||||||
|
<div style={{ fontSize: "0.85em", color: "#ffb86c" }}>
|
||||||
|
{gateState.warning}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{gateState.reasons.length > 0 && (
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
margin: "0 0 0 16px",
|
||||||
|
padding: 0,
|
||||||
|
fontSize: "0.85em",
|
||||||
|
color: "#ccc",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{gateState.reasons.map((reason) => (
|
||||||
|
<li key={`gate-reason-${reason}`}>{reason}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
|
||||||
|
No workflow data yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
324
frontend/src/components/ReviewPanel.tsx
Normal file
324
frontend/src/components/ReviewPanel.tsx
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
import type { ReviewStory } from "../api/workflow";
|
||||||
|
|
||||||
|
interface ReviewPanelProps {
|
||||||
|
reviewQueue: ReviewStory[];
|
||||||
|
isReviewLoading: boolean;
|
||||||
|
reviewError: string | null;
|
||||||
|
proceedingStoryId: string | null;
|
||||||
|
storyId: string;
|
||||||
|
isGateLoading: boolean;
|
||||||
|
proceedError: string | null;
|
||||||
|
proceedSuccess: string | null;
|
||||||
|
lastReviewRefresh: Date | null;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onProceed: (storyId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTimestamp = (value: Date | null): string => {
|
||||||
|
if (!value) return "—";
|
||||||
|
return value.toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReviewPanel({
|
||||||
|
reviewQueue,
|
||||||
|
isReviewLoading,
|
||||||
|
reviewError,
|
||||||
|
proceedingStoryId,
|
||||||
|
storyId,
|
||||||
|
isGateLoading,
|
||||||
|
proceedError,
|
||||||
|
proceedSuccess,
|
||||||
|
lastReviewRefresh,
|
||||||
|
onRefresh,
|
||||||
|
onProceed,
|
||||||
|
}: ReviewPanelProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid #333",
|
||||||
|
borderRadius: "10px",
|
||||||
|
padding: "12px 16px",
|
||||||
|
background: "#1f1f1f",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600 }}>Stories Awaiting Review</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={isReviewLoading}
|
||||||
|
style={{
|
||||||
|
padding: "4px 10px",
|
||||||
|
borderRadius: "999px",
|
||||||
|
border: "1px solid #333",
|
||||||
|
background: isReviewLoading ? "#2a2a2a" : "#2f2f2f",
|
||||||
|
color: isReviewLoading ? "#777" : "#aaa",
|
||||||
|
cursor: isReviewLoading ? "not-allowed" : "pointer",
|
||||||
|
fontSize: "0.75em",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
gap: "2px",
|
||||||
|
fontSize: "0.85em",
|
||||||
|
color: "#aaa",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{reviewQueue.filter((story) => story.can_accept).length} ready /{" "}
|
||||||
|
{reviewQueue.length} total
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.8em", color: "#777" }}>
|
||||||
|
Updated {formatTimestamp(lastReviewRefresh)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isReviewLoading ? (
|
||||||
|
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
|
||||||
|
Loading review queue...
|
||||||
|
</div>
|
||||||
|
) : reviewError ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.85em",
|
||||||
|
color: "#ff7b72",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "8px",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{reviewError} Use Refresh to try again.</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={isReviewLoading}
|
||||||
|
style={{
|
||||||
|
padding: "4px 10px",
|
||||||
|
borderRadius: "999px",
|
||||||
|
border: "1px solid #333",
|
||||||
|
background: isReviewLoading ? "#2a2a2a" : "#2f2f2f",
|
||||||
|
color: isReviewLoading ? "#777" : "#aaa",
|
||||||
|
cursor: isReviewLoading ? "not-allowed" : "pointer",
|
||||||
|
fontSize: "0.75em",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : reviewQueue.length === 0 ? (
|
||||||
|
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
|
||||||
|
No stories waiting for review.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{reviewQueue.map((story) => (
|
||||||
|
<div
|
||||||
|
key={`review-${story.story_id}`}
|
||||||
|
style={{
|
||||||
|
border: "1px solid #2a2a2a",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "10px 12px",
|
||||||
|
background: "#191919",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "8px",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600 }}>{story.story_id}</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: "999px",
|
||||||
|
fontSize: "0.7em",
|
||||||
|
fontWeight: 600,
|
||||||
|
background: story.can_accept ? "#7ee787" : "#ff7b72",
|
||||||
|
color: story.can_accept ? "#000" : "#1a1a1a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{story.can_accept ? "Ready" : "Blocked"}
|
||||||
|
</span>
|
||||||
|
{story.summary.failed > 0 && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: "999px",
|
||||||
|
fontSize: "0.7em",
|
||||||
|
fontWeight: 600,
|
||||||
|
background: "#ffb86c",
|
||||||
|
color: "#1a1a1a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Failing {story.summary.failed}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{story.warning && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: "999px",
|
||||||
|
fontSize: "0.7em",
|
||||||
|
fontWeight: 600,
|
||||||
|
background: "#ffb86c",
|
||||||
|
color: "#1a1a1a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Warning
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{story.missing_categories.length > 0 && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: "999px",
|
||||||
|
fontSize: "0.7em",
|
||||||
|
fontWeight: 600,
|
||||||
|
background: "#3a2a1a",
|
||||||
|
color: "#ffb86c",
|
||||||
|
border: "1px solid #5a3a1a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Missing
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={
|
||||||
|
proceedingStoryId === story.story_id ||
|
||||||
|
isReviewLoading ||
|
||||||
|
(story.story_id === storyId && isGateLoading) ||
|
||||||
|
!story.can_accept
|
||||||
|
}
|
||||||
|
onClick={() => onProceed(story.story_id)}
|
||||||
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
border: "none",
|
||||||
|
background:
|
||||||
|
proceedingStoryId === story.story_id
|
||||||
|
? "#444"
|
||||||
|
: story.can_accept
|
||||||
|
? "#7ee787"
|
||||||
|
: "#333",
|
||||||
|
color:
|
||||||
|
proceedingStoryId === story.story_id
|
||||||
|
? "#bbb"
|
||||||
|
: story.can_accept
|
||||||
|
? "#000"
|
||||||
|
: "#aaa",
|
||||||
|
cursor:
|
||||||
|
proceedingStoryId === story.story_id || !story.can_accept
|
||||||
|
? "not-allowed"
|
||||||
|
: "pointer",
|
||||||
|
fontSize: "0.85em",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{proceedingStoryId === story.story_id
|
||||||
|
? "Proceeding..."
|
||||||
|
: story.can_accept
|
||||||
|
? "Proceed"
|
||||||
|
: "Blocked"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
|
||||||
|
Summary: {story.summary.passed}/{story.summary.total} passing,{" "}
|
||||||
|
{` ${story.summary.failed}`} failing
|
||||||
|
</div>
|
||||||
|
{story.missing_categories.length > 0 && (
|
||||||
|
<div style={{ fontSize: "0.85em", color: "#ffb86c" }}>
|
||||||
|
Missing: {story.missing_categories.join(", ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{story.reasons.length > 0 && (
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
margin: "0 0 0 16px",
|
||||||
|
padding: 0,
|
||||||
|
fontSize: "0.85em",
|
||||||
|
color: "#ccc",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{story.reasons.map((reason) => (
|
||||||
|
<li key={`review-reason-${story.story_id}-${reason}`}>
|
||||||
|
{reason}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{story.warning && (
|
||||||
|
<div style={{ fontSize: "0.85em", color: "#ffb86c" }}>
|
||||||
|
{story.warning}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{proceedError && (
|
||||||
|
<div style={{ fontSize: "0.85em", color: "#ff7b72" }}>
|
||||||
|
{proceedError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{proceedSuccess && (
|
||||||
|
<div style={{ fontSize: "0.85em", color: "#7ee787" }}>
|
||||||
|
{proceedSuccess}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
frontend/src/setupTests.ts
Normal file
1
frontend/src/setupTests.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import "@testing-library/jest-dom";
|
||||||
13
frontend/tests/e2e/review-panel.spec.ts
Normal file
13
frontend/tests/e2e/review-panel.spec.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("App boot smoke test", () => {
|
||||||
|
test("renders the project selection screen", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await expect(page.getByText("StorkIt")).toBeVisible();
|
||||||
|
await expect(page.getByPlaceholder("/path/to/project")).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Open Project" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
344
frontend/tests/e2e/tdd-gates.spec.ts
Normal file
344
frontend/tests/e2e/tdd-gates.spec.ts
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,6 +20,5 @@
|
|||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"]
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowSyntheticDefaultImports": true
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.ts", "playwright.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
13
frontend/vitest.config.ts
Normal file
13
frontend/vitest.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ["./src/setupTests.ts"],
|
||||||
|
css: true,
|
||||||
|
exclude: ["tests/e2e/**", "node_modules/**"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -21,6 +21,7 @@ eventsource-stream = { workspace = true }
|
|||||||
rust-embed = { workspace = true }
|
rust-embed = { workspace = true }
|
||||||
mime_guess = { workspace = true }
|
mime_guess = { workspace = true }
|
||||||
homedir = { workspace = true }
|
homedir = { workspace = true }
|
||||||
|
serde_yaml = "0.9"
|
||||||
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use crate::state::SessionState;
|
use crate::state::SessionState;
|
||||||
use crate::store::JsonFileStore;
|
use crate::store::JsonFileStore;
|
||||||
|
use crate::workflow::WorkflowState;
|
||||||
use poem::http::StatusCode;
|
use poem::http::StatusCode;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -7,6 +8,7 @@ use std::sync::Arc;
|
|||||||
pub struct AppContext {
|
pub struct AppContext {
|
||||||
pub state: Arc<SessionState>,
|
pub state: Arc<SessionState>,
|
||||||
pub store: Arc<JsonFileStore>,
|
pub store: Arc<JsonFileStore>,
|
||||||
|
pub workflow: Arc<std::sync::Mutex<WorkflowState>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type OpenApiResult<T> = poem::Result<T>;
|
pub type OpenApiResult<T> = poem::Result<T>;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ pub mod context;
|
|||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod io;
|
pub mod io;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
|
pub mod workflow;
|
||||||
|
|
||||||
pub mod project;
|
pub mod project;
|
||||||
pub mod ws;
|
pub mod ws;
|
||||||
@@ -19,6 +20,7 @@ use poem::{Route, get};
|
|||||||
use poem_openapi::OpenApiService;
|
use poem_openapi::OpenApiService;
|
||||||
use project::ProjectApi;
|
use project::ProjectApi;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use workflow::WorkflowApi;
|
||||||
|
|
||||||
pub fn build_routes(ctx: AppContext) -> impl poem::Endpoint {
|
pub fn build_routes(ctx: AppContext) -> impl poem::Endpoint {
|
||||||
let ctx_arc = std::sync::Arc::new(ctx);
|
let ctx_arc = std::sync::Arc::new(ctx);
|
||||||
@@ -36,7 +38,14 @@ pub fn build_routes(ctx: AppContext) -> impl poem::Endpoint {
|
|||||||
.data(ctx_arc)
|
.data(ctx_arc)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiTuple = (ProjectApi, ModelApi, AnthropicApi, IoApi, ChatApi);
|
type ApiTuple = (
|
||||||
|
ProjectApi,
|
||||||
|
ModelApi,
|
||||||
|
AnthropicApi,
|
||||||
|
IoApi,
|
||||||
|
ChatApi,
|
||||||
|
WorkflowApi,
|
||||||
|
);
|
||||||
|
|
||||||
type ApiService = OpenApiService<ApiTuple, ()>;
|
type ApiService = OpenApiService<ApiTuple, ()>;
|
||||||
|
|
||||||
@@ -48,6 +57,7 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
|
|||||||
AnthropicApi::new(ctx.clone()),
|
AnthropicApi::new(ctx.clone()),
|
||||||
IoApi { ctx: ctx.clone() },
|
IoApi { ctx: ctx.clone() },
|
||||||
ChatApi { ctx: ctx.clone() },
|
ChatApi { ctx: ctx.clone() },
|
||||||
|
WorkflowApi { ctx: ctx.clone() },
|
||||||
);
|
);
|
||||||
|
|
||||||
let api_service =
|
let api_service =
|
||||||
@@ -58,7 +68,8 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
|
|||||||
ModelApi { ctx: ctx.clone() },
|
ModelApi { ctx: ctx.clone() },
|
||||||
AnthropicApi::new(ctx.clone()),
|
AnthropicApi::new(ctx.clone()),
|
||||||
IoApi { ctx: ctx.clone() },
|
IoApi { ctx: ctx.clone() },
|
||||||
ChatApi { ctx },
|
ChatApi { ctx: ctx.clone() },
|
||||||
|
WorkflowApi { ctx },
|
||||||
);
|
);
|
||||||
|
|
||||||
let docs_service =
|
let docs_service =
|
||||||
|
|||||||
319
server/src/http/workflow.rs
Normal file
319
server/src/http/workflow.rs
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
||||||
|
use crate::io::story_metadata::{StoryMetadata, parse_front_matter};
|
||||||
|
use crate::workflow::{
|
||||||
|
StoryTestResults, TestCaseResult, TestStatus, evaluate_acceptance, summarize_results,
|
||||||
|
};
|
||||||
|
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::fs;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[derive(Tags)]
|
||||||
|
enum WorkflowTags {
|
||||||
|
Workflow,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Object)]
|
||||||
|
struct TestCasePayload {
|
||||||
|
pub name: String,
|
||||||
|
pub status: String,
|
||||||
|
pub details: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Object)]
|
||||||
|
struct RecordTestsPayload {
|
||||||
|
pub story_id: String,
|
||||||
|
pub unit: Vec<TestCasePayload>,
|
||||||
|
pub integration: Vec<TestCasePayload>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Object)]
|
||||||
|
struct AcceptanceRequest {
|
||||||
|
pub story_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Object)]
|
||||||
|
struct TestRunSummaryResponse {
|
||||||
|
pub total: usize,
|
||||||
|
pub passed: usize,
|
||||||
|
pub failed: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Object)]
|
||||||
|
struct AcceptanceResponse {
|
||||||
|
pub can_accept: bool,
|
||||||
|
pub reasons: Vec<String>,
|
||||||
|
pub warning: Option<String>,
|
||||||
|
pub summary: TestRunSummaryResponse,
|
||||||
|
pub missing_categories: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Object)]
|
||||||
|
struct ReviewStory {
|
||||||
|
pub story_id: String,
|
||||||
|
pub can_accept: bool,
|
||||||
|
pub reasons: Vec<String>,
|
||||||
|
pub warning: Option<String>,
|
||||||
|
pub summary: TestRunSummaryResponse,
|
||||||
|
pub missing_categories: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Object)]
|
||||||
|
struct ReviewListResponse {
|
||||||
|
pub stories: Vec<ReviewStory>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_current_story_metadata(ctx: &AppContext) -> Result<Vec<(String, StoryMetadata)>, String> {
|
||||||
|
let root = ctx.state.get_project_root()?;
|
||||||
|
let current_dir = root.join(".story_kit").join("stories").join("current");
|
||||||
|
|
||||||
|
if !current_dir.exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stories = Vec::new();
|
||||||
|
for entry in fs::read_dir(¤t_dir)
|
||||||
|
.map_err(|e| format!("Failed to read current stories directory: {e}"))?
|
||||||
|
{
|
||||||
|
let entry = entry.map_err(|e| format!("Failed to read current story entry: {e}"))?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let story_id = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|stem| stem.to_str())
|
||||||
|
.ok_or_else(|| "Invalid story file name.".to_string())?
|
||||||
|
.to_string();
|
||||||
|
let contents = fs::read_to_string(&path)
|
||||||
|
.map_err(|e| format!("Failed to read story file {}: {e}", path.display()))?;
|
||||||
|
let metadata = parse_front_matter(&contents)
|
||||||
|
.map_err(|e| format!("Failed to parse front matter for {story_id}: {e:?}"))?;
|
||||||
|
stories.push((story_id, metadata));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(stories)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_review_story(story_id: &str, results: &StoryTestResults) -> ReviewStory {
|
||||||
|
let decision = evaluate_acceptance(results);
|
||||||
|
let summary = summarize_results(results);
|
||||||
|
|
||||||
|
let mut missing_categories = Vec::new();
|
||||||
|
let mut reasons = decision.reasons;
|
||||||
|
|
||||||
|
if results.unit.is_empty() {
|
||||||
|
missing_categories.push("unit".to_string());
|
||||||
|
reasons.push("Missing unit test results.".to_string());
|
||||||
|
}
|
||||||
|
if results.integration.is_empty() {
|
||||||
|
missing_categories.push("integration".to_string());
|
||||||
|
reasons.push("Missing integration test results.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let can_accept = decision.can_accept && missing_categories.is_empty();
|
||||||
|
|
||||||
|
ReviewStory {
|
||||||
|
story_id: story_id.to_string(),
|
||||||
|
can_accept,
|
||||||
|
reasons,
|
||||||
|
warning: decision.warning,
|
||||||
|
summary: TestRunSummaryResponse {
|
||||||
|
total: summary.total,
|
||||||
|
passed: summary.passed,
|
||||||
|
failed: summary.failed,
|
||||||
|
},
|
||||||
|
missing_categories,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WorkflowApi {
|
||||||
|
pub ctx: Arc<AppContext>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OpenApi(tag = "WorkflowTags::Workflow")]
|
||||||
|
impl WorkflowApi {
|
||||||
|
/// Record test results for a story (unit + integration).
|
||||||
|
#[oai(path = "/workflow/tests/record", method = "post")]
|
||||||
|
async fn record_tests(&self, payload: Json<RecordTestsPayload>) -> OpenApiResult<Json<bool>> {
|
||||||
|
let unit = payload
|
||||||
|
.0
|
||||||
|
.unit
|
||||||
|
.into_iter()
|
||||||
|
.map(to_test_case)
|
||||||
|
.collect::<Result<Vec<_>, String>>()
|
||||||
|
.map_err(bad_request)?;
|
||||||
|
let integration = payload
|
||||||
|
.0
|
||||||
|
.integration
|
||||||
|
.into_iter()
|
||||||
|
.map(to_test_case)
|
||||||
|
.collect::<Result<Vec<_>, String>>()
|
||||||
|
.map_err(bad_request)?;
|
||||||
|
|
||||||
|
let mut workflow = self
|
||||||
|
.ctx
|
||||||
|
.workflow
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| bad_request(e.to_string()))?;
|
||||||
|
workflow
|
||||||
|
.record_test_results_validated(payload.0.story_id, unit, integration)
|
||||||
|
.map_err(bad_request)?;
|
||||||
|
|
||||||
|
Ok(Json(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluate acceptance readiness for a story.
|
||||||
|
#[oai(path = "/workflow/acceptance", method = "post")]
|
||||||
|
async fn acceptance(
|
||||||
|
&self,
|
||||||
|
payload: Json<AcceptanceRequest>,
|
||||||
|
) -> OpenApiResult<Json<AcceptanceResponse>> {
|
||||||
|
let results = {
|
||||||
|
let workflow = self
|
||||||
|
.ctx
|
||||||
|
.workflow
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| bad_request(e.to_string()))?;
|
||||||
|
workflow
|
||||||
|
.results
|
||||||
|
.get(&payload.0.story_id)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let decision = evaluate_acceptance(&results);
|
||||||
|
let summary = summarize_results(&results);
|
||||||
|
|
||||||
|
let mut missing_categories = Vec::new();
|
||||||
|
let mut reasons = decision.reasons;
|
||||||
|
|
||||||
|
if results.unit.is_empty() {
|
||||||
|
missing_categories.push("unit".to_string());
|
||||||
|
reasons.push("Missing unit test results.".to_string());
|
||||||
|
}
|
||||||
|
if results.integration.is_empty() {
|
||||||
|
missing_categories.push("integration".to_string());
|
||||||
|
reasons.push("Missing integration test results.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let can_accept = decision.can_accept && missing_categories.is_empty();
|
||||||
|
|
||||||
|
Ok(Json(AcceptanceResponse {
|
||||||
|
can_accept,
|
||||||
|
reasons,
|
||||||
|
warning: decision.warning,
|
||||||
|
summary: TestRunSummaryResponse {
|
||||||
|
total: summary.total,
|
||||||
|
passed: summary.passed,
|
||||||
|
failed: summary.failed,
|
||||||
|
},
|
||||||
|
missing_categories,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List stories that are ready for human review.
|
||||||
|
#[oai(path = "/workflow/review", method = "get")]
|
||||||
|
async fn review_queue(&self) -> OpenApiResult<Json<ReviewListResponse>> {
|
||||||
|
let stories = {
|
||||||
|
let workflow = self
|
||||||
|
.ctx
|
||||||
|
.workflow
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| bad_request(e.to_string()))?;
|
||||||
|
workflow
|
||||||
|
.results
|
||||||
|
.iter()
|
||||||
|
.map(|(story_id, results)| to_review_story(story_id, results))
|
||||||
|
.filter(|story| story.can_accept)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(ReviewListResponse { stories }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List stories in the review queue, including blocked items and current stories.
|
||||||
|
#[oai(path = "/workflow/review/all", method = "get")]
|
||||||
|
async fn review_queue_all(&self) -> OpenApiResult<Json<ReviewListResponse>> {
|
||||||
|
let current_stories =
|
||||||
|
load_current_story_metadata(self.ctx.as_ref()).map_err(bad_request)?;
|
||||||
|
let stories = {
|
||||||
|
let mut workflow = self
|
||||||
|
.ctx
|
||||||
|
.workflow
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| bad_request(e.to_string()))?;
|
||||||
|
|
||||||
|
if !current_stories.is_empty() {
|
||||||
|
workflow.load_story_metadata(current_stories);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut story_ids = BTreeSet::new();
|
||||||
|
|
||||||
|
for story_id in workflow.results.keys() {
|
||||||
|
story_ids.insert(story_id.clone());
|
||||||
|
}
|
||||||
|
for story_id in workflow.stories.keys() {
|
||||||
|
story_ids.insert(story_id.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
story_ids
|
||||||
|
.into_iter()
|
||||||
|
.map(|story_id| {
|
||||||
|
let results = workflow.results.get(&story_id).cloned().unwrap_or_default();
|
||||||
|
to_review_story(&story_id, &results)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(ReviewListResponse { stories }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure a story can be accepted; returns an error when gates fail.
|
||||||
|
#[oai(path = "/workflow/acceptance/ensure", method = "post")]
|
||||||
|
async fn ensure_acceptance(
|
||||||
|
&self,
|
||||||
|
payload: Json<AcceptanceRequest>,
|
||||||
|
) -> OpenApiResult<Json<bool>> {
|
||||||
|
let response = self.acceptance(payload).await?.0;
|
||||||
|
if response.can_accept {
|
||||||
|
return Ok(Json(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
if !response.reasons.is_empty() {
|
||||||
|
parts.push(response.reasons.join("; "));
|
||||||
|
}
|
||||||
|
if let Some(warning) = response.warning {
|
||||||
|
parts.push(warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = if parts.is_empty() {
|
||||||
|
"Acceptance is blocked.".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Acceptance is blocked: {}", parts.join("; "))
|
||||||
|
};
|
||||||
|
|
||||||
|
Err(bad_request(message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_test_case(input: TestCasePayload) -> Result<TestCaseResult, String> {
|
||||||
|
let status = parse_test_status(&input.status)?;
|
||||||
|
Ok(TestCaseResult {
|
||||||
|
name: input.name,
|
||||||
|
status,
|
||||||
|
details: input.details,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_test_status(value: &str) -> Result<TestStatus, String> {
|
||||||
|
match value {
|
||||||
|
"pass" => Ok(TestStatus::Pass),
|
||||||
|
"fail" => Ok(TestStatus::Fail),
|
||||||
|
other => Err(format!(
|
||||||
|
"Invalid test status '{other}'. Use 'pass' or 'fail'."
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::io::story_metadata::{TestPlanStatus, parse_front_matter};
|
||||||
use crate::state::SessionState;
|
use crate::state::SessionState;
|
||||||
use crate::store::StoreOps;
|
use crate::store::StoreOps;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -276,6 +277,7 @@ This project is a standalone Rust **web server binary** that serves a Vite/React
|
|||||||
* **Framework:** Poem HTTP server with WebSocket support for streaming; HTTP APIs should use Poem OpenAPI (Swagger) for non-streaming endpoints.
|
* **Framework:** Poem HTTP server with WebSocket support for streaming; HTTP APIs should use Poem OpenAPI (Swagger) for non-streaming endpoints.
|
||||||
* **Frontend:** TypeScript + React
|
* **Frontend:** TypeScript + React
|
||||||
* **Build Tool:** Vite
|
* **Build Tool:** Vite
|
||||||
|
* **Package Manager:** pnpm (required)
|
||||||
* **Styling:** CSS Modules or Tailwind (TBD - Defaulting to CSS Modules)
|
* **Styling:** CSS Modules or Tailwind (TBD - Defaulting to CSS Modules)
|
||||||
* **State Management:** React Context / Hooks
|
* **State Management:** React Context / Hooks
|
||||||
* **Chat UI:** Rendered Markdown with syntax highlighting.
|
* **Chat UI:** Rendered Markdown with syntax highlighting.
|
||||||
@@ -394,6 +396,34 @@ fn resolve_path_impl(root: PathBuf, relative_path: &str) -> Result<PathBuf, Stri
|
|||||||
Ok(root.join(relative_path))
|
Ok(root.join(relative_path))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_story_kit_path(path: &str) -> bool {
|
||||||
|
path == ".story_kit" || path.starts_with(".story_kit/")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_test_plan_approved(root: PathBuf) -> Result<(), String> {
|
||||||
|
let approved = tokio::task::spawn_blocking(move || {
|
||||||
|
let story_path = root
|
||||||
|
.join(".story_kit")
|
||||||
|
.join("stories")
|
||||||
|
.join("current")
|
||||||
|
.join("26_establish_tdd_workflow_and_gates.md");
|
||||||
|
let contents = fs::read_to_string(&story_path)
|
||||||
|
.map_err(|e| format!("Failed to read story file for test plan approval: {e}"))?;
|
||||||
|
let metadata = parse_front_matter(&contents)
|
||||||
|
.map_err(|e| format!("Failed to parse story front matter: {e:?}"))?;
|
||||||
|
|
||||||
|
Ok::<bool, String>(matches!(metadata.test_plan, Some(TestPlanStatus::Approved)))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Task failed: {e}"))??;
|
||||||
|
|
||||||
|
if approved {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Test plan is not approved for the current story.".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolves a relative path against the active project root.
|
/// Resolves a relative path against the active project root.
|
||||||
/// Returns error if no project is open or if path attempts traversal (..).
|
/// Returns error if no project is open or if path attempts traversal (..).
|
||||||
fn resolve_path(state: &SessionState, relative_path: &str) -> Result<PathBuf, String> {
|
fn resolve_path(state: &SessionState, relative_path: &str) -> Result<PathBuf, String> {
|
||||||
@@ -597,7 +627,11 @@ async fn write_file_impl(full_path: PathBuf, content: String) -> Result<(), Stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn write_file(path: String, content: String, state: &SessionState) -> Result<(), String> {
|
pub async fn write_file(path: String, content: String, state: &SessionState) -> Result<(), String> {
|
||||||
let full_path = resolve_path(state, &path)?;
|
let root = state.get_project_root()?;
|
||||||
|
if !is_story_kit_path(&path) {
|
||||||
|
ensure_test_plan_approved(root.clone()).await?;
|
||||||
|
}
|
||||||
|
let full_path = resolve_path_impl(root, &path)?;
|
||||||
write_file_impl(full_path, content).await
|
write_file_impl(full_path, content).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,3 +692,27 @@ pub async fn create_directory_absolute(path: String) -> Result<bool, String> {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Task failed: {}", e))?
|
.map_err(|e| format!("Task failed: {}", e))?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn write_file_requires_approved_test_plan() {
|
||||||
|
let dir = tempdir().expect("tempdir");
|
||||||
|
let state = SessionState::default();
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut root = state.project_root.lock().expect("lock project root");
|
||||||
|
*root = Some(dir.path().to_path_buf());
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = write_file("notes.txt".to_string(), "hello".to_string(), &state).await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"expected write to be blocked when test plan is not approved"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
pub mod fs;
|
pub mod fs;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
|
pub mod story_metadata;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
use crate::io::story_metadata::{TestPlanStatus, parse_front_matter};
|
||||||
use crate::state::SessionState;
|
use crate::state::SessionState;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
@@ -8,6 +10,30 @@ fn get_project_root(state: &SessionState) -> Result<PathBuf, String> {
|
|||||||
state.get_project_root()
|
state.get_project_root()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn ensure_test_plan_approved(root: PathBuf) -> Result<(), String> {
|
||||||
|
let approved = tokio::task::spawn_blocking(move || {
|
||||||
|
let story_path = root
|
||||||
|
.join(".story_kit")
|
||||||
|
.join("stories")
|
||||||
|
.join("current")
|
||||||
|
.join("26_establish_tdd_workflow_and_gates.md");
|
||||||
|
let contents = fs::read_to_string(&story_path)
|
||||||
|
.map_err(|e| format!("Failed to read story file for test plan approval: {e}"))?;
|
||||||
|
let metadata = parse_front_matter(&contents)
|
||||||
|
.map_err(|e| format!("Failed to parse story front matter: {e:?}"))?;
|
||||||
|
|
||||||
|
Ok::<bool, String>(matches!(metadata.test_plan, Some(TestPlanStatus::Approved)))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Task failed: {e}"))??;
|
||||||
|
|
||||||
|
if approved {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Test plan is not approved for the current story.".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Debug, poem_openapi::Object)]
|
#[derive(Serialize, Debug, poem_openapi::Object)]
|
||||||
pub struct CommandOutput {
|
pub struct CommandOutput {
|
||||||
pub stdout: String,
|
pub stdout: String,
|
||||||
@@ -54,5 +80,30 @@ pub async fn exec_shell(
|
|||||||
state: &SessionState,
|
state: &SessionState,
|
||||||
) -> Result<CommandOutput, String> {
|
) -> Result<CommandOutput, String> {
|
||||||
let root = get_project_root(state)?;
|
let root = get_project_root(state)?;
|
||||||
|
ensure_test_plan_approved(root.clone()).await?;
|
||||||
exec_shell_impl(command, args, root).await
|
exec_shell_impl(command, args, root).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn exec_shell_requires_approved_test_plan() {
|
||||||
|
let dir = tempdir().expect("tempdir");
|
||||||
|
let state = SessionState::default();
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut root = state.project_root.lock().expect("lock project root");
|
||||||
|
*root = Some(dir.path().to_path_buf());
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = exec_shell("ls".to_string(), Vec::new(), &state).await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"expected shell execution to be blocked when test plan is not approved"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
111
server/src/io/story_metadata.rs
Normal file
111
server/src/io/story_metadata.rs
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum TestPlanStatus {
|
||||||
|
Approved,
|
||||||
|
WaitingForApproval,
|
||||||
|
Unknown(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct StoryMetadata {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub test_plan: Option<TestPlanStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum StoryMetaError {
|
||||||
|
MissingFrontMatter,
|
||||||
|
InvalidFrontMatter(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct FrontMatter {
|
||||||
|
name: Option<String>,
|
||||||
|
test_plan: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_front_matter(contents: &str) -> Result<StoryMetadata, StoryMetaError> {
|
||||||
|
let mut lines = contents.lines();
|
||||||
|
|
||||||
|
let first = lines.next().unwrap_or_default().trim();
|
||||||
|
if first != "---" {
|
||||||
|
return Err(StoryMetaError::MissingFrontMatter);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut front_lines = Vec::new();
|
||||||
|
for line in &mut lines {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed == "---" {
|
||||||
|
let raw = front_lines.join("\n");
|
||||||
|
let front: FrontMatter = serde_yaml::from_str(&raw)
|
||||||
|
.map_err(|e| StoryMetaError::InvalidFrontMatter(e.to_string()))?;
|
||||||
|
return Ok(build_metadata(front));
|
||||||
|
}
|
||||||
|
front_lines.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(StoryMetaError::InvalidFrontMatter(
|
||||||
|
"Missing closing front matter delimiter".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_metadata(front: FrontMatter) -> StoryMetadata {
|
||||||
|
let test_plan = front.test_plan.as_deref().map(parse_test_plan_status);
|
||||||
|
|
||||||
|
StoryMetadata {
|
||||||
|
name: front.name,
|
||||||
|
test_plan,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_test_plan_status(value: &str) -> TestPlanStatus {
|
||||||
|
match value {
|
||||||
|
"approved" => TestPlanStatus::Approved,
|
||||||
|
"waiting_for_approval" => TestPlanStatus::WaitingForApproval,
|
||||||
|
other => TestPlanStatus::Unknown(other.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_front_matter_metadata() {
|
||||||
|
let input = r#"---
|
||||||
|
name: Establish the TDD Workflow and Gates
|
||||||
|
test_plan: approved
|
||||||
|
workflow: tdd
|
||||||
|
---
|
||||||
|
# Story 26
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let meta = parse_front_matter(input).expect("front matter");
|
||||||
|
assert_eq!(
|
||||||
|
meta,
|
||||||
|
StoryMetadata {
|
||||||
|
name: Some("Establish the TDD Workflow and Gates".to_string()),
|
||||||
|
test_plan: Some(TestPlanStatus::Approved),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_missing_front_matter() {
|
||||||
|
let input = "# Story 26\n";
|
||||||
|
assert_eq!(
|
||||||
|
parse_front_matter(input),
|
||||||
|
Err(StoryMetaError::MissingFrontMatter)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unclosed_front_matter() {
|
||||||
|
let input = "---\nname: Test\n";
|
||||||
|
assert!(matches!(
|
||||||
|
parse_front_matter(input),
|
||||||
|
Err(StoryMetaError::InvalidFrontMatter(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,13 @@ mod io;
|
|||||||
mod llm;
|
mod llm;
|
||||||
mod state;
|
mod state;
|
||||||
mod store;
|
mod store;
|
||||||
|
mod workflow;
|
||||||
|
|
||||||
use crate::http::build_routes;
|
use crate::http::build_routes;
|
||||||
use crate::http::context::AppContext;
|
use crate::http::context::AppContext;
|
||||||
use crate::state::SessionState;
|
use crate::state::SessionState;
|
||||||
use crate::store::JsonFileStore;
|
use crate::store::JsonFileStore;
|
||||||
|
use crate::workflow::WorkflowState;
|
||||||
use poem::Server;
|
use poem::Server;
|
||||||
use poem::listener::TcpListener;
|
use poem::listener::TcpListener;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -19,10 +21,12 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
let store = Arc::new(
|
let store = Arc::new(
|
||||||
JsonFileStore::from_path(PathBuf::from("store.json")).map_err(std::io::Error::other)?,
|
JsonFileStore::from_path(PathBuf::from("store.json")).map_err(std::io::Error::other)?,
|
||||||
);
|
);
|
||||||
|
let workflow = Arc::new(std::sync::Mutex::new(WorkflowState::default()));
|
||||||
|
|
||||||
let ctx = AppContext {
|
let ctx = AppContext {
|
||||||
state: app_state,
|
state: app_state,
|
||||||
store,
|
store,
|
||||||
|
workflow,
|
||||||
};
|
};
|
||||||
|
|
||||||
let app = build_routes(ctx);
|
let app = build_routes(ctx);
|
||||||
|
|||||||
242
server/src/workflow.rs
Normal file
242
server/src/workflow.rs
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
//! Workflow module: story gating and test result tracking.
|
||||||
|
//!
|
||||||
|
//! This module provides the in-memory primitives for:
|
||||||
|
//! - reading story metadata (front matter) for gating decisions
|
||||||
|
//! - tracking test run results
|
||||||
|
//! - evaluating acceptance readiness
|
||||||
|
//!
|
||||||
|
//! NOTE: This is a naive, local-only implementation that will be
|
||||||
|
//! refactored later into orchestration-aware components.
|
||||||
|
|
||||||
|
use crate::io::story_metadata::{StoryMetadata, TestPlanStatus};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum TestStatus {
|
||||||
|
Pass,
|
||||||
|
Fail,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct TestCaseResult {
|
||||||
|
pub name: String,
|
||||||
|
pub status: TestStatus,
|
||||||
|
pub details: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct TestRunSummary {
|
||||||
|
pub total: usize,
|
||||||
|
pub passed: usize,
|
||||||
|
pub failed: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct AcceptanceDecision {
|
||||||
|
pub can_accept: bool,
|
||||||
|
pub reasons: Vec<String>,
|
||||||
|
pub warning: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct StoryTestResults {
|
||||||
|
pub unit: Vec<TestCaseResult>,
|
||||||
|
pub integration: Vec<TestCaseResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct WorkflowState {
|
||||||
|
pub stories: HashMap<String, StoryMetadata>,
|
||||||
|
pub results: HashMap<String, StoryTestResults>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl WorkflowState {
|
||||||
|
pub fn upsert_story(&mut self, story_id: String, metadata: StoryMetadata) {
|
||||||
|
self.stories.insert(story_id, metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_story_metadata(&mut self, stories: Vec<(String, StoryMetadata)>) {
|
||||||
|
for (story_id, metadata) in stories {
|
||||||
|
self.stories.insert(story_id, metadata);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh_story_metadata(&mut self, story_id: String, metadata: StoryMetadata) -> bool {
|
||||||
|
match self.stories.get(&story_id) {
|
||||||
|
Some(existing) if existing == &metadata => false,
|
||||||
|
_ => {
|
||||||
|
self.stories.insert(story_id, metadata);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_test_results(
|
||||||
|
&mut self,
|
||||||
|
story_id: String,
|
||||||
|
unit: Vec<TestCaseResult>,
|
||||||
|
integration: Vec<TestCaseResult>,
|
||||||
|
) {
|
||||||
|
let _ = self.record_test_results_validated(story_id, unit, integration);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_test_results_validated(
|
||||||
|
&mut self,
|
||||||
|
story_id: String,
|
||||||
|
unit: Vec<TestCaseResult>,
|
||||||
|
integration: Vec<TestCaseResult>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let failures = unit
|
||||||
|
.iter()
|
||||||
|
.chain(integration.iter())
|
||||||
|
.filter(|test| test.status == TestStatus::Fail)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
if failures > 1 {
|
||||||
|
return Err(format!(
|
||||||
|
"Multiple failing tests detected ({failures}); register failures one at a time."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.results
|
||||||
|
.insert(story_id, StoryTestResults { unit, integration });
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn can_start_implementation(metadata: &StoryMetadata) -> Result<(), String> {
|
||||||
|
match metadata.test_plan {
|
||||||
|
Some(TestPlanStatus::Approved) => Ok(()),
|
||||||
|
Some(TestPlanStatus::WaitingForApproval) => {
|
||||||
|
Err("Test plan is waiting for approval; implementation is blocked.".to_string())
|
||||||
|
}
|
||||||
|
Some(TestPlanStatus::Unknown(ref value)) => Err(format!(
|
||||||
|
"Test plan state is unknown ({value}); implementation is blocked."
|
||||||
|
)),
|
||||||
|
None => Err("Missing test plan status; implementation is blocked.".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn summarize_results(results: &StoryTestResults) -> TestRunSummary {
|
||||||
|
let mut total = 0;
|
||||||
|
let mut passed = 0;
|
||||||
|
let mut failed = 0;
|
||||||
|
|
||||||
|
for test in results.unit.iter().chain(results.integration.iter()) {
|
||||||
|
total += 1;
|
||||||
|
match test.status {
|
||||||
|
TestStatus::Pass => passed += 1,
|
||||||
|
TestStatus::Fail => failed += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TestRunSummary {
|
||||||
|
total,
|
||||||
|
passed,
|
||||||
|
failed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn evaluate_acceptance(results: &StoryTestResults) -> AcceptanceDecision {
|
||||||
|
let summary = summarize_results(results);
|
||||||
|
|
||||||
|
if summary.failed == 0 && summary.total > 0 {
|
||||||
|
return AcceptanceDecision {
|
||||||
|
can_accept: true,
|
||||||
|
reasons: Vec::new(),
|
||||||
|
warning: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut reasons = Vec::new();
|
||||||
|
if summary.total == 0 {
|
||||||
|
reasons.push("No test results recorded for the story.".to_string());
|
||||||
|
}
|
||||||
|
if summary.failed > 0 {
|
||||||
|
reasons.push(format!(
|
||||||
|
"{} test(s) are failing; acceptance is blocked.",
|
||||||
|
summary.failed
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let warning = if summary.failed > 1 {
|
||||||
|
Some(format!(
|
||||||
|
"Multiple tests are failing ({} failures).",
|
||||||
|
summary.failed
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
AcceptanceDecision {
|
||||||
|
can_accept: false,
|
||||||
|
reasons,
|
||||||
|
warning,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn warns_when_multiple_tests_fail() {
|
||||||
|
let results = StoryTestResults {
|
||||||
|
unit: vec![
|
||||||
|
TestCaseResult {
|
||||||
|
name: "unit-1".to_string(),
|
||||||
|
status: TestStatus::Fail,
|
||||||
|
details: None,
|
||||||
|
},
|
||||||
|
TestCaseResult {
|
||||||
|
name: "unit-2".to_string(),
|
||||||
|
status: TestStatus::Fail,
|
||||||
|
details: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
integration: vec![TestCaseResult {
|
||||||
|
name: "integration-1".to_string(),
|
||||||
|
status: TestStatus::Pass,
|
||||||
|
details: None,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
let decision = evaluate_acceptance(&results);
|
||||||
|
|
||||||
|
assert!(!decision.can_accept);
|
||||||
|
assert_eq!(
|
||||||
|
decision.warning,
|
||||||
|
Some("Multiple tests are failing (2 failures).".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_recording_multiple_failures() {
|
||||||
|
let mut state = WorkflowState::default();
|
||||||
|
let unit = vec![
|
||||||
|
TestCaseResult {
|
||||||
|
name: "unit-1".to_string(),
|
||||||
|
status: TestStatus::Fail,
|
||||||
|
details: None,
|
||||||
|
},
|
||||||
|
TestCaseResult {
|
||||||
|
name: "unit-2".to_string(),
|
||||||
|
status: TestStatus::Fail,
|
||||||
|
details: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let integration = vec![TestCaseResult {
|
||||||
|
name: "integration-1".to_string(),
|
||||||
|
status: TestStatus::Pass,
|
||||||
|
details: None,
|
||||||
|
}];
|
||||||
|
|
||||||
|
let result = state.record_test_results_validated("story-26".to_string(), unit, integration);
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user