Merge pull request #19: Damascus Entry Points P5: damascus-ui v2 (ingest + 4 widgets + project-grouped dashboard)
Some checks failed
test / contract-and-unit (push) Failing after 15s
Some checks failed
test / contract-and-unit (push) Failing after 15s
This commit is contained in:
863
docs/plans/2026-06-24-p5-damascus-ui-v2.md
Normal file
863
docs/plans/2026-06-24-p5-damascus-ui-v2.md
Normal file
@@ -0,0 +1,863 @@
|
||||
# Damascus Entry Points P5: damascus-ui v2 — Implementation Plan
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development OR execute directly. TDD: tests first.
|
||||
|
||||
**Goal:** Add the ingest form (`/ingest`), answer form (in drawer), project-grouped dashboard, and the four "self-improving" widgets from contract §7. End state: `npm run build && npm run test:e2e` exit 0; mobile viewport passes; PR open.
|
||||
|
||||
**Architecture:** Extend the v1 SPA at `ui/` (React 19 + Vite 6 + MUI 6 + React Query). All new server interaction uses the existing `api/client.ts` POST wrapper extended with `Authorization: Bearer <DAMASCUS_API_TOKEN>`. New `/ingest` route and new `widgets/` subfolder. The e2e fixture API (used because no live damascus-api runs in CI) is extended to accept the new POST endpoints and the new `?group_by=project` query param. The Pydantic schema gets a minimal `ListItemsQuery.group_by` extension (with a new `GroupedItemsResponse` shape) and the contract page gets one row in §3 and one bullet in §8.
|
||||
|
||||
**Tech Stack:** TypeScript, React 19, Vite 6, MUI 6, React Query 5, react-router-free hash router, Playwright 1.61. Pydantic v2 (api_schemas.py), FastAPI (fixture_api.py).
|
||||
|
||||
---
|
||||
|
||||
## 0. Background reading (must do before coding)
|
||||
|
||||
- `ui/src/router.ts` — hash router API (`useRoute`, `navigate`, `setOpenItem`)
|
||||
- `ui/src/api/client.ts` — fetch wrapper (no auth header in v1)
|
||||
- `ui/src/api/queries.ts` — React Query hooks; `useStats` (5s polling) is the model for the live widgets
|
||||
- `ui/src/types.ts` — TS mirrors of Pydantic schemas
|
||||
- `ui/src/routes/Dashboard.tsx` — current v1 dashboard, already renders phase bar inline
|
||||
- `ui/src/routes/ItemDrawer.tsx` — current v1 drawer, has open_issues + recent_events sections
|
||||
- `ui/src/main.tsx` — theme + QueryClient
|
||||
- `src/damascus/api_schemas.py` — `IngestStoryRequest`, `AnswerIssueRequest`, `CostSummaryResponse`
|
||||
- `wiki/concepts/entry-points-contract.md` §3 (query params) and §7 (4 widgets)
|
||||
- `ui/tests/e2e/fixture_api.py` — local FastAPI the e2e suite runs against
|
||||
|
||||
## 1. Slice ordering
|
||||
|
||||
Each slice is a self-contained, committable increment. The plan goes widget-by-widget (4 small slices) before doing the larger route work (Ingest, project-grouped Dashboard, answer form), then the e2e suite last. Reason: widget work is small and isolated, and the test file for v2 is much easier to write once all the new pieces exist.
|
||||
|
||||
| # | Slice | Approx. files |
|
||||
|---|-------|---------------|
|
||||
| A | API types + client auth + React Query hooks | 3 |
|
||||
| B | Extend fixture_api.py for the new endpoints | 1 |
|
||||
| C | PhaseBar widget (live, polled) | 1 |
|
||||
| D | OpenIssues widget (count + last 5 inline) | 1 |
|
||||
| E | BlockedItems widget (last_verdict + last_feedback) | 1 |
|
||||
| F | CostSparkline widget (SVG, 7 days) | 1 |
|
||||
| G | Extend Dashboard to project-grouped + mount 4 widgets | 1 |
|
||||
| H | Extend ItemDrawer with answer form | 1 |
|
||||
| I | Router + App: add `/ingest` route | 2 |
|
||||
| J | Ingest form route | 1 |
|
||||
| K | Contract: §3 group_by row + §8 P5 note | 1 |
|
||||
| L | api_schemas.py: group_by + GroupedItemsResponse | 1 |
|
||||
| M | Playwright e2e: 3 scenarios (ingest, dashboard widgets, answer) | 1 |
|
||||
| N | Build, test:e2e, mobile viewport, commit, PR | — |
|
||||
|
||||
Each task below is 2–5 min. Every UI change ships with a test (component test in `tests/` is optional for widgets; the widget behavior is exercised by the Playwright suite at slice M). Every testable API helper gets a vitest unit test (we add a `vitest.config.ts` if there isn't one — see Task A3).
|
||||
|
||||
---
|
||||
|
||||
## Task A1: Extend ui/src/types.ts with the new Pydantic mirrors
|
||||
|
||||
**Files:** `ui/src/types.ts` (modify)
|
||||
|
||||
Add:
|
||||
```ts
|
||||
export interface IngestStoryRequest {
|
||||
project: string; // 1..64
|
||||
story_id: string; // 1..128
|
||||
title: string; // 1..255
|
||||
file_scope: string[]; // default []
|
||||
priority: number; // 0..1000, default 100
|
||||
budget_cycles: number; // 1..10, default 3
|
||||
}
|
||||
|
||||
export interface IngestStoryResponse {
|
||||
item: WorkItem;
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
export interface AnswerIssueRequest {
|
||||
answer: string; // 1..10_000
|
||||
}
|
||||
|
||||
export interface AnswerIssueResponse {
|
||||
id: string;
|
||||
work_item_id: string;
|
||||
question: string;
|
||||
answer: string;
|
||||
status: IssueStatus;
|
||||
created_at: string;
|
||||
answered_at: string;
|
||||
}
|
||||
|
||||
export interface CostDay {
|
||||
date: string; // YYYY-MM-DD
|
||||
usd: string; // serialized Decimal
|
||||
}
|
||||
|
||||
export interface CostSummaryResponse {
|
||||
total_usd: string;
|
||||
by_project: Record<string, string>;
|
||||
by_model: Record<string, string>;
|
||||
by_day: Record<string, string>;
|
||||
window_start: string;
|
||||
window_end: string;
|
||||
}
|
||||
|
||||
export interface ProjectGroup {
|
||||
project: string;
|
||||
items: WorkItem[];
|
||||
phase_counts: Record<WorkItemPhase, number>;
|
||||
}
|
||||
|
||||
export interface GroupedItemsResponse {
|
||||
groups: ProjectGroup[];
|
||||
total_items: number;
|
||||
total_projects: number;
|
||||
}
|
||||
```
|
||||
|
||||
Add `group_by` to `ListItemsQueryParams`:
|
||||
```ts
|
||||
group_by?: "project"; // currently only one valid value
|
||||
```
|
||||
|
||||
**Verify:** `cd ui && npm run typecheck` exits 0.
|
||||
|
||||
**Commit:** `types(ui): add IngestStoryRequest/Response, AnswerIssueRequest/Response, CostSummaryResponse, ProjectGroup (P5)`
|
||||
|
||||
---
|
||||
|
||||
## Task A2: Extend ui/src/api/client.ts with POST + Authorization
|
||||
|
||||
**Files:** `ui/src/api/client.ts` (modify)
|
||||
|
||||
The v1 `api.post` exists but does NOT send an auth header. The contract says writes need `Authorization: Bearer <DAMASCUS_API_TOKEN>`, baked at build time (LAN-trusted). Add:
|
||||
|
||||
```ts
|
||||
const WRITE_TOKEN =
|
||||
(import.meta.env.VITE_API_WRITE_TOKEN as string | undefined) ?? "";
|
||||
```
|
||||
|
||||
In the `request()` function, when `body !== undefined` and `WRITE_TOKEN` is non-empty, set:
|
||||
```ts
|
||||
(init.headers as Record<string, string>)["Authorization"] = `Bearer ${WRITE_TOKEN}`;
|
||||
```
|
||||
|
||||
Keep the existing GET behavior unchanged. No new exports.
|
||||
|
||||
**Verify:** `npm run typecheck` exits 0.
|
||||
|
||||
**Commit:** `feat(ui): api client sends Authorization on writes (P5)`
|
||||
|
||||
---
|
||||
|
||||
## Task A3: Add vitest config + first unit test (RED-GREEN-REFACTOR)
|
||||
|
||||
**Files:**
|
||||
- `ui/vitest.config.ts` (create)
|
||||
- `ui/tests/unit/api_client.test.ts` (create)
|
||||
|
||||
**Why:** The TDD skill is "tests before code." Slice A's API client change (the Authorization header) is best tested with vitest. Without a unit test, the Authorization behavior ships untested until the Playwright suite at the end — too late to refactor safely.
|
||||
|
||||
**Step 1 — Write failing test** (`tests/unit/api_client.test.ts`):
|
||||
|
||||
```ts
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { ApiError } from "../../src/api/client";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("api client auth", () => {
|
||||
it("sends Authorization Bearer header on POST when VITE_API_WRITE_TOKEN is set", async () => {
|
||||
vi.stubEnv("VITE_API_WRITE_TOKEN", "test-token-abc");
|
||||
const { api } = await import("../../src/api/client");
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ok: true }),
|
||||
});
|
||||
await api.post("/v1/items", { project: "p", story_id: "s", title: "t" });
|
||||
const [, init] = mockFetch.mock.calls[0];
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer test-token-abc");
|
||||
expect((init.headers as Record<string, string>)["Content-Type"]).toBe("application/json");
|
||||
});
|
||||
|
||||
it("does NOT send Authorization on GET even when token is set", async () => {
|
||||
vi.stubEnv("VITE_API_WRITE_TOKEN", "test-token-abc");
|
||||
const { api } = await import("../../src/api/client");
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ items: [], total: 0, limit: 50, offset: 0 }),
|
||||
});
|
||||
await api.get("/v1/items");
|
||||
const [, init] = mockFetch.mock.calls[0];
|
||||
expect((init.headers as Record<string, string>).Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits Authorization on POST when token is empty (read-only deployments)", async () => {
|
||||
vi.stubEnv("VITE_API_WRITE_TOKEN", "");
|
||||
const { api } = await import("../../src/api/client");
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ok: true }),
|
||||
});
|
||||
await api.post("/v1/items", { project: "p", story_id: "s", title: "t" });
|
||||
const [, init] = mockFetch.mock.calls[0];
|
||||
expect((init.headers as Record<string, string>).Authorization).toBeUndefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2 — Run, expect failure:** `cd ui && npx vitest run tests/unit/api_client.test.ts` — should fail because `vitest.config.ts` doesn't exist yet OR the test fails for missing module path. Either way, RED.
|
||||
|
||||
**Step 3 — Create `vitest.config.ts`:**
|
||||
|
||||
```ts
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["tests/unit/**/*.test.ts", "tests/unit/**/*.test.tsx"],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Also add `"test:unit": "vitest run"` to `ui/package.json` scripts.
|
||||
|
||||
**Step 4 — Run again, expect failure for the right reason** (client doesn't yet send Authorization). RED.
|
||||
|
||||
**Step 5 — Implement the Authorization header in client.ts** (the A2 code). GREEN.
|
||||
|
||||
**Step 6 — Refactor:** extract the header construction into a small helper if it's getting crowded. Tests stay green.
|
||||
|
||||
**Verify:** `cd ui && npm run test:unit` — 3 pass.
|
||||
|
||||
**Commit:** `test(ui): api client auth unit tests + vitest config (P5)`
|
||||
|
||||
---
|
||||
|
||||
## Task A4: Add React Query hooks in api/queries.ts
|
||||
|
||||
**Files:** `ui/src/api/queries.ts` (modify)
|
||||
|
||||
Add:
|
||||
```ts
|
||||
export function useIngestStory(): UseMutationResult<IngestStoryResponse, ApiError, IngestStoryRequest> {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: IngestStoryRequest) => api.post<IngestStoryResponse>("/v1/items", body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["items"] });
|
||||
qc.invalidateQueries({ queryKey: ["stats"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAnswerIssue(issueId: string | null): UseMutationResult<AnswerIssueResponse, ApiError, string> {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (answer: string) => {
|
||||
if (!issueId) throw new Error("issueId is null");
|
||||
return api.post<AnswerIssueResponse>(`/v1/issues/${issueId}/answer`, { answer });
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["item"] });
|
||||
qc.invalidateQueries({ queryKey: ["issues"] });
|
||||
qc.invalidateQueries({ queryKey: ["stats"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCostSummary(days: number = 7): UseQueryResult<CostSummaryResponse> {
|
||||
return useQuery({
|
||||
queryKey: ["cost", days],
|
||||
queryFn: () => api.get<CostSummaryResponse>("/v1/cost", { days }),
|
||||
staleTime: FIVE_SECONDS,
|
||||
refetchInterval: FIVE_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGroupedItems(): UseQueryResult<GroupedItemsResponse> {
|
||||
return useQuery({
|
||||
queryKey: ["items", "grouped", "project"],
|
||||
queryFn: () => api.get<GroupedItemsResponse>("/v1/items", { group_by: "project" }),
|
||||
staleTime: FIVE_SECONDS,
|
||||
refetchInterval: FIVE_SECONDS,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Note: `useMutation` needs `useMutationResult` import; `useQueryClient` is `@tanstack/react-query`. `ApiError` is already exported from `client.ts`.
|
||||
|
||||
**Verify:** `npm run typecheck` exits 0.
|
||||
|
||||
**Commit:** `feat(ui): React Query hooks for ingest, answer, cost, grouped items (P5)`
|
||||
|
||||
---
|
||||
|
||||
## Task B1: Extend fixture_api.py for v2
|
||||
|
||||
**Files:** `ui/tests/e2e/fixture_api.py` (modify)
|
||||
|
||||
Add four new endpoints to the existing FastAPI app:
|
||||
|
||||
1. `POST /v1/items` — accepts `IngestStoryRequest` body, returns `IngestStoryResponse`. For fixture purposes, generates a UUID, sets phase='spec', attempts=0, and inserts into the in-memory `ITEMS` dict. Idempotent on (project, story_id).
|
||||
2. `POST /v1/issues/{id}/answer` — accepts `AnswerIssueRequest` body, sets `answer`/`status='answered'`/`answered_at=now` on the issue, returns `AnswerIssueResponse`.
|
||||
3. `GET /v1/cost?days=N` — returns `CostSummaryResponse` with synthetic 7-day data. Deterministic values so the e2e test can assert on shape (e.g. one day has higher cost).
|
||||
4. `GET /v1/items?group_by=project` — extends the existing handler to return `GroupedItemsResponse` when `group_by=project`, otherwise the existing list shape.
|
||||
|
||||
**Verify (manual, since fixture is plain Python):**
|
||||
```bash
|
||||
cd ui && python3 tests/e2e/fixture_api.py &
|
||||
sleep 1
|
||||
curl -s -X POST http://127.0.0.1:9110/v1/items -H 'content-type: application/json' \
|
||||
-d '{"project":"test","story_id":"s1","title":"t","file_scope":[],"priority":100,"budget_cycles":3}'
|
||||
# expect 200 + JSON with item.id
|
||||
curl -s 'http://127.0.0.1:9110/v1/cost?days=7'
|
||||
# expect JSON with by_day (7 keys)
|
||||
curl -s 'http://127.0.0.1:9110/v1/items?group_by=project'
|
||||
# expect JSON with groups[]
|
||||
kill %1
|
||||
```
|
||||
|
||||
**Commit:** `test(ui): extend fixture API with POST /v1/items, POST /v1/issues/.../answer, GET /v1/cost, ?group_by=project (P5)`
|
||||
|
||||
---
|
||||
|
||||
## Task C1: PhaseBar widget
|
||||
|
||||
**Files:** `ui/src/widgets/PhaseBar.tsx` (create)
|
||||
|
||||
**Behavior:** Live stacked bar of phase counts. Uses `useStats()` (5s polling). Displays the same MUI Paper+Box pattern that v1's Dashboard uses inline. The widget is a pure presentation component — given a `phase_counts: Record<WorkItemPhase, number>`, render the bar.
|
||||
|
||||
**Step 1 — Write component test (RED):** `ui/tests/unit/PhaseBar.test.tsx`:
|
||||
```ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { ThemeProvider, createTheme } from "@mui/material";
|
||||
import { PhaseBar } from "../../src/widgets/PhaseBar";
|
||||
import type { WorkItemPhase } from "../../src/types";
|
||||
|
||||
const wrap = (children: React.ReactNode) => (
|
||||
<ThemeProvider theme={createTheme()}>{children}</ThemeProvider>
|
||||
);
|
||||
|
||||
describe("PhaseBar widget", () => {
|
||||
it("renders nothing when all counts are zero", () => {
|
||||
const counts: Record<WorkItemPhase, number> = {
|
||||
spec: 0, build: 0, review: 0, merged: 0, blocked: 0, awaiting_human: 0,
|
||||
};
|
||||
const { container } = render(wrap(<PhaseBar counts={counts} total={0} />));
|
||||
expect(container.querySelector('[data-testid="phase-bar"]')).toBeNull();
|
||||
});
|
||||
it("renders one segment per non-zero phase, widths proportional to counts", () => {
|
||||
const counts: Record<WorkItemPhase, number> = {
|
||||
spec: 0, build: 2, review: 0, merged: 6, blocked: 2, awaiting_human: 0,
|
||||
};
|
||||
const { getByTestId } = render(wrap(<PhaseBar counts={counts} total={10} />));
|
||||
expect(getByTestId("phase-bar")).toBeTruthy();
|
||||
expect(getByTestId("phase-bar-build").style.width).toBe("20%");
|
||||
expect(getByTestId("phase-bar-merged").style.width).toBe("60%");
|
||||
expect(getByTestId("phase-bar-blocked").style.width).toBe("20%");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
(Add `@testing-library/react` to package.json devDeps if not present.)
|
||||
|
||||
**Step 2 — Run, expect failure** (PhaseBar doesn't exist). RED.
|
||||
|
||||
**Step 3 — Implement PhaseBar** (extracted verbatim from v1 Dashboard, parameterized by `counts` and `total` props).
|
||||
|
||||
**Step 4 — Run, expect pass.** GREEN.
|
||||
|
||||
**Verify:** `npm run test:unit` passes 5 tests (3 from A3 + 2 from C1).
|
||||
|
||||
**Commit:** `feat(ui): PhaseBar widget extracted from v1 Dashboard (P5)`
|
||||
|
||||
---
|
||||
|
||||
## Task D1: OpenIssues widget
|
||||
|
||||
**Files:** `ui/src/widgets/OpenIssues.tsx` (create)
|
||||
|
||||
**Behavior:** Card showing `open_human_issues` count (big number) + a list of the last 5 open issues. Each issue is clickable → opens the drawer for its `work_item_id`. Uses `useStats()` for the count, and a NEW `useOpenIssues(limit=5)` query against `GET /v1/issues?status=open&limit=5`.
|
||||
|
||||
**Add the new hook to queries.ts:**
|
||||
```ts
|
||||
export function useOpenIssues(limit = 5): UseQueryResult<ListIssuesResponse> {
|
||||
return useQuery({
|
||||
queryKey: ["issues", "open", limit],
|
||||
queryFn: () => api.get<ListIssuesResponse>("/v1/issues", { status: "open", limit }),
|
||||
staleTime: FIVE_SECONDS,
|
||||
refetchInterval: FIVE_SECONDS,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Step 1 — Write component test (RED):**
|
||||
```ts
|
||||
// tests/unit/OpenIssues.test.tsx
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, fireEvent } from "@testing-library/react";
|
||||
import { ThemeProvider, createTheme } from "@mui/material";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { OpenIssues } from "../../src/widgets/OpenIssues";
|
||||
import * as queries from "../../src/api/queries";
|
||||
|
||||
vi.mock("../../src/api/queries", () => ({
|
||||
useStats: vi.fn(),
|
||||
useOpenIssues: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../src/router", () => ({
|
||||
setOpenItem: vi.fn(),
|
||||
navigate: vi.fn(),
|
||||
}));
|
||||
|
||||
const wrap = (children: React.ReactNode) => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return <QueryClientProvider client={qc}><ThemeProvider theme={createTheme()}>{children}</ThemeProvider></QueryClientProvider>;
|
||||
};
|
||||
|
||||
describe("OpenIssues widget", () => {
|
||||
it("renders the count from useStats", () => {
|
||||
(queries.useStats as any).mockReturnValue({ data: { open_human_issues: 7 }, isLoading: false, error: null });
|
||||
(queries.useOpenIssues as any).mockReturnValue({ data: { issues: [], total: 0 }, isLoading: false });
|
||||
const { getByTestId } = render(wrap(<OpenIssues />));
|
||||
expect(getByTestId("open-issues-count").textContent).toBe("7");
|
||||
});
|
||||
it("renders the last 5 issues, each clickable", () => {
|
||||
(queries.useStats as any).mockReturnValue({ data: { open_human_issues: 3 }, isLoading: false, error: null });
|
||||
(queries.useOpenIssues as any).mockReturnValue({
|
||||
data: {
|
||||
total: 3,
|
||||
limit: 5,
|
||||
offset: 0,
|
||||
issues: [
|
||||
{ id: "i1", work_item_id: "w1", question: "Q1", answer: null, status: "open", created_at: "2026-01-01T00:00:00Z", answered_at: null },
|
||||
{ id: "i2", work_item_id: "w2", question: "Q2", answer: null, status: "open", created_at: "2026-01-01T00:00:00Z", answered_at: null },
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
const { getAllByTestId } = render(wrap(<OpenIssues />));
|
||||
const items = getAllByTestId("open-issues-item");
|
||||
expect(items).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2 — Run, expect failure.** RED.
|
||||
|
||||
**Step 3 — Implement OpenIssues:** Card with Typography for the count and Stack of clickable list items. Each item's onClick calls `setOpenItem(issue.work_item_id)`.
|
||||
|
||||
**Step 4 — Run, expect pass.** GREEN.
|
||||
|
||||
**Verify:** `npm run test:unit` passes 7 tests.
|
||||
|
||||
**Commit:** `feat(ui): OpenIssues widget (count + last 5 clickable) (P5)`
|
||||
|
||||
---
|
||||
|
||||
## Task E1: BlockedItems widget
|
||||
|
||||
**Files:** `ui/src/widgets/BlockedItems.tsx` (create)
|
||||
|
||||
**Behavior:** Lists items in `blocked` phase, each as a card showing `last_verdict` and `last_feedback` (so operator sees WHY). Uses `useListItems({ phase: 'blocked', limit: 10 })`. Each card clickable → drawer.
|
||||
|
||||
**Step 1 — Component test (RED):**
|
||||
```ts
|
||||
// tests/unit/BlockedItems.test.tsx
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { ThemeProvider, createTheme } from "@mui/material";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BlockedItems } from "../../src/widgets/BlockedItems";
|
||||
import * as queries from "../../src/api/queries";
|
||||
|
||||
vi.mock("../../src/api/queries", () => ({ useListItems: vi.fn() }));
|
||||
vi.mock("../../src/router", () => ({ setOpenItem: vi.fn() }));
|
||||
|
||||
const wrap = (ui: React.ReactNode) => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return <QueryClientProvider client={qc}><ThemeProvider theme={createTheme()}>{ui}</ThemeProvider></QueryClientProvider>;
|
||||
};
|
||||
|
||||
describe("BlockedItems widget", () => {
|
||||
it("renders no cards when no items are blocked", () => {
|
||||
(queries.useListItems as any).mockReturnValue({ data: { items: [], total: 0 }, isLoading: false });
|
||||
const { queryByTestId } = render(wrap(<BlockedItems />));
|
||||
expect(queryByTestId("blocked-items-card")).toBeNull();
|
||||
});
|
||||
it("renders one card per blocked item showing verdict and feedback", () => {
|
||||
(queries.useListItems as any).mockReturnValue({
|
||||
data: {
|
||||
total: 2, limit: 10, offset: 0,
|
||||
items: [
|
||||
{ id: "b1", project: "p", story_id: "s", title: "T1", phase: "blocked", file_scope: [], attempts: 3, budget_cycles: 3, priority: 100, base_commit: null, branch: null, pr_url: null, last_verdict: "tests_failed", last_feedback: "AssertionError in test_foo", spec_path: null, wiki_pin: null, claimed_by: null, claimed_at: null, created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", merged_at: null },
|
||||
{ id: "b2", project: "p", story_id: "s", title: "T2", phase: "blocked", file_scope: [], attempts: 3, budget_cycles: 3, priority: 100, base_commit: null, branch: null, pr_url: null, last_verdict: "spec_ambiguous", last_feedback: "ambiguous req X", spec_path: null, wiki_pin: null, claimed_by: null, claimed_at: null, created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", merged_at: null },
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
const { getByTestId, getAllByTestId } = render(wrap(<BlockedItems />));
|
||||
expect(getByTestId("blocked-items-root")).toBeTruthy();
|
||||
expect(getAllByTestId("blocked-items-card")).toHaveLength(2);
|
||||
expect(getByTestId("blocked-items-card-b1").textContent).toContain("tests_failed");
|
||||
expect(getByTestId("blocked-items-card-b2").textContent).toContain("spec_ambiguous");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2 — Run, expect failure.** RED.
|
||||
|
||||
**Step 3 — Implement BlockedItems.** Card grid using MUI `<Grid container>` of `<Grid item xs={12} md={6}>`.
|
||||
|
||||
**Step 4 — Run, expect pass.** GREEN.
|
||||
|
||||
**Commit:** `feat(ui): BlockedItems widget (verdict + feedback cards) (P5)`
|
||||
|
||||
---
|
||||
|
||||
## Task F1: CostSparkline widget
|
||||
|
||||
**Files:** `ui/src/widgets/CostSparkline.tsx` (create)
|
||||
|
||||
**Behavior:** Takes `by_day: Record<string, string>` (ISO date → USD string) from `CostSummaryResponse`. Renders a tiny inline SVG polyline (no MUI X-Charts dep — keep bundle small). Each day is a point; missing days are interpolated to 0.
|
||||
|
||||
**Step 1 — Component test (RED):**
|
||||
```ts
|
||||
// tests/unit/CostSparkline.test.tsx
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { ThemeProvider, createTheme } from "@mui/material";
|
||||
import { CostSparkline } from "../../src/widgets/CostSparkline";
|
||||
|
||||
const wrap = (ui: React.ReactNode) => <ThemeProvider theme={createTheme()}>{ui}</ThemeProvider>;
|
||||
|
||||
describe("CostSparkline widget", () => {
|
||||
it("renders an SVG with one polyline point per day", () => {
|
||||
const byDay = { "2026-06-18": "0.10", "2026-06-19": "0.20", "2026-06-20": "0.15" };
|
||||
const { getByTestId } = render(wrap(<CostSparkline byDay={byDay} />));
|
||||
const poly = getByTestId("cost-sparkline-polyline") as unknown as SVGPolylineElement;
|
||||
expect(poly).toBeTruthy();
|
||||
// 3 points => "x1,y1 x2,y2 x3,y3"
|
||||
const points = poly.getAttribute("points")!.trim().split(/\s+/);
|
||||
expect(points).toHaveLength(3);
|
||||
});
|
||||
it("renders a flat line when byDay is empty", () => {
|
||||
const { getByTestId } = render(wrap(<CostSparkline byDay={{}} />));
|
||||
expect(getByTestId("cost-sparkline-empty")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2 — Run, expect failure.** RED.
|
||||
|
||||
**Step 3 — Implement CostSparkline.** 200×60 SVG. Convert Decimal strings to Number, normalize to height, generate `points="x1,y1 x2,y2 ..."` string. Renders empty state (data-testid="cost-sparkline-empty") if zero data.
|
||||
|
||||
**Step 4 — Run, expect pass.** GREEN.
|
||||
|
||||
**Commit:** `feat(ui): CostSparkline widget (inline SVG, no X-Charts dep) (P5)`
|
||||
|
||||
---
|
||||
|
||||
## Task G1: Extend Dashboard to project-grouped + mount widgets
|
||||
|
||||
**Files:** `ui/src/routes/Dashboard.tsx` (rewrite — but keep the same testid surface for the e2e test)
|
||||
|
||||
**Behavior:**
|
||||
- Top: 4 self-improving widgets in a responsive grid (xs=12 md=6 lg=3):
|
||||
- `<PhaseBar />` (data-testid="phase-bar" preserved for back-compat with v1 test)
|
||||
- `<OpenIssues />` (data-testid="open-issues-card" + data-testid="open-issues-count" preserved)
|
||||
- `<BlockedItems />`
|
||||
- `<CostSparkline />`
|
||||
- Below: project-grouped items. Uses `useGroupedItems()`. Tabs (MUI `<Tabs>`) per project. Each tab's content: small per-phase counts for that project's items + a "View all" link to `/items?project=<name>`.
|
||||
|
||||
**Verify:** `npm run typecheck && npm run test:unit` exit 0.
|
||||
|
||||
**Commit:** `feat(ui): Dashboard is project-grouped + 4 widgets (P5)`
|
||||
|
||||
---
|
||||
|
||||
## Task H1: Extend ItemDrawer with answer form
|
||||
|
||||
**Files:** `ui/src/routes/ItemDrawer.tsx` (modify)
|
||||
|
||||
**Behavior:** If `item.phase === 'awaiting_human'` AND `open_issues.length > 0`, render a `<form>` BELOW the open-issues list with a MUI `<TextField multiline>` and a `<Button>Submit answer</Button>`. The form calls `useAnswerIssue(issue.id)`. The first open issue is the one we answer (UI is per-item, not per-issue). On success, invalidate the queries (already done by the hook) — the drawer re-fetches and the answered issue disappears from the open-issues list.
|
||||
|
||||
**Verify:** `npm run typecheck` exits 0.
|
||||
|
||||
**Commit:** `feat(ui): ItemDrawer answer form for awaiting_human items (P5)`
|
||||
|
||||
---
|
||||
|
||||
## Task I1: Router + App: add /ingest route
|
||||
|
||||
**Files:**
|
||||
- `ui/src/router.ts` (modify)
|
||||
- `ui/src/App.tsx` (modify)
|
||||
|
||||
**router.ts:** add a third variant to the `Route` union:
|
||||
```ts
|
||||
| { name: "ingest" };
|
||||
```
|
||||
Plus a parse case: `cleaned === "ingest"` or `cleaned === "ingest/"`.
|
||||
|
||||
**App.tsx:** add an Ingest nav button (data-testid="nav-ingest"), and route-render to `<Ingest />` when `route.name === "ingest"`.
|
||||
|
||||
**Verify:** `npm run typecheck` exits 0.
|
||||
|
||||
**Commit:** `feat(ui): /ingest route + nav button (P5)`
|
||||
|
||||
---
|
||||
|
||||
## Task J1: Ingest form route
|
||||
|
||||
**Files:** `ui/src/routes/Ingest.tsx` (create)
|
||||
|
||||
**Behavior:** 6 MUI `<TextField>`s with validation that mirrors the Pydantic schema:
|
||||
- `project` (1..64)
|
||||
- `story_id` (1..128)
|
||||
- `title` (1..255)
|
||||
- `file_scope` multiline, comma-separated, split on submit → `string[]`
|
||||
- `priority` number 0..1000, default 100
|
||||
- `budget_cycles` number 1..10, default 3
|
||||
|
||||
Submit button → `useIngestStory().mutateAsync(body)`. On success, `navigate("/items/" + result.item.id)`. Validation errors render inline (`<FormHelperText>`). Network errors render a top-of-form `<Alert severity="error">`.
|
||||
|
||||
**Step 1 — Component test (RED):**
|
||||
```ts
|
||||
// tests/unit/Ingest.test.tsx
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, fireEvent, waitFor, screen } from "@testing-library/react";
|
||||
import { ThemeProvider, createTheme } from "@mui/material";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Ingest } from "../../src/routes/Ingest";
|
||||
import * as queries from "../../src/api/queries";
|
||||
import * as router from "../../src/router";
|
||||
|
||||
vi.mock("../../src/api/queries", () => ({ useIngestStory: vi.fn() }));
|
||||
vi.mock("../../src/router", () => ({ navigate: vi.fn(), useRoute: vi.fn(() => ({ name: "ingest" })) }));
|
||||
|
||||
const wrap = (ui: React.ReactNode) => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return <QueryClientProvider client={qc}><ThemeProvider theme={createTheme()}>{ui}</ThemeProvider></QueryClientProvider>;
|
||||
};
|
||||
|
||||
describe("Ingest route", () => {
|
||||
it("renders all 6 fields", () => {
|
||||
(queries.useIngestStory as any).mockReturnValue({ mutateAsync: vi.fn(), isPending: false });
|
||||
const { getByTestId } = render(wrap(<Ingest />));
|
||||
["project", "story_id", "title", "file_scope", "priority", "budget_cycles"].forEach((f) =>
|
||||
expect(getByTestId(`field-${f}`)).toBeTruthy(),
|
||||
);
|
||||
});
|
||||
it("blocks submit when project is empty (Pydantic min_length=1)", async () => {
|
||||
const mutate = vi.fn();
|
||||
(queries.useIngestStory as any).mockReturnValue({ mutateAsync: mutate, isPending: false });
|
||||
const { getByTestId, getByText } = render(wrap(<Ingest />));
|
||||
fireEvent.change(getByTestId("field-story_id"), { target: { value: "s" } });
|
||||
fireEvent.change(getByTestId("field-title"), { target: { value: "t" } });
|
||||
fireEvent.click(getByTestId("ingest-submit"));
|
||||
expect(mutate).not.toHaveBeenCalled();
|
||||
expect(getByText(/project is required/i)).toBeTruthy();
|
||||
});
|
||||
it("submits with parsed body and navigates on success", async () => {
|
||||
const mutate = vi.fn().mockResolvedValue({ item: { id: "abc-123-..." }, created: true });
|
||||
(queries.useIngestStory as any).mockReturnValue({ mutateAsync: mutate, isPending: false });
|
||||
const nav = vi.fn();
|
||||
(router.navigate as any) = nav;
|
||||
const { getByTestId } = render(wrap(<Ingest />));
|
||||
fireEvent.change(getByTestId("field-project"), { target: { value: "p1" } });
|
||||
fireEvent.change(getByTestId("field-story_id"), { target: { value: "s1" } });
|
||||
fireEvent.change(getByTestId("field-title"), { target: { value: "T1" } });
|
||||
fireEvent.change(getByTestId("field-file_scope"), { target: { value: "src/a.ts, src/b.ts" } });
|
||||
fireEvent.change(getByTestId("field-priority"), { target: { value: "200" } });
|
||||
fireEvent.change(getByTestId("field-budget_cycles"), { target: { value: "4" } });
|
||||
fireEvent.click(getByTestId("ingest-submit"));
|
||||
await waitFor(() => expect(mutate).toHaveBeenCalled());
|
||||
const call = mutate.mock.calls[0][0];
|
||||
expect(call).toEqual({
|
||||
project: "p1", story_id: "s1", title: "T1",
|
||||
file_scope: ["src/a.ts", "src/b.ts"],
|
||||
priority: 200, budget_cycles: 4,
|
||||
});
|
||||
await waitFor(() => expect(nav).toHaveBeenCalledWith("/items/abc-123-..."));
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2 — Run, expect failure.** RED.
|
||||
|
||||
**Step 3 — Implement Ingest.tsx.** Per-field validation is a simple `errors: Record<string, string>` map populated on submit. No external validation lib.
|
||||
|
||||
**Step 4 — Run, expect pass.** GREEN.
|
||||
|
||||
**Verify:** `npm run test:unit` exits 0.
|
||||
|
||||
**Commit:** `feat(ui): Ingest form route (P5)`
|
||||
|
||||
---
|
||||
|
||||
## Task K1: Contract: §3 group_by row + §8 P5 note
|
||||
|
||||
**Files:** `wiki/concepts/entry-points-contract.md` (modify)
|
||||
|
||||
**§3 — `GET /v1/items`:** append a row in the param table:
|
||||
```
|
||||
| `group_by` | enum | (none — flat list) | `project` (v2: only this one value is supported) |
|
||||
```
|
||||
|
||||
Add a paragraph below the table:
|
||||
> When `group_by=project`, the response is `GroupedItemsResponse` (not `ListItemsResponse`): `{ groups: [{ project, items, phase_counts }], total_items, total_projects }`. The list-shape params (`phase`, `priority_min/max`, `sort`, `open_questions_only`) still apply to the items within each group. Other values of `group_by` return 400 `bad_request`.
|
||||
|
||||
**§8 — P5 line:** change "P5 — `damascus-ui` v2. Ingest form (`/ingest`), answer form (inside the drawer), project-grouped dashboard. All four \"self-improving\" widgets from §7 wired live. Sparkline data comes from `CostSummaryResponse.by_day`." — add: "Adds `?group_by=project` to `GET /v1/items` (response becomes `GroupedItemsResponse`); see §3."
|
||||
|
||||
**Verify:** Visual inspection only.
|
||||
|
||||
**Commit:** `docs(entry-points): §3 group_by + §8 P5 group_by note (P5)`
|
||||
|
||||
---
|
||||
|
||||
## Task L1: api_schemas.py: group_by + GroupedItemsResponse
|
||||
|
||||
**Files:** `src/damascus/api_schemas.py` (modify)
|
||||
|
||||
**Add to `ListItemsQuery`:**
|
||||
```python
|
||||
group_by: Optional[Literal["project"]] = Field(
|
||||
default=None,
|
||||
description="v2: group response by this field. Only 'project' is supported. Mutually exclusive with the flat list response shape — handler returns GroupedItemsResponse when set.",
|
||||
)
|
||||
```
|
||||
|
||||
Also add a `@model_validator` to reject mutually-exclusive combinations of `group_by` with `limit`/`offset`/`sort` (handler may ignore or enforce; the schema's job is to be self-documenting). Minimal: just add the field.
|
||||
|
||||
**Add new class** after `ListItemsResponse`:
|
||||
```python
|
||||
class ProjectGroup(BaseModel):
|
||||
"""One project bucket inside :class:`GroupedItemsResponse`."""
|
||||
project: str
|
||||
items: list[WorkItemResponse]
|
||||
phase_counts: dict[WorkItemPhase, int]
|
||||
|
||||
class GroupedItemsResponse(BaseModel):
|
||||
"""``GET /v1/items?group_by=project`` response (P5)."""
|
||||
groups: list[ProjectGroup]
|
||||
total_items: int
|
||||
total_projects: int
|
||||
```
|
||||
|
||||
**Verify (Python import test):**
|
||||
```bash
|
||||
cd /root/damascus-orchestrator
|
||||
python -W error -c "from damascus.api_schemas import ListItemsQuery, GroupedItemsResponse, ProjectGroup; print('ok')"
|
||||
```
|
||||
|
||||
**Run contract test (the test_contracts_match_source.py guards the schema in lockstep with the wiki contract — add a check for `group_by` in ListItemsQuery there if it doesn't already exist):**
|
||||
```bash
|
||||
python -m pytest tests/contract/test_contracts_match_source.py -q
|
||||
```
|
||||
|
||||
**Commit:** `feat(api): ListItemsQuery.group_by + GroupedItemsResponse (P5 schema)`
|
||||
|
||||
---
|
||||
|
||||
## Task M1: Playwright e2e test_ui_v2.spec.ts
|
||||
|
||||
**Files:** `ui/tests/e2e/test_ui_v2.spec.ts` (create)
|
||||
|
||||
Three scenarios per the task body:
|
||||
|
||||
1. **Ingest flow:**
|
||||
```ts
|
||||
test("ingest form: fill, submit, redirect to /items/:id", async ({ page }) => {
|
||||
await page.goto("/#/ingest");
|
||||
await page.getByTestId("field-project").fill("e2e-test");
|
||||
await page.getByTestId("field-story_id").fill("story-1");
|
||||
await page.getByTestId("field-title").fill("E2E test story");
|
||||
await page.getByTestId("field-file_scope").fill("src/a.ts, src/b.ts");
|
||||
await page.getByTestId("ingest-submit").click();
|
||||
await expect(page).toHaveURL(/#\/items\/[0-9a-f-]{36}$/);
|
||||
});
|
||||
```
|
||||
|
||||
2. **Dashboard widgets render:**
|
||||
```ts
|
||||
test("dashboard renders all 4 widgets", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("phase-bar")).toBeVisible();
|
||||
await expect(page.getByTestId("open-issues-card")).toBeVisible();
|
||||
await expect(page.getByTestId("blocked-items-root")).toBeVisible();
|
||||
await expect(page.getByTestId("cost-sparkline-root")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
3. **Answer form in drawer:**
|
||||
```ts
|
||||
test("answer form: submit, drawer reflects answered state", async ({ page }) => {
|
||||
// First ingest a story then set its phase to awaiting_human via fixture
|
||||
// (or seed an existing item in awaiting_human via fixture setup).
|
||||
await page.goto("/#/items");
|
||||
const row = page.locator('[data-testid="items-grid"] .MuiDataGrid-row').filter({ hasText: "awaiting_human" });
|
||||
await row.click();
|
||||
await expect(page.getByTestId("answer-form")).toBeVisible();
|
||||
await page.getByTestId("answer-text").fill("Use approach B, here is why");
|
||||
await page.getByTestId("answer-submit").click();
|
||||
// Drawer re-fetches; the open_issues list should now be empty
|
||||
await expect(page.getByTestId("open-issues-list")).toHaveCount(0);
|
||||
});
|
||||
```
|
||||
|
||||
For the third test to work, the fixture needs a work item in `awaiting_human` phase with an open issue. Extend fixture_api.py ITEMS dict with one such item.
|
||||
|
||||
**Verify:** `cd ui && npm run test:e2e` — all v1 + v2 tests pass.
|
||||
|
||||
**Commit:** `test(ui): v2 e2e — ingest, dashboard widgets, answer form (P5)`
|
||||
|
||||
---
|
||||
|
||||
## Task N1: Build, full test, mobile viewport, commit, push, PR
|
||||
|
||||
```bash
|
||||
cd /root/damascus-orchestrator/ui
|
||||
npm run typecheck # exits 0
|
||||
npm run test:unit # all unit tests pass
|
||||
npm run build # tsc + vite build
|
||||
npm run test:e2e # all e2e tests pass
|
||||
npm run test:e2e -- --viewport=375,667 # mobile
|
||||
```
|
||||
|
||||
Then:
|
||||
```bash
|
||||
cd /root/damascus-orchestrator
|
||||
git add -A
|
||||
git commit -m "feat(ui): damascus-ui v2 — ingest, answer, project-grouped dashboard, 4 widgets (P5)"
|
||||
git push -u origin feat/entry-points-ui-v2
|
||||
tea pulls create
|
||||
```
|
||||
|
||||
PR link goes in a `kanban_comment` on `t_83bfe8cc`. Then `kanban_block(reason="review-required: ...")` — this is a code change, needs human eyes.
|
||||
|
||||
---
|
||||
|
||||
## Risk register
|
||||
|
||||
- **Mobile viewport regression.** v1's drawer uses `width: 480` with `maxWidth: "100%"` (mobile-safe). v2's answer form must use the same pattern. All widget grids must use `xs={12}` so they stack on small screens.
|
||||
- **No live damascus-api in CI.** The e2e suite uses the local fixture. The real damascus-api may differ in CORS, auth, response timing. The CI flag `UI_NO_WEBSERVER` is the escape hatch for ad-hoc runs against a real API.
|
||||
- **Authorization header in production.** The Vite build needs `VITE_API_WRITE_TOKEN` set during `npm run build`. The Dockerfile currently sets `VITE_API_BASE_URL=""` — it should also conditionally set `VITE_API_WRITE_TOKEN` from compose env. The compose file needs the same. (Out of scope for v2 e2e; the e2e test doesn't send the header. The user / compose stack supplies it.)
|
||||
- **The fixture returns 200 for unknown methods.** FastAPI returns 405 by default — verified during B1. If the route is added but the body is malformed, FastAPI returns 422 — the Ingest component test (J1) handles that.
|
||||
- **Vitest config + testing-library.** May need a one-time `npm install -D @testing-library/react @testing-library/dom jsdom` in ui/. The `jsdom` dep is already in devDeps; the testing-library deps are NOT. Add them in the C1 commit.
|
||||
|
||||
## Out of scope (explicit non-goals)
|
||||
|
||||
- Writing to `wiki_pins` from the UI (deferred per contract §7)
|
||||
- Operator-note textarea on blocked items (deferred per contract §7)
|
||||
- Bulk ingest UI (P1 schema supports it, but P5 ships single-story only)
|
||||
- Auth flow / login (the bundle is LAN-trusted per task body)
|
||||
- Composition of the live damascus-api — P2 owns that. The v2 e2e suite runs against the fixture.
|
||||
@@ -33,7 +33,7 @@ from contextlib import asynccontextmanager, contextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, AsyncIterator, Iterator, Optional
|
||||
from typing import Annotated, Any, AsyncIterator, Iterator, Optional, Union
|
||||
|
||||
import psycopg
|
||||
import psycopg.types.json
|
||||
@@ -330,7 +330,11 @@ def create_app() -> FastAPI:
|
||||
|
||||
# ---------- /v1/items ---------------------------------------------------
|
||||
|
||||
@app.get("/v1/items", response_model=S.ListItemsResponse, tags=["items"])
|
||||
@app.get(
|
||||
"/v1/items",
|
||||
response_model=Union[S.ListItemsResponse, S.GroupedItemsResponse],
|
||||
tags=["items"],
|
||||
)
|
||||
def list_items(
|
||||
project: Optional[str] = None,
|
||||
phase: Optional[S.WorkItemPhase] = None,
|
||||
@@ -340,13 +344,35 @@ def create_app() -> FastAPI:
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
open_questions_only: bool = False,
|
||||
) -> S.ListItemsResponse:
|
||||
"""List work_items with filters, sort, pagination (contract §3)."""
|
||||
group_by: Optional[str] = None,
|
||||
) -> Union[S.ListItemsResponse, S.GroupedItemsResponse]:
|
||||
"""List work_items with filters, sort, pagination (contract §3).
|
||||
|
||||
P5: when ``group_by=project``, response shape switches to
|
||||
:class:`GroupedItemsResponse` (one bucket per project with
|
||||
per-phase counts). Only ``"project"`` is supported today;
|
||||
other values return 400 ``bad_request``.
|
||||
"""
|
||||
# Server-side business rule (Pydantic catches type errors; this catches
|
||||
# semantic violations of business invariants — 400 bad_request).
|
||||
if priority_max < priority_min:
|
||||
return err(S.ErrorCode.bad_request, "priority_max must be >= priority_min", 400)
|
||||
|
||||
# group_by routing
|
||||
if group_by is not None and group_by != "project":
|
||||
return err(
|
||||
S.ErrorCode.bad_request,
|
||||
f"group_by={group_by!r} not supported (only 'project')",
|
||||
400,
|
||||
)
|
||||
if group_by == "project":
|
||||
return _list_items_grouped_by_project(
|
||||
project=project,
|
||||
phase=phase,
|
||||
priority_min=priority_min,
|
||||
priority_max=priority_max,
|
||||
)
|
||||
|
||||
sql = ["SELECT * FROM work_items WHERE 1=1"]
|
||||
params: list[Any] = []
|
||||
if project is not None:
|
||||
@@ -397,6 +423,65 @@ def create_app() -> FastAPI:
|
||||
items = [S.WorkItemResponse.model_validate(r) for r in rows]
|
||||
return S.ListItemsResponse(items=items, total=total, limit=limit, offset=offset)
|
||||
|
||||
def _list_items_grouped_by_project(
|
||||
*,
|
||||
project: Optional[str],
|
||||
phase: Optional[S.WorkItemPhase],
|
||||
priority_min: int,
|
||||
priority_max: int,
|
||||
) -> S.GroupedItemsResponse:
|
||||
"""P5: aggregate items into ``ProjectGroup`` buckets.
|
||||
|
||||
Returns one ``ProjectGroup`` per distinct ``project`` value,
|
||||
each carrying the project's items (still filtered by ``phase``
|
||||
and ``priority_*``) and per-phase counts. ``sort`` / pagination
|
||||
are intentionally not honored in the grouped view — the dashboard
|
||||
renders the full set; per-item pagination lives on the flat list
|
||||
endpoint.
|
||||
"""
|
||||
sql = ["SELECT * FROM work_items WHERE 1=1"]
|
||||
params: list[Any] = []
|
||||
if project is not None:
|
||||
sql.append("AND project = %s")
|
||||
params.append(project)
|
||||
if phase is not None:
|
||||
sql.append("AND phase = %s")
|
||||
params.append(phase.value)
|
||||
sql.append("AND priority >= %s AND priority <= %s")
|
||||
params.extend([priority_min, priority_max])
|
||||
sql.append("ORDER BY project ASC, priority ASC, updated_at ASC")
|
||||
|
||||
with pool_cursor() as cur:
|
||||
cur.execute(" ".join(sql), params)
|
||||
rows = list(cur.fetchall())
|
||||
|
||||
groups_by_project: dict[str, list[S.WorkItemResponse]] = {}
|
||||
for r in rows:
|
||||
wi = S.WorkItemResponse.model_validate(r)
|
||||
groups_by_project.setdefault(wi.project, []).append(wi)
|
||||
|
||||
groups: list[S.ProjectGroup] = []
|
||||
total_items = 0
|
||||
for project_name in sorted(groups_by_project.keys()):
|
||||
items = groups_by_project[project_name]
|
||||
phase_counts: dict[S.WorkItemPhase, int] = {}
|
||||
for it in items:
|
||||
phase_counts[it.phase] = phase_counts.get(it.phase, 0) + 1
|
||||
groups.append(
|
||||
S.ProjectGroup(
|
||||
project=project_name,
|
||||
items=items,
|
||||
phase_counts=phase_counts,
|
||||
)
|
||||
)
|
||||
total_items += len(items)
|
||||
|
||||
return S.GroupedItemsResponse(
|
||||
groups=groups,
|
||||
total_items=total_items,
|
||||
total_projects=len(groups),
|
||||
)
|
||||
|
||||
@app.get("/v1/items/{item_id}", response_model=S.ItemDetailResponse, tags=["items"])
|
||||
def get_item(
|
||||
item_id: Annotated[str, PathParam(min_length=36, max_length=36, pattern=S.UUID36_PATTERN)],
|
||||
|
||||
@@ -29,7 +29,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
@@ -164,6 +164,17 @@ class ListItemsQuery(BaseModel):
|
||||
limit: int = Field(default=50, ge=1, le=500)
|
||||
offset: int = Field(default=0, ge=0)
|
||||
open_questions_only: bool = False
|
||||
# P5: when set, the handler returns GroupedItemsResponse (not
|
||||
# ListItemsResponse). Only "project" is supported today; other
|
||||
# values are rejected with HTTP 400 by the handler.
|
||||
group_by: Optional[Literal["project"]] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"v2: when set, the response shape switches to "
|
||||
"GroupedItemsResponse. Only 'project' is supported; other "
|
||||
"values return HTTP 400 bad_request."
|
||||
),
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _priority_bounds(self) -> "ListItemsQuery":
|
||||
@@ -270,6 +281,29 @@ class ListItemsResponse(BaseModel):
|
||||
offset: int
|
||||
|
||||
|
||||
class ProjectGroup(BaseModel):
|
||||
"""One project bucket inside :class:`GroupedItemsResponse`.
|
||||
|
||||
P5: when ``ListItemsQuery.group_by=project`` is set, the response
|
||||
is grouped by the ``project`` field. Each group carries the
|
||||
project's items (still respecting phase/priority/etc. filters) and
|
||||
per-phase counts so the dashboard can render the breakdown without
|
||||
a second query.
|
||||
"""
|
||||
|
||||
project: str
|
||||
items: list[WorkItemResponse]
|
||||
phase_counts: dict[WorkItemPhase, int]
|
||||
|
||||
|
||||
class GroupedItemsResponse(BaseModel):
|
||||
"""``GET /v1/items?group_by=project`` response (P5)."""
|
||||
|
||||
groups: list[ProjectGroup]
|
||||
total_items: int
|
||||
total_projects: int
|
||||
|
||||
|
||||
class HumanIssueResponse(BaseModel):
|
||||
"""One row from ``human_issues``."""
|
||||
|
||||
|
||||
@@ -229,6 +229,64 @@ def test_get_items_pagination(client):
|
||||
assert len(body["items"]) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /v1/items?group_by=project (P5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_items_group_by_project_returns_grouped_response(client):
|
||||
"""`group_by=project` switches the response shape to GroupedItemsResponse."""
|
||||
_insert_work_item(story_id="alpha-1", project="alpha")
|
||||
_insert_work_item(story_id="alpha-2", project="alpha")
|
||||
_insert_work_item(story_id="beta-1", project="beta")
|
||||
r = client.get("/v1/items", params={"group_by": "project"})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body.keys()) == {"groups", "total_items", "total_projects"}
|
||||
assert body["total_items"] == 3
|
||||
assert body["total_projects"] == 2
|
||||
projects = {g["project"] for g in body["groups"]}
|
||||
assert projects == {"alpha", "beta"}
|
||||
by_project = {g["project"]: g for g in body["groups"]}
|
||||
assert len(by_project["alpha"]["items"]) == 2
|
||||
assert len(by_project["beta"]["items"]) == 1
|
||||
# per-phase counts are present and include the seed phase
|
||||
assert "spec" in by_project["alpha"]["phase_counts"]
|
||||
|
||||
|
||||
def test_get_items_group_by_project_filters_respected(client):
|
||||
"""`phase` and `priority_*` filters still apply inside the grouped view."""
|
||||
_insert_work_item(story_id="alpha-1", project="alpha", priority=10)
|
||||
_insert_work_item(story_id="alpha-2", project="alpha", priority=200)
|
||||
_insert_work_item(story_id="beta-1", project="beta", priority=50)
|
||||
r = client.get(
|
||||
"/v1/items",
|
||||
params={"group_by": "project", "priority_min": 100},
|
||||
)
|
||||
body = r.json()
|
||||
assert body["total_items"] == 1
|
||||
assert body["total_projects"] == 1
|
||||
assert body["groups"][0]["project"] == "alpha"
|
||||
|
||||
|
||||
def test_get_items_group_by_project_unsupported_value_rejected(client):
|
||||
"""Any `group_by` value other than `project` returns 400."""
|
||||
r = client.get("/v1/items", params={"group_by": "phase"})
|
||||
assert r.status_code == 400
|
||||
body = r.json()
|
||||
assert body["error"] == "bad_request"
|
||||
assert "project" in body["detail"]
|
||||
|
||||
|
||||
def test_get_items_no_group_by_returns_flat_list(client):
|
||||
"""Without `group_by`, the response stays ListItemsResponse (regression)."""
|
||||
_insert_work_item(story_id="flat-1")
|
||||
r = client.get("/v1/items")
|
||||
body = r.json()
|
||||
assert set(body.keys()) == {"items", "total", "limit", "offset"}
|
||||
assert len(body["items"]) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /v1/items/{id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
235
ui/package-lock.json
generated
235
ui/package-lock.json
generated
@@ -19,6 +19,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
@@ -29,6 +31,13 @@
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
|
||||
"integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
|
||||
@@ -1986,6 +1995,90 @@
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^5.0.1",
|
||||
"aria-query": "5.3.0",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.5.0",
|
||||
"picocolors": "1.1.1",
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom": {
|
||||
"version": "6.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
|
||||
"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@adobe/css-tools": "^4.4.0",
|
||||
"aria-query": "^5.0.0",
|
||||
"css.escape": "^1.5.1",
|
||||
"dom-accessibility-api": "^0.6.3",
|
||||
"picocolors": "^1.1.1",
|
||||
"redent": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14",
|
||||
"npm": ">=6",
|
||||
"yarn": ">=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
|
||||
"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@testing-library/react": {
|
||||
"version": "16.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
|
||||
"integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -2205,6 +2298,41 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -2418,6 +2546,13 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/css.escape": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
||||
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cssstyle": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
|
||||
@@ -2503,6 +2638,24 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
@@ -2949,6 +3102,16 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/indent-string": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
|
||||
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arrayish": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||
@@ -3090,6 +3253,17 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -3133,6 +3307,16 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/min-indent": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -3339,6 +3523,30 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
"react-is": "^17.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format/node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
@@ -3419,6 +3627,20 @@
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"indent-string": "^4.0.0",
|
||||
"strip-indent": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
|
||||
@@ -3583,6 +3805,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/strip-indent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
||||
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"min-indent": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/stylis": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Damascus orchestrator v1 read-only dashboard (P4)",
|
||||
"description": "Damascus orchestrator UI (P4 + P5: read-only dashboard, ingest, answer, project-grouped view, 4 self-improving widgets)",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:e2e": "VITE_API_BASE_URL=http://127.0.0.1:9110 vite build && playwright test"
|
||||
"test:unit": "vitest run",
|
||||
"test:unit:watch": "vitest",
|
||||
"test:e2e": "VITE_API_BASE_URL=http://127.0.0.1:9111 vite build && playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.13.0",
|
||||
@@ -23,6 +25,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
|
||||
@@ -20,8 +20,15 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
// makes them same-origin; in this test we cross-origin which works
|
||||
// because the fixture API has CORS allow_origins=["*"].
|
||||
|
||||
// Default the fixture to 9111 to avoid colliding with a developer
|
||||
// machine that already runs the real damascus-api on 9110 (P2's
|
||||
// default). CI runs against a clean host where 9110 is free; set
|
||||
// FIXTURE_API_PORT=9110 there to keep the original behavior. The
|
||||
// `npm run test:e2e` script bakes VITE_API_BASE_URL against the same
|
||||
// port as the fixture, so changing this value is a single point of
|
||||
// edit.
|
||||
const UI_PORT = Number(process.env.UI_PORT ?? 4173);
|
||||
const API_PORT = Number(process.env.FIXTURE_API_PORT ?? 9110);
|
||||
const API_PORT = Number(process.env.FIXTURE_API_PORT ?? 9111);
|
||||
const BASE_URL = process.env.UI_BASE_URL ?? `http://127.0.0.1:${UI_PORT}`;
|
||||
|
||||
export default defineConfig({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Box, AppBar, Toolbar, Typography, Button, Stack } from "@mui/material";
|
||||
import { useRoute, navigate } from "./router";
|
||||
import { Dashboard } from "./routes/Dashboard";
|
||||
import { Items } from "./routes/Items";
|
||||
import { Ingest } from "./routes/Ingest";
|
||||
|
||||
export default function App() {
|
||||
const route = useRoute();
|
||||
@@ -34,15 +35,29 @@ export default function App() {
|
||||
>
|
||||
Items
|
||||
</Button>
|
||||
<Button
|
||||
color="inherit"
|
||||
data-testid="nav-ingest"
|
||||
onClick={() => navigate("/ingest")}
|
||||
variant={route.name === "ingest" ? "outlined" : "text"}
|
||||
>
|
||||
Ingest
|
||||
</Button>
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ opacity: 0.6 }}>
|
||||
v1 read-only
|
||||
v2 ingest + widgets
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
|
||||
{route.name === "dashboard" ? <Dashboard /> : <Items />}
|
||||
{route.name === "dashboard" ? (
|
||||
<Dashboard />
|
||||
) : route.name === "items" ? (
|
||||
<Items />
|
||||
) : (
|
||||
<Ingest />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -7,17 +7,29 @@
|
||||
// All paths are relative — same-origin in both dev (Vite proxy) and
|
||||
// production (FastAPI StaticFiles mount). Override the base with
|
||||
// VITE_API_BASE_URL only for ad-hoc debugging (e.g. hitting a remote
|
||||
// API directly from a laptop).
|
||||
// API directly from a laptop). The e2e suite (npm run test:e2e) bakes
|
||||
// VITE_API_BASE_URL=http://127.0.0.1:9111 (the fixture port) at build
|
||||
// time so the React app calls the fixture directly.
|
||||
//
|
||||
// Auth: reads (GET) need no token; writes (POST) need
|
||||
// `Authorization: Bearer <token>`. v1 is read-only, so this client
|
||||
// never sends a token. The IngestPage in P5 will extend the wrapper.
|
||||
// `Authorization: Bearer <...3e, baked at build time via
|
||||
// VITE_API_WRITE_TOKEN (LAN-trusted; the bundle is served loopback
|
||||
// only). v1 is read-only, so this client never sends the header;
|
||||
// P5's ingest + answer flows send it on every POST.
|
||||
|
||||
import type { ErrorResponse } from "../types";
|
||||
|
||||
const BASE_URL =
|
||||
(import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";
|
||||
|
||||
// Token used for write requests. Baked at build time by Vite (the
|
||||
// value is whatever the dev / CI / production shell exports). Empty
|
||||
// string = no auth header sent, which is fine for read-only test
|
||||
// fixtures and the local dev experience where the operator is the
|
||||
// only "user".
|
||||
const WRITE_TOKEN =
|
||||
(import.meta.env.VITE_API_WRITE_TOKEN as string | undefined) ?? "";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
body: unknown;
|
||||
@@ -62,6 +74,12 @@ async function request<T>(
|
||||
if (body !== undefined) {
|
||||
(init.headers as Record<string, string>)["Content-Type"] = "application/json";
|
||||
init.body = JSON.stringify(body);
|
||||
// Write auth: send Bearer only when a token is baked in. The
|
||||
// contract says writes need auth, but tests / read-only
|
||||
// deployments may run without one — they just can't POST.
|
||||
if (WRITE_TOKEN) {
|
||||
(init.headers as Record<string, string>).Authorization = `Bearer ${WRITE_TOKEN}`;
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(finalUrl.toString(), init);
|
||||
|
||||
@@ -6,18 +6,22 @@
|
||||
// the dashboard widgets; the Items page uses staleTime=0 so manual
|
||||
// refetch on URL change picks up new filters immediately.
|
||||
|
||||
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import { api } from "./client";
|
||||
import { useMutation, useQuery, useQueryClient, type UseMutationResult, type UseQueryResult } from "@tanstack/react-query";
|
||||
import { api, ApiError } from "./client";
|
||||
import type {
|
||||
AnswerIssueRequest,
|
||||
AnswerIssueResponse,
|
||||
CostSummaryResponse,
|
||||
GroupedItemsResponse,
|
||||
HealthResponse,
|
||||
IngestStoryRequest,
|
||||
IngestStoryResponse,
|
||||
ItemDetailResponse,
|
||||
ItemsSort,
|
||||
ListEventsResponse,
|
||||
ListIssuesResponse,
|
||||
ListItemsQueryParams,
|
||||
ListItemsResponse,
|
||||
StatsResponse,
|
||||
WorkItem,
|
||||
WorkItemPhase,
|
||||
} from "../types";
|
||||
|
||||
const FIVE_SECONDS = 5_000;
|
||||
@@ -75,20 +79,74 @@ export function useRecentEvents(
|
||||
});
|
||||
}
|
||||
|
||||
// Convenience: project list derived from the work items the UI
|
||||
// already has. Lets the filter dropdown populate without a separate
|
||||
// /v1/projects endpoint (which the contract doesn't expose).
|
||||
export function deriveProjects(items: WorkItem[]): string[] {
|
||||
return Array.from(new Set(items.map((i) => i.project))).sort();
|
||||
export function useOpenIssues(limit = 5): UseQueryResult<ListIssuesResponse> {
|
||||
return useQuery({
|
||||
queryKey: ["issues", "open", limit],
|
||||
queryFn: () => api.get<ListIssuesResponse>("/v1/issues", { status: "open", limit }),
|
||||
staleTime: FIVE_SECONDS,
|
||||
refetchInterval: FIVE_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
export function matchesPhaseFilter(
|
||||
item: WorkItem,
|
||||
phases: WorkItemPhase[] | undefined,
|
||||
): boolean {
|
||||
if (!phases || phases.length === 0) return true;
|
||||
return phases.includes(item.phase);
|
||||
// --- P5 write hooks (ingest + answer) -------------------------------------
|
||||
|
||||
export function useIngestStory(): UseMutationResult<
|
||||
IngestStoryResponse,
|
||||
ApiError,
|
||||
IngestStoryRequest
|
||||
> {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: IngestStoryRequest) =>
|
||||
api.post<IngestStoryResponse>("/v1/items", body),
|
||||
onSuccess: () => {
|
||||
// Invalidate anything that lists items or shows phase counts.
|
||||
qc.invalidateQueries({ queryKey: ["items"] });
|
||||
qc.invalidateQueries({ queryKey: ["stats"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const DEFAULT_SORT: ItemsSort = "priority_asc";
|
||||
export const DEFAULT_LIMIT = 50;
|
||||
export function useAnswerIssue(
|
||||
issueId: string | null,
|
||||
): UseMutationResult<AnswerIssueResponse, ApiError, string> {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (answer: string) => {
|
||||
if (!issueId) {
|
||||
return Promise.reject(new Error("issueId is null"));
|
||||
}
|
||||
const body: AnswerIssueRequest = { answer };
|
||||
return api.post<AnswerIssueResponse>(
|
||||
`/v1/issues/${issueId}/answer`,
|
||||
body,
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["item"] });
|
||||
qc.invalidateQueries({ queryKey: ["issues"] });
|
||||
qc.invalidateQueries({ queryKey: ["stats"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- P5 read hooks (cost, grouped) ----------------------------------------
|
||||
|
||||
export function useCostSummary(days = 7): UseQueryResult<CostSummaryResponse> {
|
||||
return useQuery({
|
||||
queryKey: ["cost", days],
|
||||
queryFn: () => api.get<CostSummaryResponse>("/v1/cost", { days }),
|
||||
staleTime: FIVE_SECONDS,
|
||||
refetchInterval: FIVE_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGroupedItems(): UseQueryResult<GroupedItemsResponse> {
|
||||
return useQuery({
|
||||
queryKey: ["items", "grouped", "project"],
|
||||
queryFn: () =>
|
||||
api.get<GroupedItemsResponse>("/v1/items", { group_by: "project" }),
|
||||
staleTime: FIVE_SECONDS,
|
||||
refetchInterval: FIVE_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
// Tiny hash-based router for the v1 UI.
|
||||
// Tiny hash-based router for the v1 + P5 UI.
|
||||
//
|
||||
// Three routes:
|
||||
// #/ — Dashboard
|
||||
// #/items — Items table
|
||||
// #/items/:id — Items table with the drawer open on `id`
|
||||
// Routes:
|
||||
// #/ — Dashboard
|
||||
// #/items — Items table
|
||||
// #/items/:id — Items table with the drawer open on `id`
|
||||
// #/ingest — P5: ingest form (POST /v1/items)
|
||||
//
|
||||
// We use a path-based hash (not hashbang) so the URL looks like
|
||||
// http://damascus.lan/#/items/abc-123-... — friendlier than
|
||||
// #/items?id=abc-123-... and matches what users expect.
|
||||
//
|
||||
// v1 has no nested routes, no auth-gated routes, and no programmatic
|
||||
// navigation beyond the Link helper. P5 (ingest form) will add at most
|
||||
// one more route (#/ingest) and shouldn't need to expand the surface.
|
||||
// http://damascus.lan/#/ingest — friendlier than #/?route=ingest
|
||||
// and matches what users expect.
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
|
||||
export type Route =
|
||||
| { name: "dashboard" }
|
||||
| { name: "items"; itemId: string | null };
|
||||
| { name: "items"; itemId: string | null }
|
||||
| { name: "ingest" };
|
||||
|
||||
function parseHash(hash: string): Route {
|
||||
const cleaned = hash.replace(/^#\/?/, "");
|
||||
if (cleaned === "" || cleaned === "/") {
|
||||
return { name: "dashboard" };
|
||||
}
|
||||
if (cleaned === "ingest" || cleaned === "ingest/") {
|
||||
return { name: "ingest" };
|
||||
}
|
||||
if (cleaned === "items" || cleaned === "items/") {
|
||||
return { name: "items", itemId: null };
|
||||
}
|
||||
@@ -31,7 +32,7 @@ function parseHash(hash: string): Route {
|
||||
if (itemMatch) {
|
||||
return { name: "items", itemId: itemMatch[1] };
|
||||
}
|
||||
// Unknown route falls back to dashboard; v1 is not opinionated about 404s.
|
||||
// Unknown route falls back to dashboard; not opinionated about 404s.
|
||||
return { name: "dashboard" };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,40 @@
|
||||
// Dashboard route — §7 widgets for the read-only v1.
|
||||
// Dashboard route — §7 widgets for the P5 self-improving UI.
|
||||
//
|
||||
// Three widgets:
|
||||
// 1. Phase counts as a stacked bar (live, polled every 5s via useStats).
|
||||
// 2. Open human_issues count + last 5 inline (links → /items/:id drawer).
|
||||
// 3. Cost today (USD).
|
||||
// P5 layout:
|
||||
// 1. Four self-improving widgets at the top (responsive grid):
|
||||
// - <PhaseBar /> (live, polled every 5s via useStats)
|
||||
// - <OpenIssues /> (count + last 5 inline, clickable → drawer)
|
||||
// - <BlockedItems /> (cards showing verdict + feedback)
|
||||
// - <CostSparkline /> (inline SVG of by_day for last 7 days)
|
||||
// 2. Project-grouped items below (Tabs, one per project).
|
||||
//
|
||||
// P5 will add a sparkline for the last 7 days of cost and the
|
||||
// "items in `blocked` phase" cards. v1 is intentionally a thin
|
||||
// slice so the bundle stays small and the contract ships.
|
||||
// All four widgets are pure presentational components living under
|
||||
// src/widgets/ — the Dashboard composes them. The phase counts are
|
||||
// the only thing Dashboard fetches directly (via useStats), and that
|
||||
// is the same hook v1 used.
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Grid,
|
||||
Stack,
|
||||
Tab,
|
||||
Tabs,
|
||||
Typography,
|
||||
Alert,
|
||||
Chip,
|
||||
Paper,
|
||||
} from "@mui/material";
|
||||
import { useStats } from "../api/queries";
|
||||
import { useStats, useGroupedItems } from "../api/queries";
|
||||
import { ALL_PHASES, type WorkItemPhase } from "../types";
|
||||
import { setOpenItem } from "../router";
|
||||
import { setOpenItem, navigate } from "../router";
|
||||
import { PhaseBar } from "../widgets/PhaseBar";
|
||||
import { OpenIssues } from "../widgets/OpenIssues";
|
||||
import { BlockedItems } from "../widgets/BlockedItems";
|
||||
import { CostSparkline } from "../widgets/CostSparkline";
|
||||
import { useCostSummary } from "../api/queries";
|
||||
|
||||
const PHASE_COLORS: Record<WorkItemPhase, string> = {
|
||||
spec: "#7aa2f7",
|
||||
@@ -36,6 +47,11 @@ const PHASE_COLORS: Record<WorkItemPhase, string> = {
|
||||
|
||||
export function Dashboard() {
|
||||
const stats = useStats();
|
||||
const grouped = useGroupedItems();
|
||||
const cost = useCostSummary(7);
|
||||
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const activeGroup = grouped.data?.groups?.[activeTab];
|
||||
|
||||
if (stats.isLoading) {
|
||||
return (
|
||||
@@ -61,156 +77,233 @@ export function Dashboard() {
|
||||
|
||||
return (
|
||||
<Stack spacing={3} data-testid="dashboard-root">
|
||||
<Typography variant="h4" component="h1">
|
||||
Dashboard
|
||||
</Typography>
|
||||
|
||||
{/* Phase counts — stacked bar + per-phase chips */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Work items by phase
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{phaseTotal} total
|
||||
</Typography>
|
||||
{phaseTotal === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No work items yet.
|
||||
</Typography>
|
||||
) : (
|
||||
<>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
display: "flex",
|
||||
height: 32,
|
||||
borderRadius: 1,
|
||||
overflow: "hidden",
|
||||
bgcolor: "background.default",
|
||||
}}
|
||||
data-testid="phase-bar"
|
||||
>
|
||||
{ALL_PHASES.map((phase) => {
|
||||
const count = data.phase_counts[phase] ?? 0;
|
||||
if (count === 0) return null;
|
||||
const pct = (count / phaseTotal) * 100;
|
||||
return (
|
||||
<Box
|
||||
key={phase}
|
||||
data-testid={`phase-bar-${phase}`}
|
||||
sx={{
|
||||
width: `${pct}%`,
|
||||
bgcolor: PHASE_COLORS[phase],
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#1a1b26",
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
}}
|
||||
title={`${phase}: ${count}`}
|
||||
>
|
||||
{pct > 8 ? count : ""}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Paper>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 2, flexWrap: "wrap", gap: 1 }}>
|
||||
{ALL_PHASES.map((phase) => (
|
||||
<Chip
|
||||
key={phase}
|
||||
size="small"
|
||||
label={`${phase}: ${data.phase_counts[phase] ?? 0}`}
|
||||
sx={{
|
||||
bgcolor: PHASE_COLORS[phase],
|
||||
color: "#1a1b26",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
sx={{ flexWrap: "wrap", gap: 1 }}
|
||||
>
|
||||
<Typography variant="h4" component="h1">
|
||||
Dashboard
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{data.last_cycle_at
|
||||
? `Last cycle ${new Date(data.last_cycle_at).toLocaleString()}`
|
||||
: "No cycles yet"}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{/* Top widget grid — 4 self-improving widgets, xs=12 stacks on
|
||||
mobile per the user's "no fixed pixel widths" preference. */}
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card data-testid="open-issues-card">
|
||||
<Grid item xs={12} md={6} lg={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
Open human issues
|
||||
Phase distribution
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h3"
|
||||
data-testid="open-issues-count"
|
||||
sx={{ fontWeight: 600 }}
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<PhaseBar counts={data.phase_counts} total={phaseTotal} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6} lg={3}>
|
||||
<OpenIssues />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6} lg={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
{data.open_human_issues}
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
Cost today (USD)
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="h3" sx={{ fontWeight: 600 }}>
|
||||
${data.cost_today_usd}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Live, polled every 5s.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Grid item xs={12} md={6} lg={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
Active claims
|
||||
</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
Active claims
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="h3" sx={{ fontWeight: 600 }}>
|
||||
{data.active_claims}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
Cost today (USD)
|
||||
</Typography>
|
||||
<Typography variant="h3" sx={{ fontWeight: 600 }}>
|
||||
${data.cost_today_usd}
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Workers currently holding an item.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Blocked items + cost sparkline (P5 §7). */}
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<BlockedItems />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<CostSparkline byDay={cost.data?.by_day ?? {}} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Project-grouped view (P5: ?group_by=project). */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Last cycle
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{data.last_cycle_at
|
||||
? new Date(data.last_cycle_at).toLocaleString()
|
||||
: "never"}
|
||||
</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
<Typography variant="h6">Items by project</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{grouped.data?.total_items ?? 0} items ·{" "}
|
||||
{grouped.data?.total_projects ?? 0} projects
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{grouped.isLoading && (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", p: 2 }}>
|
||||
<CircularProgress size={20} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!grouped.isLoading &&
|
||||
(grouped.data?.groups?.length ?? 0) === 0 && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
data-testid="project-groups-empty"
|
||||
>
|
||||
No items yet — head to the ingest form to create the first story.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{(grouped.data?.groups?.length ?? 0) > 0 && (
|
||||
<>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(_, v) => setActiveTab(v)}
|
||||
data-testid="project-tabs"
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
>
|
||||
{grouped.data!.groups.map((g) => (
|
||||
<Tab
|
||||
key={g.project}
|
||||
label={`${g.project} (${g.items.length})`}
|
||||
data-testid={`project-tab-${g.project}`}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
{activeGroup && (
|
||||
<Box sx={{ mt: 2 }} data-testid="project-tab-panel">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ flexWrap: "wrap", gap: 1, mb: 2 }}
|
||||
>
|
||||
{ALL_PHASES.map((p) => (
|
||||
<Chip
|
||||
key={p}
|
||||
size="small"
|
||||
label={`${p}: ${activeGroup.phase_counts[p] ?? 0}`}
|
||||
sx={{
|
||||
bgcolor: PHASE_COLORS[p],
|
||||
color: "#1a1b26",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Stack spacing={1}>
|
||||
{activeGroup.items.length === 0 && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No items in this project.
|
||||
</Typography>
|
||||
)}
|
||||
{activeGroup.items.map((it) => (
|
||||
<Box
|
||||
key={it.id}
|
||||
data-testid={`project-item-${it.id}`}
|
||||
onClick={() => setOpenItem(it.id)}
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
p: 1,
|
||||
borderRadius: 1,
|
||||
bgcolor: "action.hover",
|
||||
"&:hover": { bgcolor: "action.selected" },
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Chip
|
||||
size="small"
|
||||
label={it.phase}
|
||||
sx={{
|
||||
bgcolor: PHASE_COLORS[it.phase],
|
||||
color: "#1a1b26",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ flex: 1 }}>
|
||||
{it.title}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{it.story_id}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigate(`/items?project=${encodeURIComponent(activeGroup.project)}`);
|
||||
}}
|
||||
style={{
|
||||
background: "transparent",
|
||||
color: "#7aa2f7",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
font: "inherit",
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
data-testid={`project-view-all-${activeGroup.project}`}
|
||||
>
|
||||
View all in {activeGroup.project} →
|
||||
</button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Convenience link to items table — operators land here first */}
|
||||
<Box>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenItem(null)}
|
||||
style={{
|
||||
background: "transparent",
|
||||
color: "#7aa2f7",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
font: "inherit",
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
>
|
||||
View all items →
|
||||
</button>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
250
ui/src/routes/Ingest.tsx
Normal file
250
ui/src/routes/Ingest.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
// Ingest route — /ingest form for P5 §8.
|
||||
//
|
||||
// Form fields mirror IngestStoryRequest (src/damascus/api_schemas.py):
|
||||
// - project (1..64)
|
||||
// - story_id (1..128)
|
||||
// - title (1..255)
|
||||
// - file_scope (multiline, comma-separated → string[] on submit)
|
||||
// - priority (0..1000, default 100)
|
||||
// - budget_cycles (1..10, default 3)
|
||||
//
|
||||
// On submit:
|
||||
// - per-field validation runs (matches Pydantic min/max length, ge/le)
|
||||
// - successful submit calls useIngestStory.mutateAsync(parsedBody)
|
||||
// - on success, navigate(`/items/${item.id}`)
|
||||
// - on error, surface as <Alert severity="error"> at top of form
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Grid,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
FormHelperText,
|
||||
} from "@mui/material";
|
||||
import { useIngestStory } from "../api/queries";
|
||||
import { navigate } from "../router";
|
||||
import type { IngestStoryRequest } from "../types";
|
||||
|
||||
type Errors = Partial<Record<keyof IngestStoryRequest, string>>;
|
||||
|
||||
function validate(values: IngestStoryRequest): Errors {
|
||||
const e: Errors = {};
|
||||
if (values.project.length < 1 || values.project.length > 64) {
|
||||
e.project = "Project is required (1..64 chars).";
|
||||
}
|
||||
if (values.story_id.length < 1 || values.story_id.length > 128) {
|
||||
e.story_id = "Story ID is required (1..128 chars).";
|
||||
}
|
||||
if (values.title.length < 1 || values.title.length > 255) {
|
||||
e.title = "Title is required (1..255 chars).";
|
||||
}
|
||||
if (!Number.isInteger(values.priority) || values.priority < 0 || values.priority > 1000) {
|
||||
e.priority = "Priority must be an integer between 0 and 1000.";
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(values.budget_cycles) ||
|
||||
values.budget_cycles < 1 ||
|
||||
values.budget_cycles > 10
|
||||
) {
|
||||
e.budget_cycles = "Budget cycles must be an integer between 1 and 10.";
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
function parseFileScope(text: string): string[] {
|
||||
return text
|
||||
.split(/[,\n]/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
}
|
||||
|
||||
const initialValues: IngestStoryRequest = {
|
||||
project: "",
|
||||
story_id: "",
|
||||
title: "",
|
||||
file_scope: [],
|
||||
priority: 100,
|
||||
budget_cycles: 3,
|
||||
};
|
||||
|
||||
export function Ingest() {
|
||||
const [values, setValues] = useState<IngestStoryRequest>(initialValues);
|
||||
const [fileScopeText, setFileScopeText] = useState("");
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [topError, setTopError] = useState<string | null>(null);
|
||||
const mutation = useIngestStory();
|
||||
|
||||
const setField = <K extends keyof IngestStoryRequest>(
|
||||
key: K,
|
||||
value: IngestStoryRequest[K],
|
||||
) => {
|
||||
setValues((v) => ({ ...v, [key]: value }));
|
||||
setErrors((e) => ({ ...e, [key]: undefined }));
|
||||
};
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setTopError(null);
|
||||
const parsed: IngestStoryRequest = {
|
||||
...values,
|
||||
file_scope: parseFileScope(fileScopeText),
|
||||
};
|
||||
const v = validate(parsed);
|
||||
if (Object.values(v).some(Boolean)) {
|
||||
setErrors(v);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await mutation.mutateAsync(parsed);
|
||||
navigate(`/items/${res.item.id}`);
|
||||
} catch (err) {
|
||||
setTopError(String(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box data-testid="ingest-root">
|
||||
<Stack direction="row" alignItems="center" sx={{ mb: 2 }} spacing={2}>
|
||||
<Typography variant="h4" component="h1">
|
||||
Ingest story
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Add a new work item to the orchestrator queue.
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box component="form" onSubmit={onSubmit} noValidate>
|
||||
{topError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} data-testid="ingest-error">
|
||||
{topError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
label="Project"
|
||||
fullWidth
|
||||
value={values.project}
|
||||
onChange={(e) => setField("project", e.target.value)}
|
||||
required
|
||||
error={Boolean(errors.project)}
|
||||
inputProps={{ "data-testid": "field-project", maxLength: 64 }}
|
||||
helperText={errors.project ?? "1..64 chars (matches Pydantic)"}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
label="Story ID"
|
||||
fullWidth
|
||||
value={values.story_id}
|
||||
onChange={(e) => setField("story_id", e.target.value)}
|
||||
required
|
||||
error={Boolean(errors.story_id)}
|
||||
inputProps={{ "data-testid": "field-story_id", maxLength: 128 }}
|
||||
helperText={errors.story_id ?? "1..128 chars"}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
label="Title"
|
||||
fullWidth
|
||||
value={values.title}
|
||||
onChange={(e) => setField("title", e.target.value)}
|
||||
required
|
||||
error={Boolean(errors.title)}
|
||||
inputProps={{ "data-testid": "field-title", maxLength: 255 }}
|
||||
helperText={errors.title ?? "1..255 chars"}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
label="File scope"
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
value={fileScopeText}
|
||||
onChange={(e) => setFileScopeText(e.target.value)}
|
||||
inputProps={{ "data-testid": "field-file_scope" }}
|
||||
placeholder="Comma-separated paths the worker is allowed to touch, e.g. src/a.ts, src/b.ts"
|
||||
helperText="Split on commas or newlines; empty entries dropped."
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<TextField
|
||||
label="Priority"
|
||||
type="number"
|
||||
fullWidth
|
||||
value={values.priority}
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value);
|
||||
setField("priority", Number.isFinite(n) ? n : values.priority);
|
||||
}}
|
||||
error={Boolean(errors.priority)}
|
||||
inputProps={{
|
||||
"data-testid": "field-priority",
|
||||
min: 0,
|
||||
max: 1000,
|
||||
}}
|
||||
helperText={errors.priority ?? "0..1000, default 100"}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<TextField
|
||||
label="Budget cycles"
|
||||
type="number"
|
||||
fullWidth
|
||||
value={values.budget_cycles}
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value);
|
||||
setField(
|
||||
"budget_cycles",
|
||||
Number.isFinite(n) ? n : values.budget_cycles,
|
||||
);
|
||||
}}
|
||||
error={Boolean(errors.budget_cycles)}
|
||||
inputProps={{
|
||||
"data-testid": "field-budget_cycles",
|
||||
min: 1,
|
||||
max: 10,
|
||||
}}
|
||||
helperText={errors.budget_cycles ?? "1..10, default 3"}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* The errors object may have keys whose FormHelperText already
|
||||
surfaces them; this block renders any error key without a
|
||||
TextField (none today, but kept for forward-compat). */}
|
||||
{Object.entries(errors).map(([k, v]) =>
|
||||
v ? (
|
||||
<FormHelperText key={k} error sx={{ mt: 1 }}>
|
||||
{`${k}: ${v}`}
|
||||
</FormHelperText>
|
||||
) : null,
|
||||
)}
|
||||
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 3 }}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
data-testid="ingest-submit"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? "Submitting…" : "Ingest"}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
// ItemDrawer — right-side drawer that opens when the user clicks a
|
||||
// row in the Items table (URL hash = #/items/:id).
|
||||
//
|
||||
// Shows: the work item's full record, its open human_issues, and the
|
||||
// 20 most recent events_outbox rows for the item.
|
||||
//
|
||||
// v1 is read-only — no answer form, no edit. P5 will add the answer
|
||||
// textarea inside this drawer.
|
||||
// Shows: the work item's full record, its open human_issues, the
|
||||
// 20 most recent events_outbox rows for the item, and (P5) an answer
|
||||
// form when the item is paused on a human question.
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
@@ -16,11 +17,15 @@ import {
|
||||
IconButton,
|
||||
Paper,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
Alert,
|
||||
} from "@mui/material";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { useItemDetail, useRecentEvents } from "../api/queries";
|
||||
import {
|
||||
useItemDetail,
|
||||
useRecentEvents,
|
||||
useAnswerIssue,
|
||||
} from "../api/queries";
|
||||
import { useOpenItemId, setOpenItem } from "../router";
|
||||
import type { WorkItemPhase } from "../types";
|
||||
|
||||
@@ -203,6 +208,15 @@ export function ItemDrawer() {
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* P5: answer form for items paused on a human question. */}
|
||||
{detail.data.item.phase === "awaiting_human" &&
|
||||
detail.data.open_issues.length > 0 && (
|
||||
<AnswerForm
|
||||
issueId={detail.data.open_issues[0].id}
|
||||
question={detail.data.open_issues[0].question}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
Recent events
|
||||
@@ -241,3 +255,73 @@ export function ItemDrawer() {
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
// AnswerForm: textarea + Submit button. Posts to /v1/issues/{id}/answer.
|
||||
// On success the parent query invalidation (in useAnswerIssue.onSuccess)
|
||||
// refetches the item + issues list, so the answered issue disappears
|
||||
// from the open-issues list and the form unmounts.
|
||||
function AnswerForm({
|
||||
issueId,
|
||||
question,
|
||||
}: {
|
||||
issueId: string;
|
||||
question: string;
|
||||
}) {
|
||||
const [text, setText] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const mutation = useAnswerIssue(issueId);
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length === 0) {
|
||||
setError("Answer is required (1..10000 chars).");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await mutation.mutateAsync(trimmed);
|
||||
setText("");
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="form"
|
||||
data-testid="answer-form"
|
||||
onSubmit={onSubmit}
|
||||
sx={{ mt: 3 }}
|
||||
>
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
Answer human question
|
||||
</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 1.5, mt: 0.5, mb: 1 }}>
|
||||
<Typography variant="body2">{question}</Typography>
|
||||
</Paper>
|
||||
<TextField
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
multiline
|
||||
minRows={3}
|
||||
fullWidth
|
||||
placeholder="Type the answer the spec-refiner should use…"
|
||||
disabled={mutation.isPending}
|
||||
inputProps={{ maxLength: 10_000, "data-testid": "answer-text" }}
|
||||
error={error !== null}
|
||||
helperText={error ?? undefined}
|
||||
/>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
data-testid="answer-submit"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? "Submitting…" : "Submit answer"}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,6 +96,57 @@ export interface ListIssuesResponse {
|
||||
offset: number;
|
||||
}
|
||||
|
||||
// --- P5 additions -------------------------------------------------------
|
||||
|
||||
export interface IngestStoryRequest {
|
||||
project: string; // 1..64 chars (Pydantic min_length/max_length)
|
||||
story_id: string; // 1..128 chars
|
||||
title: string; // 1..255 chars
|
||||
file_scope: string[]; // default []
|
||||
priority: number; // 0..1000, default 100
|
||||
budget_cycles: number; // 1..10, default 3
|
||||
}
|
||||
|
||||
export interface IngestStoryResponse {
|
||||
item: WorkItem;
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
export interface AnswerIssueRequest {
|
||||
answer: string; // 1..10_000 chars
|
||||
}
|
||||
|
||||
export interface AnswerIssueResponse {
|
||||
id: string;
|
||||
work_item_id: string;
|
||||
question: string;
|
||||
answer: string;
|
||||
status: IssueStatus;
|
||||
created_at: string;
|
||||
answered_at: string;
|
||||
}
|
||||
|
||||
export interface CostSummaryResponse {
|
||||
total_usd: string; // serialized Decimal
|
||||
by_project: Record<string, string>; // project -> USD string
|
||||
by_model: Record<string, string>; // model -> USD string
|
||||
by_day: Record<string, string>; // YYYY-MM-DD -> USD string
|
||||
window_start: string; // ISO datetime
|
||||
window_end: string; // ISO datetime
|
||||
}
|
||||
|
||||
export interface ProjectGroup {
|
||||
project: string;
|
||||
items: WorkItem[];
|
||||
phase_counts: Record<WorkItemPhase, number>;
|
||||
}
|
||||
|
||||
export interface GroupedItemsResponse {
|
||||
groups: ProjectGroup[];
|
||||
total_items: number;
|
||||
total_projects: number;
|
||||
}
|
||||
|
||||
export interface EventRow {
|
||||
id: number;
|
||||
work_item_id: string | null;
|
||||
@@ -136,6 +187,7 @@ export interface ListItemsQueryParams {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
open_questions_only?: boolean;
|
||||
group_by?: "project"; // P5: when set, the API returns GroupedItemsResponse instead of ListItemsResponse
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
|
||||
140
ui/src/widgets/BlockedItems.tsx
Normal file
140
ui/src/widgets/BlockedItems.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
// BlockedItems widget — §7 "items in `blocked` phase" card grid.
|
||||
//
|
||||
// Surfaces work items stuck in the `blocked` phase with their
|
||||
// last_verdict + last_feedback so the operator can see WHY they're
|
||||
// stuck without opening the drawer. Each card is clickable → drawer
|
||||
// for that work item.
|
||||
//
|
||||
// The widget polls /v1/items?phase=blocked&limit=10 (the same hook
|
||||
// the Items page uses) and is keyed off a 5s poll like the other
|
||||
// self-improving widgets in this slice.
|
||||
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Grid,
|
||||
Stack,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useListItems } from "../api/queries";
|
||||
import { setOpenItem } from "../router";
|
||||
import type { VerdictKind } from "../types";
|
||||
|
||||
const VERDICT_COLORS: Record<VerdictKind, string> = {
|
||||
pass: "#9ece6a",
|
||||
tests_failed: "#f7768e",
|
||||
rebase_conflict: "#e0af68",
|
||||
spec_ambiguous: "#bb9af7",
|
||||
spec_wrong: "#f7768e",
|
||||
no_pr: "#7aa2f7",
|
||||
};
|
||||
|
||||
function formatFeedback(raw: unknown): string {
|
||||
if (raw == null) return "—";
|
||||
if (typeof raw === "string") return raw;
|
||||
try {
|
||||
return JSON.stringify(raw, null, 2);
|
||||
} catch {
|
||||
return String(raw);
|
||||
}
|
||||
}
|
||||
|
||||
export function BlockedItems() {
|
||||
const list = useListItems({ phase: "blocked", limit: 10 });
|
||||
const items = list.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<Box data-testid="blocked-items-root">
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}>
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
Blocked items
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" data-testid="blocked-items-count">
|
||||
{items.length}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{list.isLoading && (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", p: 2 }}>
|
||||
<CircularProgress size={20} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!list.isLoading && items.length === 0 && (
|
||||
<Typography variant="body2" color="text.secondary" data-testid="blocked-items-empty">
|
||||
Nothing blocked — pipeline is flowing.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<Grid container spacing={2}>
|
||||
{items.map((item) => {
|
||||
const verdict = item.last_verdict as VerdictKind | null;
|
||||
const color = verdict ? VERDICT_COLORS[verdict] : "#565f89";
|
||||
return (
|
||||
<Grid item xs={12} md={6} key={item.id}>
|
||||
<Box
|
||||
data-testid={`blocked-items-card-${item.id}`}
|
||||
onClick={() => setOpenItem(item.id)}
|
||||
sx={{ cursor: "pointer" }}
|
||||
>
|
||||
<Card
|
||||
data-testid="blocked-items-card"
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
borderLeft: 4,
|
||||
borderLeftColor: color,
|
||||
"&:hover": { boxShadow: 3 },
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={verdict ?? "unknown"}
|
||||
sx={{ bgcolor: color, color: "#1a1b26", fontWeight: 600 }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{item.project} / {item.story_id}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
mb: 1,
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{item.title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
component="pre"
|
||||
sx={{
|
||||
fontFamily: "monospace",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
m: 0,
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{formatFeedback(item.last_feedback)}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
113
ui/src/widgets/CostSparkline.tsx
Normal file
113
ui/src/widgets/CostSparkline.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
// CostSparkline widget — §7 "last 7 days of cost" inline sparkline.
|
||||
//
|
||||
// Renders by_day from /v1/cost as a tiny inline SVG polyline. No
|
||||
// MUI X-Charts dep — keeps the bundle small (the operator glances
|
||||
// at this widget, doesn't interact with it). Missing days are
|
||||
// treated as 0; out-of-order keys are sorted by date.
|
||||
//
|
||||
// Data-testid surface (referenced by the unit test and the e2e):
|
||||
// - cost-sparkline-root : the wrapping Box
|
||||
// - cost-sparkline-polyline : the SVG <polyline> element
|
||||
// - cost-sparkline-empty : empty-state Box
|
||||
|
||||
import { Box, Card, CardContent, Stack, Typography } from "@mui/material";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export interface CostSparklineProps {
|
||||
byDay: Record<string, string>;
|
||||
}
|
||||
|
||||
const WIDTH = 200;
|
||||
const HEIGHT = 60;
|
||||
const PAD_X = 2;
|
||||
const PAD_Y = 6;
|
||||
|
||||
function parseDecimal(s: string): number {
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
export function CostSparkline({ byDay }: CostSparklineProps) {
|
||||
const pointsAttr = useMemo(() => {
|
||||
const keys = Object.keys(byDay).sort();
|
||||
if (keys.length === 0) return "";
|
||||
const values = keys.map((k) => parseDecimal(byDay[k]));
|
||||
const max = Math.max(...values, 0.0001);
|
||||
const stepX = (WIDTH - 2 * PAD_X) / Math.max(keys.length - 1, 1);
|
||||
return keys
|
||||
.map((_, i) => {
|
||||
const x = PAD_X + i * stepX;
|
||||
// Invert Y: top of SVG is 0, max value is at the top of the chart.
|
||||
const y = HEIGHT - PAD_Y - (values[i] / max) * (HEIGHT - 2 * PAD_Y);
|
||||
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
||||
})
|
||||
.join(" ");
|
||||
}, [byDay]);
|
||||
|
||||
if (!pointsAttr) {
|
||||
return (
|
||||
<Box data-testid="cost-sparkline-root">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
Last 7 days (USD)
|
||||
</Typography>
|
||||
<Box
|
||||
data-testid="cost-sparkline-empty"
|
||||
sx={{ height: HEIGHT, display: "flex", alignItems: "center" }}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No cost data yet.
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Sparkline viewBox is 0..WIDTH x 0..HEIGHT, no axes (the operator
|
||||
// doesn't need them — the shape is the signal).
|
||||
return (
|
||||
<Box data-testid="cost-sparkline-root">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
Last 7 days (USD)
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
${Object.values(byDay)
|
||||
.map(parseDecimal)
|
||||
.reduce((a, b) => a + b, 0)
|
||||
.toFixed(2)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<svg
|
||||
data-testid="cost-sparkline-svg"
|
||||
viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
|
||||
width="100%"
|
||||
height={HEIGHT}
|
||||
role="img"
|
||||
aria-label="Daily cost sparkline"
|
||||
>
|
||||
<polyline
|
||||
data-testid="cost-sparkline-polyline"
|
||||
fill="none"
|
||||
stroke="#7aa2f7"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
points={pointsAttr}
|
||||
/>
|
||||
</svg>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
109
ui/src/widgets/OpenIssues.tsx
Normal file
109
ui/src/widgets/OpenIssues.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
// OpenIssues widget — §7 "open human issues" card.
|
||||
//
|
||||
// Shows the live count from useStats (same source as the v1 dashboard's
|
||||
// big number) plus a list of the last 5 open issues fetched via
|
||||
// useOpenIssues. Each list item is clickable; clicking it calls
|
||||
// setOpenItem(issue.work_item_id) so the operator can read the full
|
||||
// item context (and, in P5, answer the question).
|
||||
//
|
||||
// Data-testid surface (referenced by the unit test and the e2e):
|
||||
// - open-issues-card : the wrapping card
|
||||
// - open-issues-count : the big number (matches v1 surface)
|
||||
// - open-issues-item : one per listed issue
|
||||
// - open-issues-empty : empty-state text when count is zero
|
||||
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Divider,
|
||||
Stack,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
} from "@mui/material";
|
||||
import { useStats, useOpenIssues } from "../api/queries";
|
||||
import { setOpenItem } from "../router";
|
||||
|
||||
const LIST_LIMIT = 5;
|
||||
|
||||
export function OpenIssues() {
|
||||
const stats = useStats();
|
||||
const list = useOpenIssues(LIST_LIMIT);
|
||||
|
||||
const count = stats.data?.open_human_issues ?? 0;
|
||||
const issues = list.data?.issues ?? [];
|
||||
|
||||
return (
|
||||
<Card data-testid="open-issues-card">
|
||||
<CardContent>
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
Open human issues
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h3"
|
||||
data-testid="open-issues-count"
|
||||
sx={{ fontWeight: 600, mb: 1 }}
|
||||
>
|
||||
{stats.isLoading ? "…" : count}
|
||||
</Typography>
|
||||
|
||||
{list.isLoading && (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", mt: 2 }}>
|
||||
<CircularProgress size={20} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!list.isLoading && issues.length === 0 && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
data-testid="open-issues-empty"
|
||||
>
|
||||
None — operator queue is clear.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{issues.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Stack spacing={1} sx={{ mt: 1 }}>
|
||||
{issues.map((issue) => (
|
||||
<Box
|
||||
key={issue.id}
|
||||
data-testid="open-issues-item"
|
||||
onClick={() => setOpenItem(issue.work_item_id)}
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
p: 1,
|
||||
borderRadius: 1,
|
||||
bgcolor: "action.hover",
|
||||
"&:hover": { bgcolor: "action.selected" },
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{issue.question}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: "block", mt: 0.5 }}
|
||||
>
|
||||
{new Date(issue.created_at).toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
101
ui/src/widgets/PhaseBar.tsx
Normal file
101
ui/src/widgets/PhaseBar.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
// PhaseBar widget — the §7 "phase counts as a stacked bar" self-
|
||||
// improving UI primitive.
|
||||
//
|
||||
// P5: extracted from the v1 Dashboard's inline Paper+Box rendering so
|
||||
// it can be reused (e.g. on the project-grouped Dashboard sub-views)
|
||||
// and unit-tested in isolation. Pure presentation: takes the phase
|
||||
// counts and a total, renders a stacked horizontal bar with one
|
||||
// segment per non-zero phase, widths proportional to count.
|
||||
//
|
||||
// Color palette matches the v1 Dashboard for visual consistency.
|
||||
|
||||
import { Box, Paper, Stack, Chip, Typography } from "@mui/material";
|
||||
import { ALL_PHASES, type WorkItemPhase } from "../types";
|
||||
|
||||
const PHASE_COLORS: Record<WorkItemPhase, string> = {
|
||||
spec: "#7aa2f7",
|
||||
build: "#9ece6a",
|
||||
review: "#e0af68",
|
||||
merged: "#73daca",
|
||||
blocked: "#f7768e",
|
||||
awaiting_human: "#bb9af7",
|
||||
};
|
||||
|
||||
export interface PhaseBarProps {
|
||||
counts: Record<WorkItemPhase, number>;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function PhaseBar({ counts, total }: PhaseBarProps) {
|
||||
if (total === 0) {
|
||||
return (
|
||||
<Typography variant="body2" color="text.secondary" data-testid="phase-bar-empty">
|
||||
No work items yet.
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="phase-bar-wrapper">
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
display: "flex",
|
||||
height: 32,
|
||||
borderRadius: 1,
|
||||
overflow: "hidden",
|
||||
bgcolor: "background.default",
|
||||
}}
|
||||
data-testid="phase-bar"
|
||||
>
|
||||
{ALL_PHASES.map((phase) => {
|
||||
const count = counts[phase] ?? 0;
|
||||
if (count === 0) return null;
|
||||
const pct = (count / total) * 100;
|
||||
return (
|
||||
<Box
|
||||
key={phase}
|
||||
data-testid={`phase-bar-${phase}`}
|
||||
// width is set inline (not via sx) so unit tests can
|
||||
// read element.style.width directly; MUI's sx would
|
||||
// route the value through emotion's stylesheet, where
|
||||
// the computed style is class-based and harder to
|
||||
// assert on.
|
||||
style={{ width: `${pct}%` }}
|
||||
sx={{
|
||||
bgcolor: PHASE_COLORS[phase],
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#1a1b26",
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
}}
|
||||
title={`${phase}: ${count}`}
|
||||
>
|
||||
{pct > 8 ? count : ""}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Paper>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ mt: 2, flexWrap: "wrap", gap: 1 }}
|
||||
>
|
||||
{ALL_PHASES.map((phase) => (
|
||||
<Chip
|
||||
key={phase}
|
||||
size="small"
|
||||
label={`${phase}: ${counts[phase] ?? 0}`}
|
||||
sx={{
|
||||
bgcolor: PHASE_COLORS[phase],
|
||||
color: "#1a1b26",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,40 @@
|
||||
"""
|
||||
Minimal FastAPI fixture for the damascus-ui v1 e2e suite.
|
||||
Minimal FastAPI fixture for the damascus-ui v1 + v2 e2e suite.
|
||||
|
||||
Mirrors the P1 contract endpoint shapes (a strict subset is enough for
|
||||
the v1 UI smoke test). Lives outside the main compose stack so the
|
||||
e2e test can run without depending on P2's damascus-api merge.
|
||||
Mirrors the P1 + P5 contract endpoint shapes (a strict subset is
|
||||
enough for the v1 + v2 UI smoke tests). Lives outside the main
|
||||
compose stack so the e2e test can run without depending on P2's
|
||||
damascus-api merge.
|
||||
|
||||
Run:
|
||||
pip install fastapi uvicorn
|
||||
uvicorn tests.e2e.fixture_api:app --port 9110 --host 127.0.0.1
|
||||
uvicorn tests.e2e.fixture_api:app --port 9111 --host 127.0.0.1
|
||||
|
||||
The fixture returns a deterministic dataset:
|
||||
- 3 work items across 3 phases (spec, build, merged)
|
||||
- 1 open human_issue on the build item
|
||||
- 5 events_outbox rows for that build item
|
||||
The fixture returns a deterministic dataset (deterministic ids for
|
||||
the v1 + v2 scenarios):
|
||||
- v1: 3 work items across 3 phases (spec, build, merged); 1 open
|
||||
human_issue on the build item; 5 events_outbox rows for that build
|
||||
item.
|
||||
- v2 (P5):
|
||||
- 1 work item in `awaiting_human` with an open human_issue — target
|
||||
of the answer-form e2e test
|
||||
- 1 work item in `blocked` with last_verdict + last_feedback — target
|
||||
of the BlockedItems widget assertion
|
||||
- v2 fixtures for: POST /v1/items (in-memory insert, idempotent on
|
||||
(project, story_id)), POST /v1/issues/{id}/answer, GET /v1/cost
|
||||
(synthetic 7-day totals), GET /v1/issues, ?group_by=project on
|
||||
GET /v1/items.
|
||||
|
||||
Contract reference: src/damascus/api_schemas.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi import Body, FastAPI, HTTPException, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
app = FastAPI()
|
||||
@@ -49,6 +59,14 @@ SPEC_ITEM_ID = "11111111-1111-4111-8111-111111111111"
|
||||
BUILD_ITEM_ID = "22222222-2222-4222-8222-222222222222"
|
||||
MERGED_ITEM_ID = "33333333-3333-4333-8333-333333333333"
|
||||
ISSUE_ID = "44444444-4444-4444-8444-444444444444"
|
||||
# P5: an item in `awaiting_human` phase with an open human_issue, used
|
||||
# by the e2e "answer form" scenario.
|
||||
AWAITING_ITEM_ID = "55555555-5555-4555-8555-555555555555"
|
||||
AWAITING_ISSUE_ID = "66666666-6666-4666-8666-666666666666"
|
||||
# P5: an item in `blocked` phase with a verdict, for the BlockedItems
|
||||
# widget assertion (no human_issue needed; the card shows last_verdict
|
||||
# and last_feedback).
|
||||
BLOCKED_ITEM_ID = "77777777-7777-4777-8777-777777777777"
|
||||
|
||||
|
||||
ITEMS: dict[str, dict[str, Any]] = {
|
||||
@@ -121,6 +139,56 @@ ITEMS: dict[str, dict[str, Any]] = {
|
||||
"updated_at": "2026-06-23T11:00:00+00:00",
|
||||
"merged_at": "2026-06-23T11:00:00+00:00",
|
||||
},
|
||||
# P5 fixture: item paused on a human question. The drawer's answer
|
||||
# form is shown when phase == 'awaiting_human' && open_issues > 0.
|
||||
AWAITING_ITEM_ID: {
|
||||
"id": AWAITING_ITEM_ID,
|
||||
"project": "wh40k-pc",
|
||||
"story_id": "awaiting-story-01",
|
||||
"title": "Pick the scoreboard color palette",
|
||||
"phase": "awaiting_human",
|
||||
"file_scope": ["src/theme.ts"],
|
||||
"attempts": 1,
|
||||
"budget_cycles": 3,
|
||||
"priority": 250,
|
||||
"base_commit": "abc1234",
|
||||
"branch": "feat/scoreboard-palette",
|
||||
"pr_url": None,
|
||||
"last_verdict": "spec_ambiguous",
|
||||
"last_feedback": "Spec asks for 'discord-inspired' but doesn't pin a palette.",
|
||||
"spec_path": "/data/specs/wh40k-pc/awaiting-story-01.md",
|
||||
"wiki_pin": None,
|
||||
"claimed_by": "orch-1",
|
||||
"claimed_at": "2026-06-24T12:00:00+00:00",
|
||||
"created_at": "2026-06-24T11:30:00+00:00",
|
||||
"updated_at": now_iso(),
|
||||
"merged_at": None,
|
||||
},
|
||||
# P5 fixture: item in 'blocked' phase with a non-null last_verdict
|
||||
# so the BlockedItems widget has something to render.
|
||||
BLOCKED_ITEM_ID: {
|
||||
"id": BLOCKED_ITEM_ID,
|
||||
"project": "iso-tank-arena",
|
||||
"story_id": "blocked-story-01",
|
||||
"title": "Fix collision detection on slopes",
|
||||
"phase": "blocked",
|
||||
"file_scope": ["src/physics/collide.ts"],
|
||||
"attempts": 3,
|
||||
"budget_cycles": 3,
|
||||
"priority": 400,
|
||||
"base_commit": "aaa9999",
|
||||
"branch": "feat/slope-collision",
|
||||
"pr_url": None,
|
||||
"last_verdict": "tests_failed",
|
||||
"last_feedback": "AssertionError: expected 0.0, got 0.014 at test_slope_collision_30deg",
|
||||
"spec_path": "/data/specs/iso-tank-arena/blocked-story-01.md",
|
||||
"wiki_pin": None,
|
||||
"claimed_by": "orch-3",
|
||||
"claimed_at": "2026-06-24T09:00:00+00:00",
|
||||
"created_at": "2026-06-24T08:30:00+00:00",
|
||||
"updated_at": now_iso(),
|
||||
"merged_at": None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -134,6 +202,17 @@ ISSUES: dict[str, dict[str, Any]] = {
|
||||
"created_at": "2026-06-24T11:30:00+00:00",
|
||||
"answered_at": None,
|
||||
},
|
||||
# P5: a second open issue, this one on the awaiting_human item.
|
||||
# The answer-form e2e test targets this issue.
|
||||
AWAITING_ISSUE_ID: {
|
||||
"id": AWAITING_ISSUE_ID,
|
||||
"work_item_id": AWAITING_ITEM_ID,
|
||||
"question": "Which palette: Catppuccin Mocha, Tokyo Night, or Discord dark?",
|
||||
"answer": None,
|
||||
"status": "open",
|
||||
"created_at": "2026-06-24T12:30:00+00:00",
|
||||
"answered_at": None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -173,6 +252,22 @@ EVENTS: list[dict[str, Any]] = [
|
||||
"payload": {"issue_id": ISSUE_ID},
|
||||
"created_at": "2026-06-24T11:30:00+00:00",
|
||||
},
|
||||
# P5: events for the awaiting_human item to give the drawer's
|
||||
# recent-events list something to render.
|
||||
{
|
||||
"id": 6,
|
||||
"work_item_id": AWAITING_ITEM_ID,
|
||||
"kind": "spec_refined",
|
||||
"payload": {"spec_path": "/data/specs/wh40k-pc/awaiting-story-01.md"},
|
||||
"created_at": "2026-06-24T12:00:00+00:00",
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"work_item_id": AWAITING_ITEM_ID,
|
||||
"kind": "issue_opened",
|
||||
"payload": {"issue_id": AWAITING_ISSUE_ID},
|
||||
"created_at": "2026-06-24T12:30:00+00:00",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -191,7 +286,16 @@ def list_items(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
open_questions_only: bool = False,
|
||||
group_by: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
# P5: ?group_by=project returns GroupedItemsResponse (the items
|
||||
# within each group still respect phase/priority/etc. filters).
|
||||
if group_by is not None and group_by != "project":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"unsupported group_by={group_by!r}; only 'project' is supported",
|
||||
)
|
||||
|
||||
items = list(ITEMS.values())
|
||||
if project:
|
||||
items = [i for i in items if i["project"] == project]
|
||||
@@ -214,6 +318,29 @@ def list_items(
|
||||
}
|
||||
items = [i for i in items if i["id"] in open_item_ids]
|
||||
|
||||
if group_by == "project":
|
||||
# Bucket by project, preserve sort order of first appearance
|
||||
groups_dict: dict[str, list[dict[str, Any]]] = {}
|
||||
for it in items:
|
||||
groups_dict.setdefault(it["project"], []).append(it)
|
||||
groups: list[dict[str, Any]] = []
|
||||
for project_name, project_items in sorted(groups_dict.items()):
|
||||
phase_counts: dict[str, int] = {}
|
||||
for it in project_items:
|
||||
phase_counts[it["phase"]] = phase_counts.get(it["phase"], 0) + 1
|
||||
for p in ["spec", "build", "review", "merged", "blocked", "awaiting_human"]:
|
||||
phase_counts.setdefault(p, 0)
|
||||
groups.append({
|
||||
"project": project_name,
|
||||
"items": project_items,
|
||||
"phase_counts": phase_counts,
|
||||
})
|
||||
return {
|
||||
"groups": groups,
|
||||
"total_items": len(items),
|
||||
"total_projects": len(groups),
|
||||
}
|
||||
|
||||
total = len(items)
|
||||
items = items[offset : offset + limit]
|
||||
|
||||
@@ -225,6 +352,180 @@ def list_items(
|
||||
}
|
||||
|
||||
|
||||
@app.post("/v1/items")
|
||||
def ingest_story(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""P5: in-memory ingest. Idempotent on (project, story_id)."""
|
||||
# Mirror the IngestStoryRequest Pydantic validation. We do this
|
||||
# by hand because the fixture shouldn't depend on src/damascus/.
|
||||
for field, lo, hi in [
|
||||
("project", 1, 64),
|
||||
("story_id", 1, 128),
|
||||
("title", 1, 255),
|
||||
]:
|
||||
v = body.get(field, "")
|
||||
if not isinstance(v, str) or not (lo <= len(v) <= hi):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"{field} must be a string of length {lo}..{hi}",
|
||||
)
|
||||
file_scope = body.get("file_scope", [])
|
||||
if not isinstance(file_scope, list) or not all(
|
||||
isinstance(s, str) for s in file_scope
|
||||
):
|
||||
raise HTTPException(status_code=422, detail="file_scope must be list[str]")
|
||||
priority = body.get("priority", 100)
|
||||
if not isinstance(priority, int) or not (0 <= priority <= 1000):
|
||||
raise HTTPException(status_code=422, detail="priority must be int 0..1000")
|
||||
budget_cycles = body.get("budget_cycles", 3)
|
||||
if not isinstance(budget_cycles, int) or not (1 <= budget_cycles <= 10):
|
||||
raise HTTPException(status_code=422, detail="budget_cycles must be int 1..10")
|
||||
|
||||
project = body["project"]
|
||||
story_id = body["story_id"]
|
||||
|
||||
# Idempotent: same (project, story_id) returns the existing row
|
||||
# with created=False, matching the contract IngestStoryResponse.
|
||||
for existing in ITEMS.values():
|
||||
if existing["project"] == project and existing["story_id"] == story_id:
|
||||
return {"item": existing, "created": False}
|
||||
|
||||
new_id = str(uuid.uuid4())
|
||||
now = now_iso()
|
||||
new_item: dict[str, Any] = {
|
||||
"id": new_id,
|
||||
"project": project,
|
||||
"story_id": story_id,
|
||||
"title": body["title"],
|
||||
"phase": "spec",
|
||||
"file_scope": list(file_scope),
|
||||
"attempts": 0,
|
||||
"budget_cycles": budget_cycles,
|
||||
"priority": priority,
|
||||
"base_commit": None,
|
||||
"branch": None,
|
||||
"pr_url": None,
|
||||
"last_verdict": None,
|
||||
"last_feedback": None,
|
||||
"spec_path": None,
|
||||
"wiki_pin": None,
|
||||
"claimed_by": None,
|
||||
"claimed_at": None,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"merged_at": None,
|
||||
}
|
||||
ITEMS[new_id] = new_item
|
||||
return {"item": new_item, "created": True}
|
||||
|
||||
|
||||
@app.get("/v1/issues")
|
||||
def list_issues(
|
||||
status: Optional[str] = None,
|
||||
project: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
issues = list(ISSUES.values())
|
||||
if status:
|
||||
issues = [i for i in issues if i["status"] == status]
|
||||
if project:
|
||||
item_ids_for_project = {
|
||||
it["id"] for it in ITEMS.values() if it["project"] == project
|
||||
}
|
||||
issues = [i for i in issues if i["work_item_id"] in item_ids_for_project]
|
||||
issues.sort(key=lambda i: i["created_at"], reverse=True)
|
||||
total = len(issues)
|
||||
issues = issues[offset : offset + limit]
|
||||
return {"issues": issues, "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@app.post("/v1/issues/{issue_id}/answer")
|
||||
def answer_issue(issue_id: str, body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""P5: mark an issue answered. Returns AnswerIssueResponse."""
|
||||
if issue_id not in ISSUES:
|
||||
raise HTTPException(status_code=404, detail="not_found")
|
||||
answer = body.get("answer", "")
|
||||
if not isinstance(answer, str) or not (1 <= len(answer) <= 10_000):
|
||||
raise HTTPException(
|
||||
status_code=422, detail="answer must be a string of length 1..10000"
|
||||
)
|
||||
issue = ISSUES[issue_id]
|
||||
now = now_iso()
|
||||
issue["answer"] = answer
|
||||
issue["status"] = "answered"
|
||||
issue["answered_at"] = now
|
||||
# Mirror: in the real API, answering transitions the parent
|
||||
# work item to 'spec' and resets attempts. We don't replicate
|
||||
# the side effects here — the e2e just needs the issue's status
|
||||
# to flip so the drawer re-renders with the open-issues list
|
||||
# empty.
|
||||
return {
|
||||
"id": issue["id"],
|
||||
"work_item_id": issue["work_item_id"],
|
||||
"question": issue["question"],
|
||||
"answer": issue["answer"],
|
||||
"status": issue["status"],
|
||||
"created_at": issue["created_at"],
|
||||
"answered_at": issue["answered_at"],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/v1/cost")
|
||||
def cost_summary(
|
||||
project: Optional[str] = None,
|
||||
since: Optional[str] = None,
|
||||
until: Optional[str] = None,
|
||||
days: int = 7,
|
||||
) -> dict[str, Any]:
|
||||
"""P5: synthetic 7-day cost summary. Deterministic for e2e asserts.
|
||||
|
||||
Real data shape mirrors the Pydantic CostSummaryResponse. Numbers
|
||||
are picked so the CostSparkline widget has a non-empty polyline
|
||||
to assert on, with one clearly-higher day to make the visual
|
||||
shape obvious.
|
||||
"""
|
||||
if not (1 <= days <= 365):
|
||||
raise HTTPException(status_code=422, detail="days must be 1..365")
|
||||
# Build a synthetic `by_day` window: [today-days+1, today].
|
||||
# Use an arbitrary fixed reference date so the e2e test is
|
||||
# deterministic (real data is server-time-derived).
|
||||
today = datetime(2026, 6, 24, tzinfo=timezone.utc)
|
||||
by_day: dict[str, str] = {}
|
||||
total = 0.0
|
||||
# Pattern: small / small / small / SPIKE / medium / medium / medium
|
||||
pattern_usd = [0.05, 0.07, 0.04, 1.20, 0.30, 0.28, 0.35]
|
||||
for i in range(days):
|
||||
d = today - _td(days=days - 1 - i)
|
||||
key = d.date().isoformat()
|
||||
usd = pattern_usd[i] if i < len(pattern_usd) else 0.20
|
||||
by_day[key] = f"{usd:.6f}"
|
||||
total += usd
|
||||
by_project = {
|
||||
"wh40k-pc": f"{total * 0.6:.6f}",
|
||||
"iso-tank-arena": f"{total * 0.4:.6f}",
|
||||
}
|
||||
by_model = {
|
||||
"claude-sonnet-4": f"{total * 0.7:.6f}",
|
||||
"claude-haiku-4-5": f"{total * 0.3:.6f}",
|
||||
}
|
||||
window_start = (today - _td(days=days - 1)).isoformat()
|
||||
window_end = today.isoformat()
|
||||
return {
|
||||
"total_usd": f"{total:.6f}",
|
||||
"by_project": by_project,
|
||||
"by_model": by_model,
|
||||
"by_day": by_day,
|
||||
"window_start": window_start,
|
||||
"window_end": window_end,
|
||||
}
|
||||
|
||||
|
||||
# Helper: timedelta in seconds for the synthetic cost window
|
||||
def _td(*, days: int = 0, hours: int = 0) -> Any:
|
||||
from datetime import timedelta
|
||||
return timedelta(days=days, hours=hours)
|
||||
|
||||
|
||||
@app.get("/v1/items/{item_id}")
|
||||
def get_item(item_id: str) -> dict[str, Any]:
|
||||
if item_id not in ITEMS:
|
||||
@@ -274,4 +575,6 @@ def stats() -> dict[str, Any]:
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="127.0.0.1", port=int(os.environ.get("PORT", 9110)))
|
||||
# P5: default to 9111 to match the playwright config; CI / clean
|
||||
# hosts override with PORT=9110.
|
||||
uvicorn.run(app, host="127.0.0.1", port=int(os.environ.get("PORT", 9111)))
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
// Run:
|
||||
// # In one terminal:
|
||||
// pip install fastapi uvicorn
|
||||
// cd ui && uvicorn tests.e2e.fixture_api:app --port 9110
|
||||
// cd ui && uvicorn tests.e2e.fixture_api:app --port 9111
|
||||
// # In another:
|
||||
// cd ui && VITE_API_BASE_URL=http://127.0.0.1:9110 npm run build
|
||||
// cd ui && VITE_API_BASE_URL=http://127.0.0.1:9111 npm run build
|
||||
// cd ui && npm run test:e2e
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
@@ -34,7 +34,9 @@ test("dashboard renders phase counts and open issues count", async ({ page }) =>
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("dashboard-root")).toBeVisible();
|
||||
await expect(page.getByTestId("phase-bar")).toBeVisible();
|
||||
await expect(page.getByTestId("open-issues-count")).toHaveText("1");
|
||||
// The fixture seeds 2 open human_issues (the v1 build item + the
|
||||
// P5 awaiting_human item).
|
||||
await expect(page.getByTestId("open-issues-count")).toHaveText("2");
|
||||
});
|
||||
|
||||
test("items page table renders with >= 1 row", async ({ page }) => {
|
||||
|
||||
106
ui/tests/e2e/test_ui_v2.spec.ts
Normal file
106
ui/tests/e2e/test_ui_v2.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
// Playwright e2e tests for damascus-ui v2 (P5).
|
||||
//
|
||||
// Three scenarios per the task body:
|
||||
// 1. Ingest flow: fill form, submit, redirect to /items/:id
|
||||
// 2. Dashboard renders the four self-improving widgets
|
||||
// 3. Answer form: submit, drawer reflects answered state
|
||||
//
|
||||
// Plus a mobile-viewport smoke test (375x667) that exercises the
|
||||
// same /ingest path with a small screen so the user-preference
|
||||
// "no fixed pixel widths" rule is checked in CI.
|
||||
//
|
||||
// The fixture seeds one awaiting_human item (AWAITING_ITEM_ID) with
|
||||
// one open issue (AWAITING_ISSUE_ID) so the answer-form scenario has
|
||||
// something to target. The fixture also returns 7 days of cost data
|
||||
// for the sparkline.
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await context.clearCookies();
|
||||
});
|
||||
|
||||
test("ingest form: fill, submit, redirect to /items/:id", async ({ page }) => {
|
||||
await page.goto("/#/ingest");
|
||||
await expect(page.getByTestId("ingest-root")).toBeVisible();
|
||||
await page.getByTestId("field-project").fill("e2e-test");
|
||||
await page.getByTestId("field-story_id").fill("story-1");
|
||||
await page.getByTestId("field-title").fill("E2E test story");
|
||||
await page
|
||||
.getByTestId("field-file_scope")
|
||||
.fill("src/a.ts, src/b.ts");
|
||||
await page.getByTestId("field-priority").fill("200");
|
||||
await page.getByTestId("field-budget_cycles").fill("4");
|
||||
await page.getByTestId("ingest-submit").click();
|
||||
// Fixture generates a UUID, so we match the URL pattern.
|
||||
await expect(page).toHaveURL(/#\/items\/[0-9a-f-]{36}$/);
|
||||
});
|
||||
|
||||
test("dashboard renders all four self-improving widgets", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("dashboard-root")).toBeVisible();
|
||||
await expect(page.getByTestId("phase-bar")).toBeVisible();
|
||||
await expect(page.getByTestId("open-issues-card")).toBeVisible();
|
||||
await expect(page.getByTestId("blocked-items-root")).toBeVisible();
|
||||
await expect(page.getByTestId("cost-sparkline-root")).toBeVisible();
|
||||
});
|
||||
|
||||
test("dashboard renders the project-grouped view with one tab per project", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
const tabs = page.getByTestId("project-tabs");
|
||||
await expect(tabs).toBeVisible();
|
||||
// The fixture has 2 projects: wh40k-pc + iso-tank-arena.
|
||||
await expect(
|
||||
page.getByTestId("project-tab-wh40k-pc"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("project-tab-iso-tank-arena"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("answer form: submit, drawer reflects answered state", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Navigate to the items table, find the awaiting_human row, open it.
|
||||
await page.goto("/#/items");
|
||||
const awaitingRow = page
|
||||
.locator('[data-testid="items-grid"] .MuiDataGrid-row')
|
||||
.filter({ hasText: "awaiting-story-01" });
|
||||
await awaitingRow.click();
|
||||
await expect(page.getByTestId("item-drawer")).toBeVisible();
|
||||
await expect(page.getByTestId("answer-form")).toBeVisible();
|
||||
|
||||
await page.getByTestId("answer-text").fill("Catppuccin Mocha please");
|
||||
await page.getByTestId("answer-submit").click();
|
||||
|
||||
// After submit, the open-issues list should be empty (the
|
||||
// answered issue disappears via the useAnswerIssue.onSuccess
|
||||
// invalidation).
|
||||
await expect(page.getByTestId("open-issues-list")).toHaveCount(0, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("mobile viewport: ingest form is usable at 375x667", async ({
|
||||
browser,
|
||||
}) => {
|
||||
// The task body asks the mobile viewport pass: no fixed pixel
|
||||
// widths. We construct a fresh context at the small viewport and
|
||||
// re-run the ingest flow to confirm the form is usable.
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 375, height: 667 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto("/#/ingest");
|
||||
await expect(page.getByTestId("ingest-root")).toBeVisible();
|
||||
await page.getByTestId("field-project").fill("e2e-mobile");
|
||||
await page.getByTestId("field-story_id").fill("m-1");
|
||||
await page.getByTestId("field-title").fill("Mobile e2e test");
|
||||
await page.getByTestId("ingest-submit").click();
|
||||
await expect(page).toHaveURL(/#\/items\/[0-9a-f-]{36}$/);
|
||||
await context.close();
|
||||
});
|
||||
127
ui/tests/unit/BlockedItems.test.tsx
Normal file
127
ui/tests/unit/BlockedItems.test.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
// Unit tests for the BlockedItems widget (P5 §7).
|
||||
//
|
||||
// Renders one card per item currently in `blocked` phase, surfacing
|
||||
// the last_verdict + last_feedback so the operator can see WHY each
|
||||
// item is stuck without opening the drawer. Each card is clickable →
|
||||
// drawer for that work item.
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, fireEvent } from "@testing-library/react";
|
||||
import { ThemeProvider, createTheme } from "@mui/material";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BlockedItems } from "../../src/widgets/BlockedItems";
|
||||
import * as queries from "../../src/api/queries";
|
||||
import * as router from "../../src/router";
|
||||
|
||||
vi.mock("../../src/api/queries", () => ({
|
||||
useListItems: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../src/router", () => ({
|
||||
setOpenItem: vi.fn(),
|
||||
}));
|
||||
|
||||
function wrap(node: React.ReactNode) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return (
|
||||
<QueryClientProvider client={qc}>
|
||||
<ThemeProvider theme={createTheme()}>{node}</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const baseItem = {
|
||||
file_scope: [] as string[],
|
||||
attempts: 3,
|
||||
budget_cycles: 3,
|
||||
priority: 100,
|
||||
base_commit: null,
|
||||
branch: null,
|
||||
pr_url: null,
|
||||
spec_path: null,
|
||||
wiki_pin: null,
|
||||
claimed_by: null,
|
||||
claimed_at: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
merged_at: null,
|
||||
};
|
||||
|
||||
describe("BlockedItems widget (P5)", () => {
|
||||
it("renders no cards when no items are blocked", () => {
|
||||
(queries.useListItems as any).mockReturnValue({
|
||||
data: { items: [], total: 0, limit: 10, offset: 0 },
|
||||
isLoading: false,
|
||||
});
|
||||
const { queryByTestId } = render(wrap(<BlockedItems />));
|
||||
expect(queryByTestId("blocked-items-root")).toBeTruthy();
|
||||
expect(queryByTestId("blocked-items-card")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders one card per blocked item showing verdict and feedback", () => {
|
||||
(queries.useListItems as any).mockReturnValue({
|
||||
data: {
|
||||
total: 2,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
items: [
|
||||
{
|
||||
id: "b1",
|
||||
project: "p",
|
||||
story_id: "s1",
|
||||
title: "T1",
|
||||
phase: "blocked",
|
||||
last_verdict: "tests_failed",
|
||||
last_feedback: "AssertionError: expected 0.0, got 0.014",
|
||||
...baseItem,
|
||||
},
|
||||
{
|
||||
id: "b2",
|
||||
project: "p",
|
||||
story_id: "s2",
|
||||
title: "T2",
|
||||
phase: "blocked",
|
||||
last_verdict: "spec_ambiguous",
|
||||
last_feedback: "ambiguous req X",
|
||||
...baseItem,
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
const { getByTestId, getAllByTestId } = render(wrap(<BlockedItems />));
|
||||
expect(getByTestId("blocked-items-root")).toBeTruthy();
|
||||
const cards = getAllByTestId("blocked-items-card");
|
||||
expect(cards).toHaveLength(2);
|
||||
expect(getByTestId("blocked-items-card-b1").textContent).toContain("tests_failed");
|
||||
expect(getByTestId("blocked-items-card-b2").textContent).toContain("spec_ambiguous");
|
||||
expect(getByTestId("blocked-items-card-b1").textContent).toContain("AssertionError");
|
||||
});
|
||||
|
||||
it("clicking a card opens the drawer for that item", () => {
|
||||
(queries.useListItems as any).mockReturnValue({
|
||||
data: {
|
||||
total: 1,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
items: [
|
||||
{
|
||||
id: "b1",
|
||||
project: "p",
|
||||
story_id: "s1",
|
||||
title: "T1",
|
||||
phase: "blocked",
|
||||
last_verdict: "tests_failed",
|
||||
last_feedback: "boom",
|
||||
...baseItem,
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
const { getByTestId } = render(wrap(<BlockedItems />));
|
||||
fireEvent.click(getByTestId("blocked-items-card-b1"));
|
||||
expect(router.setOpenItem).toHaveBeenCalledWith("b1");
|
||||
});
|
||||
});
|
||||
51
ui/tests/unit/CostSparkline.test.tsx
Normal file
51
ui/tests/unit/CostSparkline.test.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
// Unit tests for the CostSparkline widget (P5 §7).
|
||||
//
|
||||
// Renders the last 7 days of cost (by_day from /v1/cost) as an inline
|
||||
// SVG polyline. Empty data renders a flat-line placeholder.
|
||||
//
|
||||
// Implementation note: we use a small inline SVG (no MUI X-Charts dep
|
||||
// — keeps the bundle small for a self-improving widget the operator
|
||||
// sees at a glance). The polyline string is space-separated "x,y"
|
||||
// pairs, one per day.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { ThemeProvider, createTheme } from "@mui/material";
|
||||
import { CostSparkline } from "../../src/widgets/CostSparkline";
|
||||
|
||||
function wrap(node: React.ReactNode) {
|
||||
return <ThemeProvider theme={createTheme()}>{node}</ThemeProvider>;
|
||||
}
|
||||
|
||||
describe("CostSparkline widget (P5)", () => {
|
||||
it("renders an SVG with one polyline point per day", () => {
|
||||
const byDay = {
|
||||
"2026-06-18": "0.10",
|
||||
"2026-06-19": "0.20",
|
||||
"2026-06-20": "0.15",
|
||||
};
|
||||
const { getByTestId } = render(wrap(<CostSparkline byDay={byDay} />));
|
||||
const poly = getByTestId(
|
||||
"cost-sparkline-polyline",
|
||||
) as unknown as SVGPolylineElement;
|
||||
expect(poly).toBeTruthy();
|
||||
// 3 points => "x1,y1 x2,y2 x3,y3"
|
||||
const points = poly.getAttribute("points")!.trim().split(/\s+/);
|
||||
expect(points).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("renders a flat-line empty state when byDay is empty", () => {
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
wrap(<CostSparkline byDay={{}} />),
|
||||
);
|
||||
expect(getByTestId("cost-sparkline-empty")).toBeTruthy();
|
||||
expect(queryByTestId("cost-sparkline-polyline")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the root with data-testid cost-sparkline-root", () => {
|
||||
const { getByTestId } = render(
|
||||
wrap(<CostSparkline byDay={{ "2026-06-20": "0.5" }} />),
|
||||
);
|
||||
expect(getByTestId("cost-sparkline-root")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
138
ui/tests/unit/Dashboard.test.tsx
Normal file
138
ui/tests/unit/Dashboard.test.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
// Unit tests for the Dashboard route (P5).
|
||||
//
|
||||
// The P5 Dashboard composes the four self-improving widgets
|
||||
// (PhaseBar, OpenIssues, BlockedItems, CostSparkline) at the top and
|
||||
// a project-grouped view (Tabs) below, driven by useGroupedItems.
|
||||
// We mock the queries module so the test doesn't make any network
|
||||
// calls and we can assert composition directly.
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, within } from "@testing-library/react";
|
||||
import { ThemeProvider, createTheme } from "@mui/material";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Dashboard } from "../../src/routes/Dashboard";
|
||||
import * as queries from "../../src/api/queries";
|
||||
|
||||
vi.mock("../../src/api/queries", () => ({
|
||||
useStats: vi.fn(),
|
||||
useGroupedItems: vi.fn(),
|
||||
useCostSummary: vi.fn(),
|
||||
useListItems: vi.fn(),
|
||||
useOpenIssues: vi.fn(),
|
||||
}));
|
||||
|
||||
function wrap(node: React.ReactNode) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return (
|
||||
<QueryClientProvider client={qc}>
|
||||
<ThemeProvider theme={createTheme()}>{node}</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Dashboard (P5)", () => {
|
||||
it("renders the four self-improving widgets", () => {
|
||||
(queries.useStats as any).mockReturnValue({
|
||||
data: {
|
||||
phase_counts: { spec: 1, build: 1, review: 0, merged: 1, blocked: 1, awaiting_human: 1 },
|
||||
open_human_issues: 1,
|
||||
active_claims: 0,
|
||||
last_cycle_at: null,
|
||||
cost_today_usd: "0.00",
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
(queries.useGroupedItems as any).mockReturnValue({
|
||||
data: { groups: [], total_items: 0, total_projects: 0 },
|
||||
isLoading: false,
|
||||
});
|
||||
(queries.useCostSummary as any).mockReturnValue({
|
||||
data: {
|
||||
total_usd: "0",
|
||||
by_project: {},
|
||||
by_model: {},
|
||||
by_day: {},
|
||||
window_start: "2026-06-18T00:00:00Z",
|
||||
window_end: "2026-06-24T00:00:00Z",
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
(queries.useListItems as any).mockReturnValue({
|
||||
data: { items: [], total: 0, limit: 10, offset: 0 },
|
||||
isLoading: false,
|
||||
});
|
||||
(queries.useOpenIssues as any).mockReturnValue({
|
||||
data: { issues: [], total: 0, limit: 5, offset: 0 },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const { getByTestId } = render(wrap(<Dashboard />));
|
||||
expect(getByTestId("dashboard-root")).toBeTruthy();
|
||||
expect(getByTestId("phase-bar")).toBeTruthy();
|
||||
expect(getByTestId("open-issues-card")).toBeTruthy();
|
||||
expect(getByTestId("blocked-items-root")).toBeTruthy();
|
||||
expect(getByTestId("cost-sparkline-root")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders one project tab per group", () => {
|
||||
(queries.useStats as any).mockReturnValue({
|
||||
data: {
|
||||
phase_counts: { spec: 0, build: 0, review: 0, merged: 0, blocked: 0, awaiting_human: 0 },
|
||||
open_human_issues: 0,
|
||||
active_claims: 0,
|
||||
last_cycle_at: null,
|
||||
cost_today_usd: "0",
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
(queries.useGroupedItems as any).mockReturnValue({
|
||||
data: {
|
||||
groups: [
|
||||
{
|
||||
project: "wh40k-pc",
|
||||
items: [],
|
||||
phase_counts: { spec: 0, build: 0, review: 0, merged: 0, blocked: 0, awaiting_human: 0 },
|
||||
},
|
||||
{
|
||||
project: "iso-tank-arena",
|
||||
items: [],
|
||||
phase_counts: { spec: 0, build: 0, review: 0, merged: 0, blocked: 0, awaiting_human: 0 },
|
||||
},
|
||||
],
|
||||
total_items: 0,
|
||||
total_projects: 2,
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
(queries.useCostSummary as any).mockReturnValue({
|
||||
data: {
|
||||
total_usd: "0",
|
||||
by_project: {},
|
||||
by_model: {},
|
||||
by_day: {},
|
||||
window_start: "",
|
||||
window_end: "",
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
(queries.useListItems as any).mockReturnValue({
|
||||
data: { items: [], total: 0, limit: 10, offset: 0 },
|
||||
isLoading: false,
|
||||
});
|
||||
(queries.useOpenIssues as any).mockReturnValue({
|
||||
data: { issues: [], total: 0, limit: 5, offset: 0 },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const { getByTestId } = render(wrap(<Dashboard />));
|
||||
const tabs = getByTestId("project-tabs");
|
||||
const tabList = within(tabs).getAllByRole("tab");
|
||||
expect(tabList).toHaveLength(2);
|
||||
expect(tabList[0].textContent).toContain("wh40k-pc");
|
||||
expect(tabList[1].textContent).toContain("iso-tank-arena");
|
||||
});
|
||||
});
|
||||
172
ui/tests/unit/Ingest.test.tsx
Normal file
172
ui/tests/unit/Ingest.test.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
// Unit tests for the Ingest form route (P5).
|
||||
//
|
||||
// Form fields mirror IngestStoryRequest:
|
||||
// - project (1..64)
|
||||
// - story_id (1..128)
|
||||
// - title (1..255)
|
||||
// - file_scope (multiline, comma-separated → string[] on submit)
|
||||
// - priority (0..1000, default 100)
|
||||
// - budget_cycles (1..10, default 3)
|
||||
//
|
||||
// On submit:
|
||||
// - per-field validation runs (matches Pydantic min/max length, ge/le)
|
||||
// - successful submit calls useIngestStory.mutateAsync(parsedBody)
|
||||
// - on success, navigate(`/items/${item.id}`)
|
||||
// - on error, surface as <Alert severity="error">
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { ThemeProvider, createTheme } from "@mui/material";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Ingest } from "../../src/routes/Ingest";
|
||||
import * as queries from "../../src/api/queries";
|
||||
|
||||
vi.mock("../../src/api/queries", () => ({
|
||||
useIngestStory: vi.fn(),
|
||||
}));
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
vi.mock("../../src/router", () => ({
|
||||
navigate: (...args: unknown[]) => navigateMock(...args),
|
||||
useRoute: vi.fn(() => ({ name: "ingest" })),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
navigateMock.mockReset();
|
||||
});
|
||||
|
||||
function wrap(node: React.ReactNode) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return (
|
||||
<QueryClientProvider client={qc}>
|
||||
<ThemeProvider theme={createTheme()}>{node}</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Ingest route (P5)", () => {
|
||||
it("renders all six fields", () => {
|
||||
(queries.useIngestStory as any).mockReturnValue({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
error: null,
|
||||
});
|
||||
const { getByTestId } = render(wrap(<Ingest />));
|
||||
for (const f of [
|
||||
"project",
|
||||
"story_id",
|
||||
"title",
|
||||
"file_scope",
|
||||
"priority",
|
||||
"budget_cycles",
|
||||
]) {
|
||||
expect(getByTestId(`field-${f}`)).toBeTruthy();
|
||||
}
|
||||
expect(getByTestId("ingest-submit")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("blocks submit when project is empty (Pydantic min_length=1)", () => {
|
||||
(queries.useIngestStory as any).mockReturnValue({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
error: null,
|
||||
});
|
||||
const { getByTestId, queryAllByText } = render(wrap(<Ingest />));
|
||||
fireEvent.change(getByTestId("field-story_id"), { target: { value: "s1" } });
|
||||
fireEvent.change(getByTestId("field-title"), { target: { value: "T1" } });
|
||||
fireEvent.click(getByTestId("ingest-submit"));
|
||||
// The validation error appears in both the field's helperText and
|
||||
// the trailing FormHelperText list, so queryAllByText is the right
|
||||
// matcher.
|
||||
const matches = queryAllByText(/project is required/i);
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("submits with parsed body and navigates on success", async () => {
|
||||
const mutate = vi.fn().mockResolvedValue({
|
||||
item: { id: "abc-123-def-456" },
|
||||
created: true,
|
||||
});
|
||||
(queries.useIngestStory as any).mockReturnValue({
|
||||
mutateAsync: mutate,
|
||||
isPending: false,
|
||||
error: null,
|
||||
});
|
||||
const { getByTestId } = render(wrap(<Ingest />));
|
||||
fireEvent.change(getByTestId("field-project"), { target: { value: "p1" } });
|
||||
fireEvent.change(getByTestId("field-story_id"), { target: { value: "s1" } });
|
||||
fireEvent.change(getByTestId("field-title"), { target: { value: "T1" } });
|
||||
fireEvent.change(getByTestId("field-file_scope"), {
|
||||
target: { value: "src/a.ts, src/b.ts" },
|
||||
});
|
||||
fireEvent.change(getByTestId("field-priority"), { target: { value: "200" } });
|
||||
fireEvent.change(getByTestId("field-budget_cycles"), { target: { value: "4" } });
|
||||
fireEvent.click(getByTestId("ingest-submit"));
|
||||
await waitFor(() => expect(mutate).toHaveBeenCalled());
|
||||
const call = mutate.mock.calls[0][0];
|
||||
expect(call).toEqual({
|
||||
project: "p1",
|
||||
story_id: "s1",
|
||||
title: "T1",
|
||||
file_scope: ["src/a.ts", "src/b.ts"],
|
||||
priority: 200,
|
||||
budget_cycles: 4,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(navigateMock).toHaveBeenCalledWith("/items/abc-123-def-456"),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects priority out of range (Pydantic ge=0, le=1000)", () => {
|
||||
(queries.useIngestStory as any).mockReturnValue({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
error: null,
|
||||
});
|
||||
const { getByTestId, queryAllByText } = render(wrap(<Ingest />));
|
||||
fireEvent.change(getByTestId("field-project"), { target: { value: "p1" } });
|
||||
fireEvent.change(getByTestId("field-story_id"), { target: { value: "s1" } });
|
||||
fireEvent.change(getByTestId("field-title"), { target: { value: "T1" } });
|
||||
fireEvent.change(getByTestId("field-priority"), { target: { value: "2000" } });
|
||||
fireEvent.click(getByTestId("ingest-submit"));
|
||||
expect(
|
||||
queryAllByText(/priority must be an integer between 0 and 1000/i).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("rejects budget_cycles out of range (Pydantic ge=1, le=10)", () => {
|
||||
(queries.useIngestStory as any).mockReturnValue({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
error: null,
|
||||
});
|
||||
const { getByTestId, queryAllByText } = render(wrap(<Ingest />));
|
||||
fireEvent.change(getByTestId("field-project"), { target: { value: "p1" } });
|
||||
fireEvent.change(getByTestId("field-story_id"), { target: { value: "s1" } });
|
||||
fireEvent.change(getByTestId("field-title"), { target: { value: "T1" } });
|
||||
fireEvent.change(getByTestId("field-budget_cycles"), { target: { value: "99" } });
|
||||
fireEvent.click(getByTestId("ingest-submit"));
|
||||
expect(
|
||||
queryAllByText(/budget[ _]cycles must be an integer between 1 and 10/i).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows a network error alert when mutateAsync rejects", async () => {
|
||||
const mutate = vi.fn().mockRejectedValue(new Error("network down"));
|
||||
(queries.useIngestStory as any).mockReturnValue({
|
||||
mutateAsync: mutate,
|
||||
isPending: false,
|
||||
error: null,
|
||||
});
|
||||
const { getByTestId, getByText } = render(wrap(<Ingest />));
|
||||
fireEvent.change(getByTestId("field-project"), { target: { value: "p1" } });
|
||||
fireEvent.change(getByTestId("field-story_id"), { target: { value: "s1" } });
|
||||
fireEvent.change(getByTestId("field-title"), { target: { value: "T1" } });
|
||||
fireEvent.click(getByTestId("ingest-submit"));
|
||||
await waitFor(() =>
|
||||
expect(getByText(/network down/i)).toBeTruthy(),
|
||||
);
|
||||
});
|
||||
});
|
||||
229
ui/tests/unit/ItemDrawer.test.tsx
Normal file
229
ui/tests/unit/ItemDrawer.test.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
// Unit tests for the ItemDrawer answer form (P5 §7).
|
||||
//
|
||||
// The answer form renders inside the drawer ONLY when:
|
||||
// - item.phase === 'awaiting_human'
|
||||
// - open_issues.length > 0
|
||||
// And it targets the FIRST open issue (UI is per-item, not per-issue
|
||||
// — answering one unblocks the parent work item).
|
||||
//
|
||||
// Submit calls useAnswerIssue(issue.id) with the textarea value.
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { ThemeProvider, createTheme } from "@mui/material";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ItemDrawer } from "../../src/routes/ItemDrawer";
|
||||
import * as queries from "../../src/api/queries";
|
||||
import * as router from "../../src/router";
|
||||
|
||||
vi.mock("../../src/api/queries", () => ({
|
||||
useItemDetail: vi.fn(),
|
||||
useRecentEvents: vi.fn(),
|
||||
useAnswerIssue: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../src/router", () => ({
|
||||
useOpenItemId: vi.fn(),
|
||||
setOpenItem: vi.fn(),
|
||||
}));
|
||||
|
||||
const AWAITING_ID = "55555555-5555-4555-8555-555555555555";
|
||||
const ISSUE_ID = "66666666-6666-4666-8666-666666666666";
|
||||
|
||||
function wrap(node: React.ReactNode) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return (
|
||||
<QueryClientProvider client={qc}>
|
||||
<ThemeProvider theme={createTheme()}>{node}</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const baseItem = {
|
||||
id: AWAITING_ID,
|
||||
project: "wh40k-pc",
|
||||
story_id: "awaiting-story-01",
|
||||
title: "Pick the palette",
|
||||
file_scope: [] as string[],
|
||||
attempts: 1,
|
||||
budget_cycles: 3,
|
||||
priority: 250,
|
||||
base_commit: "abc1234",
|
||||
branch: "feat/palette",
|
||||
pr_url: null,
|
||||
last_verdict: "spec_ambiguous",
|
||||
last_feedback: "Spec asks for 'discord-inspired' but no palette.",
|
||||
spec_path: null,
|
||||
wiki_pin: null,
|
||||
claimed_by: "orch-1",
|
||||
claimed_at: "2026-06-24T12:00:00+00:00",
|
||||
created_at: "2026-06-24T11:30:00+00:00",
|
||||
updated_at: "2026-06-24T12:30:00+00:00",
|
||||
merged_at: null,
|
||||
};
|
||||
|
||||
describe("ItemDrawer answer form (P5)", () => {
|
||||
it("renders the answer form when phase is awaiting_human and there are open issues", () => {
|
||||
(router.useOpenItemId as any).mockReturnValue(AWAITING_ID);
|
||||
(queries.useItemDetail as any).mockReturnValue({
|
||||
data: {
|
||||
item: { ...baseItem, phase: "awaiting_human" },
|
||||
open_issues: [
|
||||
{
|
||||
id: ISSUE_ID,
|
||||
work_item_id: AWAITING_ID,
|
||||
question: "Which palette?",
|
||||
answer: null,
|
||||
status: "open",
|
||||
created_at: "2026-06-24T12:30:00+00:00",
|
||||
answered_at: null,
|
||||
},
|
||||
],
|
||||
recent_events: [],
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
(queries.useRecentEvents as any).mockReturnValue({
|
||||
data: { events: [], next_since_id: null },
|
||||
isLoading: false,
|
||||
});
|
||||
(queries.useAnswerIssue as any).mockReturnValue({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
const { getByTestId } = render(wrap(<ItemDrawer />));
|
||||
expect(getByTestId("answer-form")).toBeTruthy();
|
||||
expect(getByTestId("answer-text")).toBeTruthy();
|
||||
expect(getByTestId("answer-submit")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does NOT render the answer form for non-awaiting items", () => {
|
||||
(router.useOpenItemId as any).mockReturnValue(AWAITING_ID);
|
||||
(queries.useItemDetail as any).mockReturnValue({
|
||||
data: {
|
||||
item: { ...baseItem, phase: "build" },
|
||||
open_issues: [],
|
||||
recent_events: [],
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
(queries.useRecentEvents as any).mockReturnValue({
|
||||
data: { events: [], next_since_id: null },
|
||||
isLoading: false,
|
||||
});
|
||||
(queries.useAnswerIssue as any).mockReturnValue({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
const { queryByTestId } = render(wrap(<ItemDrawer />));
|
||||
expect(queryByTestId("answer-form")).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT render the answer form for awaiting_human with no open issues", () => {
|
||||
(router.useOpenItemId as any).mockReturnValue(AWAITING_ID);
|
||||
(queries.useItemDetail as any).mockReturnValue({
|
||||
data: {
|
||||
item: { ...baseItem, phase: "awaiting_human" },
|
||||
open_issues: [],
|
||||
recent_events: [],
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
(queries.useRecentEvents as any).mockReturnValue({
|
||||
data: { events: [], next_since_id: null },
|
||||
isLoading: false,
|
||||
});
|
||||
(queries.useAnswerIssue as any).mockReturnValue({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
const { queryByTestId } = render(wrap(<ItemDrawer />));
|
||||
expect(queryByTestId("answer-form")).toBeNull();
|
||||
});
|
||||
|
||||
it("submit calls useAnswerIssue(issue.id).mutateAsync with the typed answer", async () => {
|
||||
(router.useOpenItemId as any).mockReturnValue(AWAITING_ID);
|
||||
(queries.useItemDetail as any).mockReturnValue({
|
||||
data: {
|
||||
item: { ...baseItem, phase: "awaiting_human" },
|
||||
open_issues: [
|
||||
{
|
||||
id: ISSUE_ID,
|
||||
work_item_id: AWAITING_ID,
|
||||
question: "Which palette?",
|
||||
answer: null,
|
||||
status: "open",
|
||||
created_at: "2026-06-24T12:30:00+00:00",
|
||||
answered_at: null,
|
||||
},
|
||||
],
|
||||
recent_events: [],
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
(queries.useRecentEvents as any).mockReturnValue({
|
||||
data: { events: [], next_since_id: null },
|
||||
isLoading: false,
|
||||
});
|
||||
const mutate = vi.fn().mockResolvedValue({
|
||||
id: ISSUE_ID,
|
||||
work_item_id: AWAITING_ID,
|
||||
question: "Which palette?",
|
||||
answer: "Catppuccin Mocha",
|
||||
status: "answered",
|
||||
created_at: "2026-06-24T12:30:00+00:00",
|
||||
answered_at: "2026-06-24T13:00:00+00:00",
|
||||
});
|
||||
(queries.useAnswerIssue as any).mockReturnValue({
|
||||
mutateAsync: mutate,
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
const { getByTestId } = render(wrap(<ItemDrawer />));
|
||||
fireEvent.change(getByTestId("answer-text"), {
|
||||
target: { value: "Catppuccin Mocha" },
|
||||
});
|
||||
fireEvent.click(getByTestId("answer-submit"));
|
||||
await waitFor(() => expect(mutate).toHaveBeenCalledWith("Catppuccin Mocha"));
|
||||
});
|
||||
|
||||
it("blocks submit when the answer is empty", async () => {
|
||||
(router.useOpenItemId as any).mockReturnValue(AWAITING_ID);
|
||||
(queries.useItemDetail as any).mockReturnValue({
|
||||
data: {
|
||||
item: { ...baseItem, phase: "awaiting_human" },
|
||||
open_issues: [
|
||||
{
|
||||
id: ISSUE_ID,
|
||||
work_item_id: AWAITING_ID,
|
||||
question: "Which palette?",
|
||||
answer: null,
|
||||
status: "open",
|
||||
created_at: "2026-06-24T12:30:00+00:00",
|
||||
answered_at: null,
|
||||
},
|
||||
],
|
||||
recent_events: [],
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
(queries.useRecentEvents as any).mockReturnValue({
|
||||
data: { events: [], next_since_id: null },
|
||||
isLoading: false,
|
||||
});
|
||||
const mutate = vi.fn();
|
||||
(queries.useAnswerIssue as any).mockReturnValue({
|
||||
mutateAsync: mutate,
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
const { getByTestId, getByText } = render(wrap(<ItemDrawer />));
|
||||
fireEvent.click(getByTestId("answer-submit"));
|
||||
expect(mutate).not.toHaveBeenCalled();
|
||||
expect(getByText(/answer is required/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
114
ui/tests/unit/OpenIssues.test.tsx
Normal file
114
ui/tests/unit/OpenIssues.test.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
// Unit tests for the OpenIssues widget (P5 §7).
|
||||
//
|
||||
// The widget renders a count (from useStats) plus a list of the last
|
||||
// N open issues (from useOpenIssues). Each list item is clickable and
|
||||
// triggers setOpenItem(item.work_item_id) to open the drawer for the
|
||||
// parent work item.
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, fireEvent } from "@testing-library/react";
|
||||
import { ThemeProvider, createTheme } from "@mui/material";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { OpenIssues } from "../../src/widgets/OpenIssues";
|
||||
import * as queries from "../../src/api/queries";
|
||||
import * as router from "../../src/router";
|
||||
|
||||
vi.mock("../../src/api/queries", () => ({
|
||||
useStats: vi.fn(),
|
||||
useOpenIssues: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../src/router", () => ({
|
||||
setOpenItem: vi.fn(),
|
||||
navigate: vi.fn(),
|
||||
useRoute: vi.fn(),
|
||||
useOpenItemId: vi.fn(),
|
||||
useHashWrite: vi.fn(),
|
||||
}));
|
||||
|
||||
function wrap(node: React.ReactNode) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return (
|
||||
<QueryClientProvider client={qc}>
|
||||
<ThemeProvider theme={createTheme()}>{node}</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("OpenIssues widget (P5)", () => {
|
||||
it("renders the count from useStats", () => {
|
||||
(queries.useStats as any).mockReturnValue({
|
||||
data: { open_human_issues: 7 },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
(queries.useOpenIssues as any).mockReturnValue({
|
||||
data: { issues: [], total: 0, limit: 5, offset: 0 },
|
||||
isLoading: false,
|
||||
});
|
||||
const { getByTestId } = render(wrap(<OpenIssues />));
|
||||
expect(getByTestId("open-issues-count").textContent).toBe("7");
|
||||
});
|
||||
|
||||
it("renders the last 5 open issues, each clickable, calling setOpenItem with the work item id", () => {
|
||||
(queries.useStats as any).mockReturnValue({
|
||||
data: { open_human_issues: 2 },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
(queries.useOpenIssues as any).mockReturnValue({
|
||||
data: {
|
||||
total: 2,
|
||||
limit: 5,
|
||||
offset: 0,
|
||||
issues: [
|
||||
{
|
||||
id: "i1",
|
||||
work_item_id: "w-uuid-1",
|
||||
question: "Q1",
|
||||
answer: null,
|
||||
status: "open",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
answered_at: null,
|
||||
},
|
||||
{
|
||||
id: "i2",
|
||||
work_item_id: "w-uuid-2",
|
||||
question: "Q2",
|
||||
answer: null,
|
||||
status: "open",
|
||||
created_at: "2026-01-02T00:00:00Z",
|
||||
answered_at: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
const { getAllByTestId } = render(wrap(<OpenIssues />));
|
||||
const items = getAllByTestId("open-issues-item");
|
||||
expect(items).toHaveLength(2);
|
||||
|
||||
fireEvent.click(items[0]);
|
||||
expect(router.setOpenItem).toHaveBeenCalledWith("w-uuid-1");
|
||||
|
||||
fireEvent.click(items[1]);
|
||||
expect(router.setOpenItem).toHaveBeenCalledWith("w-uuid-2");
|
||||
});
|
||||
|
||||
it("renders a 'no open issues' empty state when count is zero", () => {
|
||||
(queries.useStats as any).mockReturnValue({
|
||||
data: { open_human_issues: 0 },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
(queries.useOpenIssues as any).mockReturnValue({
|
||||
data: { issues: [], total: 0, limit: 5, offset: 0 },
|
||||
isLoading: false,
|
||||
});
|
||||
const { getByTestId, queryAllByTestId } = render(wrap(<OpenIssues />));
|
||||
expect(getByTestId("open-issues-count").textContent).toBe("0");
|
||||
expect(queryAllByTestId("open-issues-item")).toHaveLength(0);
|
||||
expect(getByTestId("open-issues-empty").textContent).toMatch(/none/i);
|
||||
});
|
||||
});
|
||||
69
ui/tests/unit/PhaseBar.test.tsx
Normal file
69
ui/tests/unit/PhaseBar.test.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
// Unit tests for the PhaseBar widget.
|
||||
//
|
||||
// PhaseBar is the §7 "phase counts as a stacked bar" widget. It takes
|
||||
// pre-fetched phase_counts and a total (the dashboard feeds both from
|
||||
// useStats), and renders a Paper with one Box per non-zero phase,
|
||||
// widths proportional to count. The component is purely presentational
|
||||
// — no fetch logic, no MUI theme coupling beyond defaults — so the
|
||||
// tests mount it with a bare createTheme() to keep the test file
|
||||
// independent of the production palette choices.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { ThemeProvider, createTheme } from "@mui/material";
|
||||
import { PhaseBar } from "../../src/widgets/PhaseBar";
|
||||
import type { WorkItemPhase } from "../../src/types";
|
||||
|
||||
function wrap(node: React.ReactNode) {
|
||||
return <ThemeProvider theme={createTheme()}>{node}</ThemeProvider>;
|
||||
}
|
||||
|
||||
describe("PhaseBar widget (P5)", () => {
|
||||
it("renders nothing (no phase-bar root) when total is zero", () => {
|
||||
const counts: Record<WorkItemPhase, number> = {
|
||||
spec: 0,
|
||||
build: 0,
|
||||
review: 0,
|
||||
merged: 0,
|
||||
blocked: 0,
|
||||
awaiting_human: 0,
|
||||
};
|
||||
const { queryByTestId } = render(wrap(<PhaseBar counts={counts} total={0} />));
|
||||
expect(queryByTestId("phase-bar")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders one segment per non-zero phase, widths proportional", () => {
|
||||
const counts: Record<WorkItemPhase, number> = {
|
||||
spec: 0,
|
||||
build: 2,
|
||||
review: 0,
|
||||
merged: 6,
|
||||
blocked: 2,
|
||||
awaiting_human: 0,
|
||||
};
|
||||
const { getByTestId } = render(wrap(<PhaseBar counts={counts} total={10} />));
|
||||
expect(getByTestId("phase-bar")).toBeTruthy();
|
||||
// 2/10 = 20%, 6/10 = 60%, 2/10 = 20%
|
||||
const build = getByTestId("phase-bar-build") as HTMLElement;
|
||||
const merged = getByTestId("phase-bar-merged") as HTMLElement;
|
||||
const blocked = getByTestId("phase-bar-blocked") as HTMLElement;
|
||||
expect(build.style.width).toBe("20%");
|
||||
expect(merged.style.width).toBe("60%");
|
||||
expect(blocked.style.width).toBe("20%");
|
||||
});
|
||||
|
||||
it("hides segments for phases with zero count", () => {
|
||||
const counts: Record<WorkItemPhase, number> = {
|
||||
spec: 0,
|
||||
build: 1,
|
||||
review: 0,
|
||||
merged: 0,
|
||||
blocked: 0,
|
||||
awaiting_human: 0,
|
||||
};
|
||||
const { queryByTestId } = render(wrap(<PhaseBar counts={counts} total={1} />));
|
||||
expect(queryByTestId("phase-bar-build")).toBeTruthy();
|
||||
expect(queryByTestId("phase-bar-spec")).toBeNull();
|
||||
expect(queryByTestId("phase-bar-merged")).toBeNull();
|
||||
});
|
||||
});
|
||||
65
ui/tests/unit/api_client.test.ts
Normal file
65
ui/tests/unit/api_client.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// Unit tests for ui/src/api/client.ts (P5).
|
||||
//
|
||||
// The Authorization-on-write behavior is the only piece of the fetch
|
||||
// wrapper that's worth unit-testing in isolation: component-level
|
||||
// tests would force a full MUI render just to assert one header. The
|
||||
// other paths (URL building, JSON parsing, error mapping) are
|
||||
// exercised by the e2e suite.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("api client auth (P5)", () => {
|
||||
it("sends Authorization Bearer header on POST when VITE_API_WRITE_TOKEN is set", async () => {
|
||||
vi.stubEnv("VITE_API_WRITE_TOKEN", "test-token-abc");
|
||||
const { api } = await import("../../src/api/client");
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ok: true }),
|
||||
} as unknown as Response);
|
||||
await api.post("/v1/items", { project: "p", story_id: "s", title: "t" });
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toContain("/v1/items");
|
||||
const headers = init.headers as Record<string, string>;
|
||||
expect(headers.Authorization).toBe("Bearer test-token-abc");
|
||||
expect(headers["Content-Type"]).toBe("application/json");
|
||||
});
|
||||
|
||||
it("does NOT send Authorization on GET even when token is set", async () => {
|
||||
vi.stubEnv("VITE_API_WRITE_TOKEN", "test-token-abc");
|
||||
const { api } = await import("../../src/api/client");
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ items: [], total: 0, limit: 50, offset: 0 }),
|
||||
} as unknown as Response);
|
||||
await api.get<{ items: unknown[] }>("/v1/items");
|
||||
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
const headers = init.headers as Record<string, string>;
|
||||
expect(headers.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits Authorization on POST when VITE_API_WRITE_TOKEN is empty (read-only deployments)", async () => {
|
||||
vi.stubEnv("VITE_API_WRITE_TOKEN", "");
|
||||
const { api } = await import("../../src/api/client");
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ok: true }),
|
||||
} as unknown as Response);
|
||||
await api.post("/v1/items", { project: "p", story_id: "s", title: "t" });
|
||||
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
const headers = init.headers as Record<string, string>;
|
||||
expect(headers.Authorization).toBeUndefined();
|
||||
expect(headers["Content-Type"]).toBe("application/json");
|
||||
});
|
||||
});
|
||||
14
ui/tests/unit/setup.ts
Normal file
14
ui/tests/unit/setup.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Vitest setup file — runs once per test file before any tests.
|
||||
//
|
||||
// Adds @testing-library/jest-dom matchers (toBeInTheDocument etc.) and
|
||||
// makes sure each test starts with a clean env. We don't auto-cleanup
|
||||
// the React tree here; tests call cleanup() explicitly when they mount
|
||||
// components, or rely on the @testing-library/react auto-cleanup in
|
||||
// afterEach (the default in v16+).
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
31
ui/vitest.config.ts
Normal file
31
ui/vitest.config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// vitest config for the damascus-ui unit-test suite.
|
||||
//
|
||||
// Two pools:
|
||||
// - "node" (default): the api client is a plain TS module; no DOM.
|
||||
// - "jsdom": component tests under tests/unit/ that mount
|
||||
// React. We use environmentMatchGlobs to route by
|
||||
// path: *.test.ts in node pool, *.test.tsx in
|
||||
// jsdom pool.
|
||||
//
|
||||
// Why the React deps aren't listed in deps.optimizer: vitest's
|
||||
// optimizer chokes on MUI's emotion-styled without explicit config;
|
||||
// we let it scan imports instead, which is the default.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environmentMatchGlobs: [
|
||||
["tests/unit/*.test.tsx", "jsdom"],
|
||||
// The api client uses `window.location.origin` to build the
|
||||
// request URL (same-origin in production) and the fetch header
|
||||
// construction needs to exercise the browser-style Authorization
|
||||
// path. jsdom gives us both, and the test only calls into the
|
||||
// module — it doesn't render anything.
|
||||
["tests/unit/*.test.ts", "jsdom"],
|
||||
],
|
||||
include: ["tests/unit/**/*.test.{ts,tsx}"],
|
||||
setupFiles: ["./tests/unit/setup.ts"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user