diff --git a/web-ui/src/__tests__/components/proof/CaptureGlitchModal.test.tsx b/web-ui/src/__tests__/components/proof/CaptureGlitchModal.test.tsx new file mode 100644 index 00000000..86f37e33 --- /dev/null +++ b/web-ui/src/__tests__/components/proof/CaptureGlitchModal.test.tsx @@ -0,0 +1,211 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { CaptureGlitchModal } from '@/components/proof/CaptureGlitchModal'; +import { proofApi } from '@/lib/api'; +import type { ProofRequirement } from '@/types'; + +// ── Mocks ──────────────────────────────────────────────────────────────── + +jest.mock('@/lib/api', () => ({ + proofApi: { + capture: jest.fn(), + }, +})); + +const mockCapture = proofApi.capture as jest.MockedFunction; + +// ── Helpers ─────────────────────────────────────────────────────────────── + +const WORKSPACE = '/home/user/project'; + +const DEFAULT_PROPS = { + open: true, + workspacePath: WORKSPACE, + onClose: jest.fn(), + onSuccess: jest.fn(), +}; + +const MOCK_REQ: ProofRequirement = { + id: 'REQ-001', + title: 'Test glitch', + description: 'Something broke in production', + severity: 'high', + source: 'production', + status: 'open', + glitch_type: null, + obligations: [], + evidence_rules: [], + waiver: null, + created_at: '2026-04-09T00:00:00Z', + satisfied_at: null, + created_by: 'human', + source_issue: null, + related_reqs: [], +}; + +function setup(props = DEFAULT_PROPS) { + return render(); +} + +// ── Tests ──────────────────────────────────────────────────────────────── + +describe('CaptureGlitchModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('rendering', () => { + it('renders dialog with title and description when open', () => { + setup(); + expect(screen.getByRole('heading', { name: 'Capture Glitch' })).toBeInTheDocument(); + expect(screen.getByText(/Convert a production failure/i)).toBeInTheDocument(); + }); + + it('renders all form fields', () => { + setup(); + expect(screen.getByLabelText(/Description/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/Where was it found/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/Scope/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/Severity/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/Expiry/i)).toBeInTheDocument(); + }); + + it('renders all 9 gate checkboxes', () => { + setup(); + const gates = ['unit', 'contract', 'e2e', 'visual', 'a11y', 'perf', 'sec', 'demo', 'manual']; + for (const gate of gates) { + expect(screen.getByRole('checkbox', { name: gate })).toBeInTheDocument(); + } + }); + + it('renders Cancel and Capture Glitch buttons', () => { + setup(); + expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Capture Glitch/i })).toBeInTheDocument(); + }); + }); + + describe('validation', () => { + it('shows error when description is empty on submit', async () => { + setup(); + // Check at least one gate + fireEvent.click(screen.getByRole('checkbox', { name: 'unit' })); + fireEvent.click(screen.getByRole('button', { name: /Capture Glitch/i })); + await waitFor(() => { + expect(screen.getByText(/Description is required/i)).toBeInTheDocument(); + }); + expect(mockCapture).not.toHaveBeenCalled(); + }); + + it('shows error when no gates selected on submit', async () => { + setup(); + fireEvent.change(screen.getByLabelText(/Description/i), { + target: { value: 'Something broke' }, + }); + fireEvent.click(screen.getByRole('button', { name: /Capture Glitch/i })); + await waitFor(() => { + expect(screen.getByText(/Select at least one gate/i)).toBeInTheDocument(); + }); + expect(mockCapture).not.toHaveBeenCalled(); + }); + }); + + describe('submission', () => { + it('calls proofApi.capture with correct fields and calls onSuccess', async () => { + mockCapture.mockResolvedValue(MOCK_REQ); + setup(); + + fireEvent.change(screen.getByLabelText(/Description/i), { + target: { value: 'Something broke in production' }, + }); + fireEvent.click(screen.getByRole('checkbox', { name: 'unit' })); + fireEvent.click(screen.getByRole('button', { name: /Capture Glitch/i })); + + await waitFor(() => { + expect(mockCapture).toHaveBeenCalledWith( + WORKSPACE, + expect.objectContaining({ + title: 'Something broke in production', + description: 'Something broke in production', + severity: 'high', + source: 'production', + created_by: 'human', + }) + ); + }); + await waitFor(() => { + expect(DEFAULT_PROPS.onSuccess).toHaveBeenCalledWith(MOCK_REQ); + }); + }); + + it('truncates description to 80 chars for title', async () => { + mockCapture.mockResolvedValue(MOCK_REQ); + setup(); + + const longDesc = 'A'.repeat(100); + fireEvent.change(screen.getByLabelText(/Description/i), { + target: { value: longDesc }, + }); + fireEvent.click(screen.getByRole('checkbox', { name: 'sec' })); + fireEvent.click(screen.getByRole('button', { name: /Capture Glitch/i })); + + await waitFor(() => { + expect(mockCapture).toHaveBeenCalledWith( + WORKSPACE, + expect.objectContaining({ title: 'A'.repeat(80) }) + ); + }); + }); + + it('shows inline error and keeps modal open on API failure', async () => { + mockCapture.mockRejectedValue(new Error('Network error')); + setup(); + + fireEvent.change(screen.getByLabelText(/Description/i), { + target: { value: 'Something broke' }, + }); + fireEvent.click(screen.getByRole('checkbox', { name: 'demo' })); + fireEvent.click(screen.getByRole('button', { name: /Capture Glitch/i })); + + await waitFor(() => { + expect(screen.getByText(/Failed to capture glitch/i)).toBeInTheDocument(); + }); + expect(DEFAULT_PROPS.onSuccess).not.toHaveBeenCalled(); + // Modal still open + expect(screen.getByRole('heading', { name: 'Capture Glitch' })).toBeInTheDocument(); + }); + + it('disables submit button while submitting', async () => { + let resolve!: (v: ProofRequirement) => void; + mockCapture.mockReturnValue(new Promise((r) => { resolve = r; })); + setup(); + + fireEvent.change(screen.getByLabelText(/Description/i), { + target: { value: 'Something broke' }, + }); + fireEvent.click(screen.getByRole('checkbox', { name: 'manual' })); + fireEvent.click(screen.getByRole('button', { name: /Capture Glitch/i })); + + await waitFor(() => { + expect(screen.getByText(/Capturing…/i)).toBeInTheDocument(); + }); + resolve(MOCK_REQ); + }); + }); + + describe('cancel', () => { + it('calls onClose when Cancel is clicked', () => { + setup(); + fireEvent.click(screen.getByRole('button', { name: /Cancel/i })); + expect(DEFAULT_PROPS.onClose).toHaveBeenCalled(); + }); + }); + + describe('state reset on reopen', () => { + it('clears form state when modal is reopened', () => { + const { rerender } = setup({ ...DEFAULT_PROPS, open: false }); + rerender(); + expect((screen.getByLabelText(/Description/i) as HTMLTextAreaElement).value).toBe(''); + }); + }); +}); diff --git a/web-ui/src/__tests__/components/proof/ProofPage.test.tsx b/web-ui/src/__tests__/components/proof/ProofPage.test.tsx index fabcc768..92052d8d 100644 --- a/web-ui/src/__tests__/components/proof/ProofPage.test.tsx +++ b/web-ui/src/__tests__/components/proof/ProofPage.test.tsx @@ -25,7 +25,9 @@ jest.mock('@/components/proof', () => ({ WaiveDialog: () => null, GateRunPanel: () => null, GateRunBanner: () => null, + GateEvidencePanel: () => null, RunHistoryPanel: () => null, + CaptureGlitchModal: () => null, })); jest.mock('next/link', () => { const MockLink = ({ href, children }: { href: string; children: React.ReactNode }) => ( diff --git a/web-ui/src/app/proof/page.tsx b/web-ui/src/app/proof/page.tsx index d67a7441..f5db6219 100644 --- a/web-ui/src/app/proof/page.tsx +++ b/web-ui/src/app/proof/page.tsx @@ -12,7 +12,7 @@ import { TooltipProvider, } from '@/components/ui/tooltip'; import { Button } from '@/components/ui/button'; -import { ProofStatusBadge, WaiveDialog, GateRunPanel, GateRunBanner, RunHistoryPanel, GateEvidencePanel } from '@/components/proof'; +import { ProofStatusBadge, WaiveDialog, GateRunPanel, GateRunBanner, RunHistoryPanel, GateEvidencePanel, CaptureGlitchModal } from '@/components/proof'; import { proofApi } from '@/lib/api'; import { useProofRun } from '@/hooks/useProofRun'; import { getSelectedWorkspacePath } from '@/lib/workspace-storage'; @@ -103,6 +103,7 @@ function ProofPageContent() { const [workspacePath, setWorkspacePath] = useState(null); const [workspaceReady, setWorkspaceReady] = useState(false); const [waivedReq, setWaivedReq] = useState(null); + const [captureOpen, setCaptureOpen] = useState(false); const [selectedRunId, setSelectedRunId] = useState(null); const { runState, gateEntries, passed, runMessage, errorMessage, startRun, retry } = useProofRun(); @@ -199,21 +200,30 @@ function ProofPageContent() { Learn more ↓

- +
+ + +
{isLoading && ( @@ -511,6 +521,18 @@ function ProofPageContent() { /> )} + {workspacePath && ( + setCaptureOpen(false)} + onSuccess={() => { + setCaptureOpen(false); + mutate(); + }} + /> + )} + {/* In-page help reference */}

About PROOF9

diff --git a/web-ui/src/components/proof/CaptureGlitchModal.tsx b/web-ui/src/components/proof/CaptureGlitchModal.tsx new file mode 100644 index 00000000..19dc06b8 --- /dev/null +++ b/web-ui/src/components/proof/CaptureGlitchModal.tsx @@ -0,0 +1,257 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from '@/components/ui/select'; +import { proofApi } from '@/lib/api'; +import type { ProofRequirement, ProofSeverity, CaptureGlitchRequest } from '@/types'; + +// The 9 PROOF9 gate types (mirrors Gate enum in codeframe/core/proof/models.py) +const GATE_LIST = ['unit', 'contract', 'e2e', 'visual', 'a11y', 'perf', 'sec', 'demo', 'manual'] as const; + +const SOURCE_OPTIONS: { value: CaptureGlitchRequest['source']; label: string }[] = [ + { value: 'production', label: 'Production' }, + { value: 'qa', label: 'QA' }, + { value: 'dogfooding', label: 'Dogfooding' }, + { value: 'monitoring', label: 'Monitoring' }, + { value: 'user_report', label: 'User Report' }, +]; + +const SEVERITY_OPTIONS: { value: ProofSeverity; label: string }[] = [ + { value: 'critical', label: 'Critical' }, + { value: 'high', label: 'High' }, + { value: 'medium', label: 'Medium' }, + { value: 'low', label: 'Low' }, +]; + +export interface CaptureGlitchModalProps { + open: boolean; + workspacePath: string; + onClose: () => void; + onSuccess: (req: ProofRequirement) => void; +} + +export function CaptureGlitchModal({ open, workspacePath, onClose, onSuccess }: CaptureGlitchModalProps) { + const [description, setDescription] = useState(''); + const [source, setSource] = useState('production'); + const [scopeText, setScopeText] = useState(''); + const [selectedGates, setSelectedGates] = useState>(new Set()); + const [severity, setSeverity] = useState('high'); + const [expires, setExpires] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + // Reset all state when the modal opens + useEffect(() => { + if (open) { + setDescription(''); + setSource('production'); + setScopeText(''); + setSelectedGates(new Set()); + setSeverity('high'); + setExpires(''); + setSubmitting(false); + setError(null); + } + }, [open]); + + function toggleGate(gate: string) { + setSelectedGates((prev) => { + const next = new Set(prev); + if (next.has(gate)) { + next.delete(gate); + } else { + next.add(gate); + } + return next; + }); + } + + async function handleSubmit() { + // Validate + if (!description.trim()) { + setError('Description is required'); + return; + } + if (selectedGates.size === 0) { + setError('Select at least one gate'); + return; + } + + setError(null); + setSubmitting(true); + + // Derive title from first line of description (max 80 chars) + const title = description.trim().split('\n')[0].slice(0, 80); + + // Derive `where` from scope lines, falling back to source + const scopeLines = scopeText.split('\n').map((l) => l.trim()).filter(Boolean); + const where = scopeLines.length > 0 ? scopeLines.join(', ') : source; + + const body: CaptureGlitchRequest = { + title, + description: description.trim(), + where, + severity, + source, + created_by: 'human', + }; + + try { + const result = await proofApi.capture(workspacePath, body); + onSuccess(result); + } catch (err: unknown) { + const detail = (err as { detail?: string })?.detail; + setError(detail ?? 'Failed to capture glitch. Please try again.'); + } finally { + setSubmitting(false); + } + } + + return ( + { if (!o) onClose(); }}> + + + Capture Glitch + + Convert a production failure into a permanent PROOF9 requirement. + + + +
+ {/* Description */} +
+ +