Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 211 additions & 0 deletions web-ui/src/__tests__/components/proof/CaptureGlitchModal.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof proofApi.capture>;

// ── 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(<CaptureGlitchModal {...props} />);
}

// ── 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(<CaptureGlitchModal {...DEFAULT_PROPS} open={true} />);
expect((screen.getByLabelText(/Description/i) as HTMLTextAreaElement).value).toBe('');
});
});
});
2 changes: 2 additions & 0 deletions web-ui/src/__tests__/components/proof/ProofPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
Expand Down
54 changes: 38 additions & 16 deletions web-ui/src/app/proof/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -103,6 +103,7 @@ function ProofPageContent() {
const [workspacePath, setWorkspacePath] = useState<string | null>(null);
const [workspaceReady, setWorkspaceReady] = useState(false);
const [waivedReq, setWaivedReq] = useState<ProofRequirement | null>(null);
const [captureOpen, setCaptureOpen] = useState(false);
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);

const { runState, gateEntries, passed, runMessage, errorMessage, startRun, retry } = useProofRun();
Expand Down Expand Up @@ -199,21 +200,30 @@ function ProofPageContent() {
<a href="#proof9-help" className="text-primary hover:underline">Learn more ↓</a>
</p>
</div>
<Button
onClick={() => workspacePath && startRun(workspacePath)}
disabled={!workspacePath || runState === 'starting' || runState === 'polling'}
aria-label="Run all proof gates"
className="shrink-0"
>
{(runState === 'starting' || runState === 'polling') ? (
<span className="flex items-center gap-2">
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" aria-hidden="true" />
Running…
</span>
) : (
'Run Gates'
)}
</Button>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
onClick={() => setCaptureOpen(true)}
disabled={!workspacePath}
aria-label="Capture a glitch as a new PROOF9 requirement"
>
Capture Glitch
</Button>
<Button
onClick={() => workspacePath && startRun(workspacePath)}
disabled={!workspacePath || runState === 'starting' || runState === 'polling'}
aria-label="Run all proof gates"
>
{(runState === 'starting' || runState === 'polling') ? (
<span className="flex items-center gap-2">
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" aria-hidden="true" />
Running…
</span>
) : (
'Run Gates'
)}
</Button>
</div>
</div>

{isLoading && (
Expand Down Expand Up @@ -511,6 +521,18 @@ function ProofPageContent() {
/>
)}

{workspacePath && (
<CaptureGlitchModal
open={captureOpen}
workspacePath={workspacePath}
onClose={() => setCaptureOpen(false)}
onSuccess={() => {
setCaptureOpen(false);
mutate();
}}
/>
)}

{/* In-page help reference */}
<section id="proof9-help" className="mt-12 rounded-lg border bg-muted/30 p-6">
<h2 className="mb-3 text-base font-semibold">About PROOF9</h2>
Expand Down
Loading
Loading