Story 27: Coverage tracking (full-stack)
Add end-to-end coverage tracking: backend collects vitest coverage, records metrics with threshold/baseline tracking, and blocks acceptance on regression. Frontend displays coverage in gate/review panels with a "Collect Coverage" button. Includes 20 Rust tests, 17 Vitest tests, and 14 Playwright E2E tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -22,12 +22,19 @@ export interface TestRunSummaryResponse {
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export interface CoverageReportResponse {
|
||||
current_percent: number;
|
||||
threshold_percent: number;
|
||||
baseline_percent?: number | null;
|
||||
}
|
||||
|
||||
export interface AcceptanceResponse {
|
||||
can_accept: boolean;
|
||||
reasons: string[];
|
||||
warning?: string | null;
|
||||
summary: TestRunSummaryResponse;
|
||||
missing_categories: string[];
|
||||
coverage_report?: CoverageReportResponse | null;
|
||||
}
|
||||
|
||||
export interface ReviewStory {
|
||||
@@ -37,6 +44,18 @@ export interface ReviewStory {
|
||||
warning?: string | null;
|
||||
summary: TestRunSummaryResponse;
|
||||
missing_categories: string[];
|
||||
coverage_report?: CoverageReportResponse | null;
|
||||
}
|
||||
|
||||
export interface RecordCoveragePayload {
|
||||
story_id: string;
|
||||
current_percent: number;
|
||||
threshold_percent?: number | null;
|
||||
}
|
||||
|
||||
export interface CollectCoverageRequest {
|
||||
story_id: string;
|
||||
threshold_percent?: number | null;
|
||||
}
|
||||
|
||||
export interface ReviewListResponse {
|
||||
@@ -71,6 +90,20 @@ async function requestJson<T>(
|
||||
}
|
||||
|
||||
export const workflowApi = {
|
||||
collectCoverage(payload: CollectCoverageRequest, baseUrl?: string) {
|
||||
return requestJson<CoverageReportResponse>(
|
||||
"/workflow/coverage/collect",
|
||||
{ method: "POST", body: JSON.stringify(payload) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
recordCoverage(payload: RecordCoveragePayload, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/workflow/coverage/record",
|
||||
{ method: "POST", body: JSON.stringify(payload) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
recordTests(payload: RecordTestsPayload, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/workflow/tests/record",
|
||||
|
||||
@@ -33,6 +33,8 @@ vi.mock("../api/workflow", () => {
|
||||
getReviewQueue: vi.fn(),
|
||||
getReviewQueueAll: vi.fn(),
|
||||
ensureAcceptance: vi.fn(),
|
||||
recordCoverage: vi.fn(),
|
||||
collectCoverage: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -390,4 +392,122 @@ describe("Chat review panel", () => {
|
||||
|
||||
expect(await screen.findByText("Ready to accept")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows coverage below threshold in gate panel (AC3)", async () => {
|
||||
mockedWorkflow.getAcceptance.mockResolvedValueOnce({
|
||||
can_accept: false,
|
||||
reasons: ["Coverage below threshold (55.0% < 80.0%)."],
|
||||
warning: null,
|
||||
summary: { total: 3, passed: 3, failed: 0 },
|
||||
missing_categories: [],
|
||||
coverage_report: {
|
||||
current_percent: 55.0,
|
||||
threshold_percent: 80.0,
|
||||
baseline_percent: null,
|
||||
},
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Blocked")).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Coverage: 55\.0%/)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/threshold: 80\.0%/)).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("Coverage below threshold (55.0% < 80.0%)."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows coverage regression in review panel (AC4)", async () => {
|
||||
const story: ReviewStory = {
|
||||
story_id: "27_protect_tests_and_coverage",
|
||||
can_accept: false,
|
||||
reasons: ["Coverage regression: 90.0% → 82.0% (threshold: 80.0%)."],
|
||||
warning: null,
|
||||
summary: { total: 4, passed: 4, failed: 0 },
|
||||
missing_categories: [],
|
||||
coverage_report: {
|
||||
current_percent: 82.0,
|
||||
threshold_percent: 80.0,
|
||||
baseline_percent: 90.0,
|
||||
},
|
||||
};
|
||||
|
||||
mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({
|
||||
stories: [story],
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Coverage regression: 90.0% → 82.0% (threshold: 80.0%).",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Coverage: 82\.0%/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows green coverage when above threshold (AC3)", async () => {
|
||||
mockedWorkflow.getAcceptance.mockResolvedValueOnce({
|
||||
can_accept: true,
|
||||
reasons: [],
|
||||
warning: null,
|
||||
summary: { total: 5, passed: 5, failed: 0 },
|
||||
missing_categories: [],
|
||||
coverage_report: {
|
||||
current_percent: 92.0,
|
||||
threshold_percent: 80.0,
|
||||
baseline_percent: 90.0,
|
||||
},
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Ready to accept")).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Coverage: 92\.0%/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("collect coverage button triggers collection and refreshes gate", async () => {
|
||||
const mockedCollectCoverage = vi.mocked(workflowApi.collectCoverage);
|
||||
mockedCollectCoverage.mockResolvedValueOnce({
|
||||
current_percent: 85.0,
|
||||
threshold_percent: 80.0,
|
||||
baseline_percent: null,
|
||||
});
|
||||
|
||||
mockedWorkflow.getAcceptance
|
||||
.mockResolvedValueOnce({
|
||||
can_accept: false,
|
||||
reasons: ["No test results recorded for the story."],
|
||||
warning: null,
|
||||
summary: { total: 0, passed: 0, failed: 0 },
|
||||
missing_categories: ["unit", "integration"],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
can_accept: true,
|
||||
reasons: [],
|
||||
warning: null,
|
||||
summary: { total: 5, passed: 5, failed: 0 },
|
||||
missing_categories: [],
|
||||
coverage_report: {
|
||||
current_percent: 85.0,
|
||||
threshold_percent: 80.0,
|
||||
baseline_percent: null,
|
||||
},
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
const collectButton = await screen.findByRole("button", {
|
||||
name: "Collect Coverage",
|
||||
});
|
||||
await userEvent.click(collectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedCollectCoverage).toHaveBeenCalledWith({
|
||||
story_id: "26_establish_tdd_workflow_and_gates",
|
||||
});
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/Coverage: 85\.0%/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,11 @@ interface GateState {
|
||||
failed: number;
|
||||
};
|
||||
missingCategories: string[];
|
||||
coverageReport: {
|
||||
currentPercent: number;
|
||||
thresholdPercent: number;
|
||||
baselinePercent: number | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
@@ -54,6 +59,8 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
const [proceedSuccess, setProceedSuccess] = useState<string | null>(null);
|
||||
const [lastReviewRefresh, setLastReviewRefresh] = useState<Date | null>(null);
|
||||
const [lastGateRefresh, setLastGateRefresh] = useState<Date | null>(null);
|
||||
const [isCollectingCoverage, setIsCollectingCoverage] = useState(false);
|
||||
const [coverageError, setCoverageError] = useState<string | null>(null);
|
||||
|
||||
const storyId = "26_establish_tdd_workflow_and_gates";
|
||||
const gateStatusColor = isGateLoading
|
||||
@@ -185,6 +192,14 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
warning: response.warning ?? null,
|
||||
summary: response.summary,
|
||||
missingCategories: response.missing_categories,
|
||||
coverageReport: response.coverage_report
|
||||
? {
|
||||
currentPercent: response.coverage_report.current_percent,
|
||||
thresholdPercent: response.coverage_report.threshold_percent,
|
||||
baselinePercent:
|
||||
response.coverage_report.baseline_percent ?? null,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
setLastGateRefresh(new Date());
|
||||
})
|
||||
@@ -254,6 +269,14 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
warning: response.warning ?? null,
|
||||
summary: response.summary,
|
||||
missingCategories: response.missing_categories,
|
||||
coverageReport: response.coverage_report
|
||||
? {
|
||||
currentPercent: response.coverage_report.current_percent,
|
||||
thresholdPercent: response.coverage_report.threshold_percent,
|
||||
baselinePercent:
|
||||
response.coverage_report.baseline_percent ?? null,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
setLastGateRefresh(new Date());
|
||||
} catch (error) {
|
||||
@@ -268,6 +291,21 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCollectCoverage = async () => {
|
||||
setIsCollectingCoverage(true);
|
||||
setCoverageError(null);
|
||||
try {
|
||||
await workflowApi.collectCoverage({ story_id: storyId });
|
||||
await refreshGateState(storyId);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to collect coverage.";
|
||||
setCoverageError(message);
|
||||
} finally {
|
||||
setIsCollectingCoverage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshReviewQueue = async () => {
|
||||
setIsReviewLoading(true);
|
||||
setReviewError(null);
|
||||
@@ -549,8 +587,11 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
gateStatusColor={gateStatusColor}
|
||||
isGateLoading={isGateLoading}
|
||||
gateError={gateError}
|
||||
coverageError={coverageError}
|
||||
lastGateRefresh={lastGateRefresh}
|
||||
onRefresh={() => refreshGateState(storyId)}
|
||||
onCollectCoverage={handleCollectCoverage}
|
||||
isCollectingCoverage={isCollectingCoverage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
interface CoverageReport {
|
||||
currentPercent: number;
|
||||
thresholdPercent: number;
|
||||
baselinePercent: number | null;
|
||||
}
|
||||
|
||||
interface GateState {
|
||||
canAccept: boolean;
|
||||
reasons: string[];
|
||||
@@ -8,6 +14,7 @@ interface GateState {
|
||||
failed: number;
|
||||
};
|
||||
missingCategories: string[];
|
||||
coverageReport: CoverageReport | null;
|
||||
}
|
||||
|
||||
interface GatePanelProps {
|
||||
@@ -16,8 +23,11 @@ interface GatePanelProps {
|
||||
gateStatusColor: string;
|
||||
isGateLoading: boolean;
|
||||
gateError: string | null;
|
||||
coverageError: string | null;
|
||||
lastGateRefresh: Date | null;
|
||||
onRefresh: () => void;
|
||||
onCollectCoverage: () => void;
|
||||
isCollectingCoverage: boolean;
|
||||
}
|
||||
|
||||
const formatTimestamp = (value: Date | null): string => {
|
||||
@@ -35,8 +45,11 @@ export function GatePanel({
|
||||
gateStatusColor,
|
||||
isGateLoading,
|
||||
gateError,
|
||||
coverageError,
|
||||
lastGateRefresh,
|
||||
onRefresh,
|
||||
onCollectCoverage,
|
||||
isCollectingCoverage,
|
||||
}: GatePanelProps) {
|
||||
return (
|
||||
<div
|
||||
@@ -83,6 +96,27 @@ export function GatePanel({
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCollectCoverage}
|
||||
disabled={isCollectingCoverage || isGateLoading}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
borderRadius: "999px",
|
||||
border: "1px solid #333",
|
||||
background:
|
||||
isCollectingCoverage || isGateLoading ? "#2a2a2a" : "#2f2f2f",
|
||||
color: isCollectingCoverage || isGateLoading ? "#777" : "#aaa",
|
||||
cursor:
|
||||
isCollectingCoverage || isGateLoading
|
||||
? "not-allowed"
|
||||
: "pointer",
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{isCollectingCoverage ? "Collecting..." : "Collect Coverage"}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@@ -147,6 +181,27 @@ export function GatePanel({
|
||||
Summary: {gateState.summary.passed}/{gateState.summary.total}{" "}
|
||||
passing, {gateState.summary.failed} failing
|
||||
</div>
|
||||
{gateState.coverageReport && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.85em",
|
||||
color:
|
||||
gateState.coverageReport.currentPercent <
|
||||
gateState.coverageReport.thresholdPercent
|
||||
? "#ff7b72"
|
||||
: "#7ee787",
|
||||
}}
|
||||
>
|
||||
Coverage: {gateState.coverageReport.currentPercent.toFixed(1)}%
|
||||
(threshold: {gateState.coverageReport.thresholdPercent.toFixed(1)}
|
||||
%)
|
||||
</div>
|
||||
)}
|
||||
{coverageError && (
|
||||
<div style={{ fontSize: "0.85em", color: "#ff7b72" }}>
|
||||
Coverage error: {coverageError}
|
||||
</div>
|
||||
)}
|
||||
{gateState.missingCategories.length > 0 && (
|
||||
<div style={{ fontSize: "0.85em", color: "#ffb86c" }}>
|
||||
Missing: {gateState.missingCategories.join(", ")}
|
||||
|
||||
@@ -278,6 +278,22 @@ export function ReviewPanel({
|
||||
Summary: {story.summary.passed}/{story.summary.total} passing,{" "}
|
||||
{` ${story.summary.failed}`} failing
|
||||
</div>
|
||||
{story.coverage_report && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.85em",
|
||||
color:
|
||||
story.coverage_report.current_percent <
|
||||
story.coverage_report.threshold_percent
|
||||
? "#ff7b72"
|
||||
: "#7ee787",
|
||||
}}
|
||||
>
|
||||
Coverage: {story.coverage_report.current_percent.toFixed(1)}%
|
||||
(threshold:{" "}
|
||||
{story.coverage_report.threshold_percent.toFixed(1)}%)
|
||||
</div>
|
||||
)}
|
||||
{story.missing_categories.length > 0 && (
|
||||
<div style={{ fontSize: "0.85em", color: "#ffb86c" }}>
|
||||
Missing: {story.missing_categories.join(", ")}
|
||||
|
||||
Reference in New Issue
Block a user