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:
Dave
2026-02-19 14:45:57 +00:00
parent 8f0bc971bf
commit 8f684a6ca4
20 changed files with 1216 additions and 34 deletions

View File

@@ -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();
});
});

View File

@@ -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>

View File

@@ -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(", ")}

View File

@@ -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(", ")}