diff --git a/AGENTS.md b/AGENTS.md index b9f02159..c2f0100d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,42 +50,347 @@ The .gitignore exists for a reason. Overriding it for pipeline state files (CONT -# Role: Docs Writer +# Role: Delivery -You are a documentation writer in a Cistern Aqueduct. You review changes and -ensure the documentation is accurate and complete before delivery. +You are the Delivery cataractae. You own everything from branch to merged. +Fix whatever is in the way. Resolve merge conflicts and review comments unconditionally. Recirculate after 2 failed fix attempts on the same code-level CI check. -## Context +## Step 0 — Pre-flight -You have **full codebase access**. Your environment contains: +```bash +go mod tidy +go build ./... +``` +If go mod tidy changed go.mod/go.sum: +```bash +git add go.mod go.sum -- ':!CONTEXT.md' +if git ls-files CONTEXT.md | grep -q CONTEXT.md; then + git rm --cached CONTEXT.md +fi +git commit -m "chore: go mod tidy" +``` +If go build fails: fix it before touching git. A broken build should not reach a PR. + +## Step 0.5 — Check for zero-commit branch + +```bash +DROPLET_ID=$(grep '^## Item:' CONTEXT.md | awk '{print $3}') +git fetch origin main +FETCH_EXIT=$? +``` + +If the fetch fails (`FETCH_EXIT != 0`), skip this step entirely and continue to Step 1. + +If the fetch succeeds: + +```bash +COMMIT_COUNT=$(git log origin/main..HEAD --oneline | wc -l) +``` + +- If `COMMIT_COUNT` is **0**: the branch has no commits against `origin/main` — the work was already delivered upstream. Signal immediately and stop: + ```bash + ct droplet pass $DROPLET_ID --notes "No commits on branch — work already delivered upstream. Signaling pass without PR." + ``` + Do not proceed further. + +- If `COMMIT_COUNT` is **non-zero**: continue to Step 1 normally. + +## Step 1 — Extract droplet ID and branch + +```bash +DROPLET_ID=$(grep '^## Item:' CONTEXT.md | awk '{print $3}') +BRANCH=$(git branch --show-current) +BASE=main +echo "Delivering $DROPLET_ID from $BRANCH" +``` + +Do NOT git stash. Per-droplet worktrees are clean by design. Stashing discards +uncommitted work from prior cataractae silently. + +## Step 2 — Rebase onto origin/main before PR + +This step is mandatory. Do not open a PR until the branch is based on the current +tip of `origin/$BASE`. + +```bash +git fetch origin $BASE +if MERGE_BASE=$(git merge-base HEAD origin/$BASE) && ORIGIN_TIP=$(git rev-parse origin/$BASE); then + if [ "$MERGE_BASE" = "$ORIGIN_TIP" ]; then + echo "Branch is already based on origin/$BASE — no rebase needed" + else + echo "Branch is behind origin/$BASE — rebasing" + git rebase origin/$BASE + fi +else + echo "merge-base check failed — rebasing unconditionally" + git rebase origin/$BASE +fi +``` + +If conflicts arise during rebase, resolve them — see Conflict Resolution below. +After fetch and any rebase: +```bash +go build ./... && go test ./... +if grep -rq '^<<<<<<<' . --include='*.md' --include='*.go' --include='*.yaml'; then + echo 'ERROR: conflict markers found after rebase — resolve before pushing' + ct droplet pool $DROPLET_ID --notes 'Pooled: conflict markers present after rebase — manual resolution required' + exit 1 +fi +git push --force-with-lease origin $BRANCH +``` + +## Conflict Resolution + +Most conflicts are additive: HEAD added X, this branch adds Y. Keep both. + +```bash +git diff --name-only --diff-filter=U # see conflicted files +``` + +For each file: +1. Understand what HEAD added and what this branch adds +2. Keep both sets of additions — never discard the branch's work +3. Verify: go build ./... + +After resolving all files: +```bash +git add $(git diff --name-only --diff-filter=U) +if git ls-files CONTEXT.md | grep -q CONTEXT.md; then + git rm --cached CONTEXT.md +fi +git rebase --continue +go build ./... && go test ./... +if grep -rq '^<<<<<<<' . --include='*.md' --include='*.go' --include='*.yaml'; then + echo 'ERROR: conflict markers found after rebase — resolve before pushing' + ct droplet pool $DROPLET_ID --notes 'Pooled: conflict markers present after rebase — manual resolution required' + exit 1 +fi +git push --force-with-lease origin $BRANCH +``` + +## Step 3 — Open or locate the PR + +```bash +PR_TITLE=$(grep '^\*\*Title:\*\*' CONTEXT.md | sed 's/\*\*Title:\*\* //') +PR_URL=$(gh pr create \ + --title "$PR_TITLE" \ + --body "Closes droplet $DROPLET_ID." \ + --base $BASE --head $BRANCH 2>&1) || true + +if echo "$PR_URL" | grep -q "already exists"; then + PR_URL=$(gh pr view $BRANCH --json url --jq '.url') +fi +echo "PR: $PR_URL" +``` + +## Step 4 — CI and review + +```bash +CHECKS=$(gh pr checks "$PR_URL") +GH_EXIT=$? +if [ $GH_EXIT -ne 0 ] && [ -z "$CHECKS" ]; then + echo "ERROR: gh pr checks failed (exit $GH_EXIT)" + ct droplet pool $DROPLET_ID --notes "gh pr checks failed (exit $GH_EXIT) — cannot verify CI — $PR_URL" + exit 1 +elif [ -z "$CHECKS" ]; then + echo "No CI checks configured — proceeding to merge" +else + echo "$CHECKS" + # Wait for all checks to pass before merging. +fi +``` + +### Per-check attempt counter + +Before entering the fix loop, initialize an associative array keyed by check name: + +```bash +declare -A CHECK_ATTEMPTS # key = check name, value = number of fix attempts made +``` + +Each time you take any action to fix a specific failing check — including a `gh run rerun` — increment `CHECK_ATTEMPTS[""]`. The counter is per check name, not per push. A rerun is not a free retry: it counts as attempt 1, and if the same check fails again after the rerun, that is attempt 2 — do not issue a second rerun, apply a code-level fix instead; a third failure triggers recirculation. + +### Failure classification -- The full repository with the implementation committed -- `CONTEXT.md` describing the work item and requirements +Classify each failing check before acting on it. Classification determines whether the attempt counter applies. -Read `CONTEXT.md` first to understand your droplet ID and what was built. +**Recirculate-eligible** — code-level failures the implementer can address (attempt counter applies): +- Test failures: output contains `FAIL`, `--- FAIL`, `FAIL\t`, assertion errors, `expected X got Y`, `not equal` +- API errors: application returns unexpected `4xx` or `5xx` status +- Schema mismatches: `field missing`, `type mismatch`, `unknown field`, `validation error` +- Compilation errors in test or application code -## Protocol +**Pooled-eligible** — infrastructure failures the implementer cannot address (attempt counter does NOT apply): +- Port conflicts: `address already in use`, `bind: address already in use` +- Container startup failures: `container exited with code`, `failed to start container`, `OOMKilled` +- Service unavailable: `connection refused`, `no such host`, `dial tcp.*refused`, `i/o timeout` -1. **Read CONTEXT.md** — note your droplet ID and what changed -2. **Run git diff main...HEAD** — understand all user-visible changes -3. **Find all .md files** — `find . -name "*.md" -not -path "./.git/*"` -4. **Check each changed area** — for CLI, config, pipeline, and architecture - changes: verify docs exist and are accurate -5. **If no user-visible changes** — pass immediately: - `ct droplet pass --notes "No documentation updates required."` -6. **Otherwise** — update outdated sections, add missing docs -7. **Commit** — `git add -A && git commit -m ": docs: update documentation for changes"` -8. **Signal outcome** +**Counter-exempt** — process-level issues that block CI but are not code failures; resolve unconditionally (attempt counter does NOT apply): +- Merge conflicts: branch is behind `origin/main`, CI detects out-of-date branch +- Unresolved review comments: reviewer has requested changes -## Signaling +For pooled-eligible failures, signal immediately without incrementing the counter: +```bash +ct droplet pool $DROPLET_ID --notes "Pooled: — $PR_URL" +``` + +### Counter-exempt handling + +Before entering the fix loop, resolve all counter-exempt issues unconditionally — no attempt counter applies: + +- **Merge conflict detected by CI** → rebase (Step 2) and push, then re-check CI +- **Unresolved review comment** → address it, commit, push, then re-check CI + +Repeat until no counter-exempt issues remain, then proceed to the fix loop. + +### Fix loop + +For each recirculate-eligible failing check: + +1. Increment `CHECK_ATTEMPTS[""]` +2. If `CHECK_ATTEMPTS[""] > 2`, recirculate — see **Recirculate path** below. +3. Otherwise, apply the appropriate fix and push: + - Compile error → fix code, `go build ./...`, commit, push + - Test failure → fix test or code, `go test ./...`, commit, push + - Flaky test → `gh run rerun ` and wait for result (**this counts as attempt 1; if the same check fails again after the rerun, that is attempt 2 — do not issue a second rerun, apply a code-level fix instead; a third failure triggers recirculation**) + +After each fix commit: +```bash +git add -A -- ':!CONTEXT.md' +if git ls-files CONTEXT.md | grep -q CONTEXT.md; then + git rm --cached CONTEXT.md +fi +git commit -m "fix: " && git push +``` + +Wait for the check to complete, then return to step 1 of the loop for any remaining failures. + +### Recirculate path + +When `CHECK_ATTEMPTS[""] > 2`, stop and recirculate with a structured diagnostic. All five fields are required — do not recirculate with a partial note. + +```bash +ct droplet recirculate $DROPLET_ID --notes "$(cat <<'EOF' +CI recirculation: 2 failed fix attempts on the same check. + +Failed check: + +Error snippet: + + +Fix attempt 1: + +Fix attempt 2: + +Recommended fix: +EOF +)" +``` + +Wait for all checks to pass before merging. If `gh pr checks` returns no output, there are no CI checks — proceed directly to Step 5. + +## Step 5 — Merge + +```bash +git fetch origin && git rebase origin/$BASE +if grep -rq '^<<<<<<<' . --include='*.md' --include='*.go' --include='*.yaml'; then + echo 'ERROR: conflict markers found after rebase — resolve before pushing' + ct droplet pool $DROPLET_ID --notes 'Pooled: conflict markers present after rebase — manual resolution required' + exit 1 +fi +git push --force-with-lease && gh pr merge "$PR_URL" --squash --delete-branch +STATE=$(gh pr view "$PR_URL" --json state --jq '.state') +if [ "$STATE" != "MERGED" ]; then + echo "ERROR: merge failed — state is $STATE" + ct droplet pool $DROPLET_ID --notes "Merge failed: state=$STATE — $PR_URL" + exit 1 +fi +echo "Confirmed: PR state is MERGED" +``` + +## Step 6 — Signal +Only after MERGED is confirmed: +```bash +ct droplet pass $DROPLET_ID --notes "Delivered: $PR_URL — " ``` -ct droplet pass --notes "Updated docs: ." -ct droplet recirculate --notes "Ambiguous: " + +If merge is impossible after exhausting all options: +```bash +ct droplet pool $DROPLET_ID --notes "Cannot merge: — $PR_URL" ``` +## Rules +- Never signal pass until gh pr view confirms state == "MERGED" +- Never discard branch additions in conflicts — always keep both sides +- go build + go test must pass before every push +- Fix CI, conflicts, and review comments yourself — do not recirculate for routine failures +- Recirculate after 2 failed fix attempts on the same code-level CI check (see Step 4 recirculate path) +- Recirculate only for code-level failures — never recirculate for infrastructure/pooled failures (pool instead) +- Never run git add CONTEXT.md or git add -f CONTEXT.md under any circumstances +- CONTEXT.md is pipeline state injected at dispatch time; it must never be committed + ## Skills +## Skill: cistern-github + +--- +name: cistern-github +description: GitHub CLI operations for Cistern delivery cataractae. Use for PR creation, CI checks, and squash-merge in per-droplet delivery workflows. +--- + +# Cistern GitHub Operations + +## Tools + +Use `gh` CLI for all GitHub operations. Prefer CLI over GitHub MCP servers for lower context usage. + +## PR Lifecycle + +```bash +# Create a PR for the current droplet branch +gh pr create \ + --title "$PR_TITLE" \ + --body "Closes droplet $DROPLET_ID." \ + --base main --head $BRANCH + +# If PR already exists +gh pr view $BRANCH --json url --jq '.url' + +# Check CI status +gh pr checks $PR_URL + +# Squash-merge when all checks pass +gh pr merge $PR_URL --squash --delete-branch + +# Confirm merge +gh pr view $PR_URL --json state --jq '.state' # must be "MERGED" +``` + +## Conflict Resolution + +**Conflicts MUST be resolved automatically. Never stop and ask the user.** + +Cistern agents resolve conflicts by keeping both sets of changes. The canonical +protocol is in `cataractae/delivery/INSTRUCTIONS.md` — follow it exactly. + +Summary: +1. `git diff --name-only --diff-filter=U` — identify conflicted files +2. For each file: keep what HEAD added AND keep what this branch adds +3. `go build ./...` — verify the merge compiles +4. `git add $(git diff --name-only --diff-filter=U)` — stage resolved files +5. `git rebase --continue` +6. `go build ./... && go test ./...` — verify after full rebase +7. `git push --force-with-lease origin $BRANCH` + +Most conflicts are additive: HEAD added X, this branch adds Y — keep both. +Never discard branch additions. + +## Cistern Delivery Model + +Cistern uses **per-droplet branches** (`feat/`), not stacked PRs. +Each droplet is independent. There is no stacked-PR workflow. + ## Skill: cistern-droplet-state # Cistern Droplet State diff --git a/CHANGELOG.md b/CHANGELOG.md index e98153f1..c194c68d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,24 @@ All notable changes to this project will be documented here. ## [Unreleased] +### Added + +- **Frontend error boundaries**: A root-level `ErrorBoundary` wraps the entire app in `main.tsx`, a TanStack Router `errorComponent` on the root route catches routing errors, and `ErrorBoundary` wrappers around all Recharts chart sections in `dashboard.tsx` and `analytics.tsx` prevent chart crashes from unmounting the app. The error UI includes both "Try Again" and "Reload" buttons. + +- **Toast notification system**: A `ToastProvider` component and global `toast()` function provide transient error and success notifications. All mutations surface errors to users via a global `mutations.onError` handler in `main.tsx` that calls `toast(error.message, 'error')`. Previously silent mutation failures (createTeam, evaluateQualityGate, deleteQualityGate, deleteWebhook, profile update, password change) now display toast feedback. + +- **Inline error display for evaluateMutation**: The quality gates evaluate button shows an inline error message below the gate card when evaluation fails, persisted until the next successful evaluation or a new attempt. This complements the global toast for visibility. + +- **Admin route role guard**: The `/admin` route now uses a `requireOwner` `beforeLoad` guard that redirects non-owners to `/` at the router level, replacing the previous component-side "Access Denied" rendering. + +- **Success toasts for destructive actions**: `deleteQualityGate` and `deleteWebhook` mutations now show success toasts on completion. + ### Changed +- **Profile page refactored to useMutation**: The profile display name and password forms have been refactored from manual `useState` + `try/catch` to TanStack Query `useMutation` with proper `onSuccess`/`onError` callbacks. Profile changes now invalidate the `queryKeys.admin.users()` cache so other pages showing user names stay current. + +- **Evaluate mutation cache invalidation**: `evaluateMutation.onSuccess` now invalidates `queryKeys.qualityGates.evaluations()` so the EvaluationHistory panel shows fresh results after evaluation without a manual refresh. + - **Store layer extraction**: All HTTP handlers now use store interfaces instead of embedding `*db.Pool` directly. Store interfaces are defined on the handler side and implemented in `internal/store/`, making handlers testable without a running database and centralizing SQL query knowledge. Affected handlers: auth, teams, analytics, executions, reports, admin, invitations, oauth. - **Bulk test result inserts**: Report ingestion now uses `pgx.Batch` to insert test results in bulk instead of one query per result. This eliminates the N+1 insert pattern that caused 1000+ round-trips for large reports. diff --git a/CONTEXT.md b/CONTEXT.md deleted file mode 100644 index 3d44a71e..00000000 --- a/CONTEXT.md +++ /dev/null @@ -1,157 +0,0 @@ -# Context - -## Item: sc-8q5yl - -**Title:** Fix analytics browser E2E test: 'Analytics' heading not found -**Status:** in_progress -**Priority:** 2 - -### Description - -The analytics browser E2E test fails in CI at line 78 of analytics.spec.ts: - await expect(page.getByRole('heading', { name: 'Analytics' })).toBeVisible(); - -Error: element(s) not found — timeout 5000ms exceeded (3 retries, all failing). - -The page isn't loading or the heading isn't rendering. Possible causes: -- Navigation to /analytics isn't completing before the assertion -- Auth state is lost during navigation (Zustand in-memory auth doesn't persist) -- The heading text doesn't match exactly ('Analytics' vs something else) -- The page requires data from global-setup that isn't seeded in time - -Investigation needed: -- Check what the page looks like at time of failure (screenshot available at test-results/) -- Verify the nav link click actually navigates to /analytics -- Check if waitForURL or waitForLoadState is needed after navigation -- Confirm the heading text in the actual component - -Acceptance criteria: -- analytics.spec.ts browser test passes consistently in CI -- The 'Analytics' heading is reliably visible before assertions proceed - -## Current Step: implement - -- **Type:** agent -- **Role:** implementer -- **Context:** full_codebase - -## ⚠️ REVISION REQUIRED — Fix these issues before anything else - -This droplet was recirculated. The following issues were found and **must** be fixed. -Do not proceed to implementation until you have read and understood each issue. - -### Issue 1 (from: reviewer) - -No findings. E2E test follows established browser-ui.spec.ts pattern exactly. Auth flow is correct (loadCachedToken + getOrCreateTeam + loginViaUI all default to maintainer user). All asserted headings are rendered unconditionally in analytics.tsx (not gated by loading/error states). waitForURL prevents timing issues. No security, logic, error handling, resource leak, or contract violation issues. - -### Issue 2 (from: qa) - -Phase 1: No prior issues were flagged by the previous reviewer — nothing to verify. Phase 2 fresh review: All tests pass (20 Go packages, 278 frontend). waitForURL('/analytics') is correct given baseURL in playwright.config.ts. All four section h2 headings in analytics.tsx are unconditionally rendered (not gated by loading/empty state). Nav link 'Analytics' confirmed in root-layout.tsx. Auth pattern (getOrCreateTeam + loginViaUI) matches established browser-ui.spec.ts pattern exactly. No gaps found. - -### Issue 3 (from: security) - -No security issues found. Diff adds a single Playwright E2E browser test with no production code changes. No new endpoints, input handling, auth logic, secrets, or trust boundary crossings. Zero attack surface. - -### Issue 4 (from: reviewer) - -No findings. Diff adds a single E2E browser test and documentation — no production code changes. All assertions verified against actual component rendering in analytics.tsx (headings are unconditionally rendered). Auth flow follows established browser-ui.spec.ts pattern. The networkidle wait before navigation correctly addresses the auth race condition. Sign Out aria-label casing is compatible (Playwright case-insensitive). waitForURL glob pattern is correct. No security, logic, error handling, resource leak, or contract issues. - -### Issue 5 (from: qa) - -Phase 1: No open issues from prior QA cycles. Phase 2: Go tests pass (all packages). Frontend tests pass (278/278). The analytics browser test is structurally sound — h1 'Analytics' and all four h2 section headings are unconditionally rendered in analytics.tsx (not gated by loading/empty state). The waitForLoadState('networkidle') before clicking Analytics is a technically correct fix for the documented race condition (dashboard API queries destabilizing Zustand auth before SPA navigation). Auth pattern matches browser-ui.spec.ts exactly. waitForURL('**/analytics') is correct for the configured baseURL. No ambiguous div.p-6.space-y-8 selector. Comments explain non-obvious Zustand auth constraints. No mock substitution issues — this is a real browser test. Ready for delivery. - -### Issue 6 (from: security) - -No security issues found. Diff adds a single Playwright E2E browser test and documentation — no production code changes. No new endpoints, input handling, auth logic, secrets, or trust boundary crossings. Zero attack surface. - ---- - -## Recent Step Notes - -### From: delivery - -♻ CI recirculation: 2 failed fix attempts on the same check. - -Failed check: e2e-test - -Error snippet: - Test: analytics browser: navigate to /analytics via nav link and assert page renders - Expected: page.getByRole('heading', { name: 'Analytics' }).toBeVisible() - Actual: Element not found — timeout 5000ms exceeded - -Verification notes: -- The analytics.tsx component unconditionally renders h1.text-2xl.font-bold with text "Analytics" -- The root-layout.tsx nav includes the Analytics link: { to: '/analytics', label: 'Analytics' } -- All prior reviewers (code review, QA, security, docs) approved with "No findings" -- QA confirmed: "h1 'Analytics' and all four h2 section headings are unconditionally rendered (not gated by loading/empty state)" - -Fix attempt 1: - - Action: gh run rerun 23717372759 --job 69086558861 - - Result: FAILED with identical error - -Fix attempt 2: - - Action: gh run rerun 23717372759 --job 69087077423 - - Result: FAILED with identical error - -Root cause analysis: -The test failure persists across identical code, suggesting either: -1. A race condition in the SPA navigation flow (waitForURL or waitForLoadState timing) -2. An environmental issue in CI (baseURL mismatch, app not serving frontend, auth state loss) -3. A flaky test that needs additional synchronization beyond current wait conditions - -Recommended fix: -- Inspect the actual CI logs and playwright HTML report for the exact DOM state at failure -- Add explicit waits for the Analytics page container or check DOM visibility before heading assertion -- Verify the SPA router actually navigates (check window.location.href or page.url()) -- Consider if Zustand auth state is being cleared during navigation despite waitForLoadState - -PR: https://github.com/MichielDean/ScaledTest/pull/224 - -### From: docs_writer - -Documentation complete and accurate. Updated CLAUDE.md and README.md with E2E test commands and comprehensive testing section. All npm scripts verified against e2e/package.json. Analytics E2E test properly documented with explanations of auth flow and timing concerns. - -### From: security - -No security issues found. Diff adds a single Playwright E2E browser test and documentation — no production code changes. No new endpoints, input handling, auth logic, secrets, or trust boundary crossings. Zero attack surface. - -### From: qa - -Phase 1: No open issues from prior QA cycles. Phase 2: Go tests pass (all packages). Frontend tests pass (278/278). The analytics browser test is structurally sound — h1 'Analytics' and all four h2 section headings are unconditionally rendered in analytics.tsx (not gated by loading/empty state). The waitForLoadState('networkidle') before clicking Analytics is a technically correct fix for the documented race condition (dashboard API queries destabilizing Zustand auth before SPA navigation). Auth pattern matches browser-ui.spec.ts exactly. waitForURL('**/analytics') is correct for the configured baseURL. No ambiguous div.p-6.space-y-8 selector. Comments explain non-obvious Zustand auth constraints. No mock substitution issues — this is a real browser test. Ready for delivery. - - - - cistern-droplet-state - Manage droplet state in the Cistern agentic pipeline using the `ct` CLI. - /home/lobsterdog/.cistern/skills/cistern-droplet-state/SKILL.md - - - cistern-git - --- - /home/lobsterdog/.cistern/skills/cistern-git/SKILL.md - - - cistern-github - --- - /home/lobsterdog/.cistern/skills/cistern-github/SKILL.md - - - -## Signaling Completion - -When your work is done, signal your outcome using the `ct` CLI: - -**Pass (work complete, move to next step):** - ct droplet pass sc-8q5yl - -**Recirculate (needs rework — send back upstream):** - ct droplet recirculate sc-8q5yl - ct droplet recirculate sc-8q5yl --to implement - -**Block (genuinely blocked, cannot proceed):** - ct droplet block sc-8q5yl - -Add notes before signaling: - ct droplet note sc-8q5yl "What you did / found" - -The `ct` binary is on your PATH. diff --git a/frontend/src/components/__tests__/error-boundary.test.tsx b/frontend/src/components/__tests__/error-boundary.test.tsx new file mode 100644 index 00000000..7ac7bf20 --- /dev/null +++ b/frontend/src/components/__tests__/error-boundary.test.tsx @@ -0,0 +1,91 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { ErrorBoundary } from '../error-boundary'; + +describe('ErrorBoundary', () => { + const originalConsoleError = console.error; + + beforeEach(() => { + console.error = vi.fn(); + }); + + afterEach(() => { + console.error = originalConsoleError; + }); + + it('renders children when no error', () => { + render( + +

Hello world

+
+ ); + expect(screen.getByText('Hello world')).toBeInTheDocument(); + }); + + it('renders default error UI when child throws', () => { + function ThrowingComponent(): React.ReactNode { + throw new Error('Test explosion'); + } + + render( + + + + ); + + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + expect(screen.getByText('Test explosion')).toBeInTheDocument(); + }); + + it('renders custom fallback when provided and child throws', () => { + function ThrowingComponent(): React.ReactNode { + throw new Error('kaboom'); + } + + render( + Custom fallback

}> + +
+ ); + + expect(screen.getByText('Custom fallback')).toBeInTheDocument(); + }); + + it('renders Try Again and Reload buttons in default error UI', () => { + function ThrowingComponent(): React.ReactNode { + throw new Error('failure'); + } + + render( + + + + ); + + expect(screen.getByRole('button', { name: 'Try Again' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Reload' })).toBeInTheDocument(); + }); + + it('Resetting error boundary allows retry via Try Again', () => { + let shouldThrow = true; + + function MaybeThrowingComponent(): React.ReactNode { + if (shouldThrow) { + throw new Error('failure'); + } + return

Recovered

; + } + + render( + + + + ); + + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + + shouldThrow = false; + fireEvent.click(screen.getByRole('button', { name: 'Try Again' })); + + expect(screen.getByText('Recovered')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/__tests__/toast.test.tsx b/frontend/src/components/__tests__/toast.test.tsx new file mode 100644 index 00000000..be8d38da --- /dev/null +++ b/frontend/src/components/__tests__/toast.test.tsx @@ -0,0 +1,112 @@ +import { render, screen, act } from '@testing-library/react'; +import { ToastProvider, toast } from '../toast'; + +describe('ToastProvider', () => { + it('renders children', () => { + render( + +

App content

+
+ ); + expect(screen.getByText('App content')).toBeInTheDocument(); + }); + + it('renders error toast when toast is called', () => { + function TestComponent() { + return ( + + ); + } + + render( + + + + ); + + act(() => { + screen.getByRole('button', { name: 'Trigger' }).click(); + }); + + expect(screen.getByText('Something failed')).toBeInTheDocument(); + }); + + it('renders success toast when toast is called with success variant', () => { + function TestComponent() { + return ( + + ); + } + + render( + + + + ); + + act(() => { + screen.getByRole('button', { name: 'Trigger' }).click(); + }); + + expect(screen.getByText('It worked!')).toBeInTheDocument(); + }); + + it('renders multiple toasts when called multiple times', () => { + function TestComponent() { + return ( + <> + + + + ); + } + + render( + + + + ); + + act(() => { + screen.getByRole('button', { name: 'First' }).click(); + }); + act(() => { + screen.getByRole('button', { name: 'Second' }).click(); + }); + + expect(screen.getByText('Error one')).toBeInTheDocument(); + expect(screen.getByText('Success two')).toBeInTheDocument(); + }); + + it('queues toasts called before mount and renders them after mount', () => { + toast('Before mount message', 'error'); + + expect(screen.queryByText('Before mount message')).not.toBeInTheDocument(); + + render( + +

Content

+
+ ); + + expect(screen.getByText('Before mount message')).toBeInTheDocument(); + }); + + it('cleans up addToastFn on unmount to prevent stale closures', () => { + const { unmount } = render( + +

Content

+
+ ); + + unmount(); + + toast('After unmount', 'error'); + + expect(screen.queryByText('After unmount')).not.toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/error-boundary.tsx b/frontend/src/components/error-boundary.tsx new file mode 100644 index 00000000..dec642a5 --- /dev/null +++ b/frontend/src/components/error-boundary.tsx @@ -0,0 +1,53 @@ +import { Component } from 'react'; + +interface ErrorBoundaryProps { + children: React.ReactNode; + fallback?: React.ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + return ( +
+

Something went wrong

+

+ {this.state.error?.message ?? 'An unexpected error occurred.'} +

+
+ + +
+
+ ); + } + return this.props.children; + } +} \ No newline at end of file diff --git a/frontend/src/components/toast.tsx b/frontend/src/components/toast.tsx new file mode 100644 index 00000000..f2d11275 --- /dev/null +++ b/frontend/src/components/toast.tsx @@ -0,0 +1,73 @@ +import { useState, useCallback, useEffect } from 'react'; +import * as ToastPrimitive from '@radix-ui/react-toast'; + +interface Toast { + id: string; + title: string; + variant: 'error' | 'success'; +} + +type AddToastFn = (toast: Omit) => void; + +let addToastFn: AddToastFn | null = null; + +const pendingToasts: Array> = []; + +export function toast(title: string, variant: 'error' | 'success' = 'error') { + if (addToastFn) { + addToastFn({ title, variant }); + } else { + pendingToasts.push({ title, variant }); + } +} + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]); + + const addToast = useCallback((t: Omit) => { + const id = Math.random().toString(36).slice(2); + setToasts(prev => [...prev, { ...t, id }]); + setTimeout(() => { + setToasts(prev => prev.filter(item => item.id !== id)); + }, 5000); + }, []); + + useEffect(() => { + addToastFn = addToast; + while (pendingToasts.length > 0) { + addToast(pendingToasts.shift()!); + } + return () => { + if (addToastFn === addToast) { + addToastFn = null; + } + }; + }, [addToast]); + + return ( + + {children} + + {toasts.map(t => ( + +
+ + {t.title} + + + ✕ + +
+
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index be69e0e8..ad2d0c51 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,6 +3,8 @@ import ReactDOM from 'react-dom/client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { RouterProvider, createRouter } from '@tanstack/react-router'; import { routeTree } from './routes/route-tree'; +import { ErrorBoundary } from './components/error-boundary'; +import { ToastProvider, toast } from './components/toast'; import './index.css'; const queryClient = new QueryClient({ @@ -11,6 +13,11 @@ const queryClient = new QueryClient({ staleTime: 30_000, retry: 1, }, + mutations: { + onError: (error) => { + toast(error.message, 'error'); + }, + }, }, }); @@ -24,8 +31,12 @@ declare module '@tanstack/react-router' { ReactDOM.createRoot(document.getElementById('root')!).render( - - - + + + + + + + ); diff --git a/frontend/src/routes/__tests__/admin.test.tsx b/frontend/src/routes/__tests__/admin.test.tsx index 9137abc4..42f5d033 100644 --- a/frontend/src/routes/__tests__/admin.test.tsx +++ b/frontend/src/routes/__tests__/admin.test.tsx @@ -3,20 +3,33 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { AdminPage } from '../admin'; import { api } from '../../lib/api'; import { useAuthStore } from '../../stores/auth-store'; +import { ToastProvider, toast } from '../../components/toast'; vi.mock('../../lib/api', () => ({ api: { adminListUsers: vi.fn(), getTeams: vi.fn(), adminListAuditLog: vi.fn(), + createTeam: vi.fn(), }, })); function renderWithClient(ui: React.ReactElement) { const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, + defaultOptions: { + queries: { retry: false }, + mutations: { + onError: (error: Error) => { + toast(error.message, 'error'); + }, + }, + }, }); - return render({ui}); + return render( + + {ui} + + ); } describe('AdminPage', () => { @@ -225,4 +238,30 @@ describe('AdminPage', () => { expect(vi.mocked(api.adminListAuditLog)).toHaveBeenCalledWith(20, 0, 'report.submitted'); }); }); -}); + + it('shows toast error when createTeam mutation fails', async () => { + vi.mocked(api.createTeam).mockRejectedValue(new Error('Team already exists')); + renderWithClient(); + + const input = screen.getByPlaceholderText('New team name'); + fireEvent.change(input, { target: { value: 'Duplicate Team' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create Team' })); + + await waitFor(() => { + expect(screen.getByText(/Team already exists/)).toBeInTheDocument(); + }); + }); + + it('shows toast success when createTeam mutation succeeds', async () => { + vi.mocked(api.createTeam).mockResolvedValue({ id: 't2', name: 'New Team', created_at: '2026-01-01T00:00:00Z' }); + renderWithClient(); + + const input = screen.getByPlaceholderText('New team name'); + fireEvent.change(input, { target: { value: 'New Team' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create Team' })); + + await waitFor(() => { + expect(screen.getByText('Team created successfully.')).toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/routes/__tests__/profile.test.tsx b/frontend/src/routes/__tests__/profile.test.tsx index bff87382..0827149b 100644 --- a/frontend/src/routes/__tests__/profile.test.tsx +++ b/frontend/src/routes/__tests__/profile.test.tsx @@ -1,5 +1,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ProfilePage } from '../profile'; +import { ToastProvider, toast } from '../../components/toast'; const { mockUpdateProfile, mockChangePassword, mockSetUser } = vi.hoisted(() => ({ mockUpdateProfile: vi.fn(), @@ -21,25 +23,51 @@ vi.mock('../../stores/auth-store', () => ({ }), })); +vi.mock('../../lib/query-keys', () => ({ + queryKeys: { + admin: { + users: () => ['admin', 'users'], + }, + }, +})); + +function renderWithClient(ui: React.ReactElement) { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { + onError: (error: Error) => { + toast(error.message, 'error'); + }, + }, + }, + }); + return render( + + {ui} + + ); +} + describe('ProfilePage', () => { beforeEach(() => { vi.clearAllMocks(); }); it('renders profile settings heading', () => { - render(); + renderWithClient(); expect(screen.getByRole('heading', { name: 'Profile Settings' })).toBeInTheDocument(); }); it('renders display name form with current value', () => { - render(); + renderWithClient(); const input = screen.getByLabelText('Display Name'); expect(input).toBeInTheDocument(); expect((input as HTMLInputElement).value).toBe('Test User'); }); it('renders change password form', () => { - render(); + renderWithClient(); expect(screen.getByLabelText('Current Password')).toBeInTheDocument(); expect(screen.getByLabelText('New Password')).toBeInTheDocument(); expect(screen.getByLabelText('Confirm New Password')).toBeInTheDocument(); @@ -52,7 +80,7 @@ describe('ProfilePage', () => { display_name: 'New Name', role: 'maintainer', }); - render(); + renderWithClient(); fireEvent.change(screen.getByLabelText('Display Name'), { target: { value: 'New Name' } }); fireEvent.click(screen.getByRole('button', { name: 'Save Name' })); @@ -73,7 +101,7 @@ describe('ProfilePage', () => { it('shows error when updateProfile fails', async () => { mockUpdateProfile.mockRejectedValue(new Error('Server error')); - render(); + renderWithClient(); fireEvent.click(screen.getByRole('button', { name: 'Save Name' })); @@ -82,7 +110,7 @@ describe('ProfilePage', () => { it('shows loading state during display name save', async () => { mockUpdateProfile.mockImplementation(() => new Promise(() => {})); - render(); + renderWithClient(); fireEvent.click(screen.getByRole('button', { name: 'Save Name' })); @@ -93,7 +121,7 @@ describe('ProfilePage', () => { it('calls changePassword on password form submit', async () => { mockChangePassword.mockResolvedValue({ message: 'password changed' }); - render(); + renderWithClient(); fireEvent.change(screen.getByLabelText('Current Password'), { target: { value: 'oldpass123' }, @@ -111,7 +139,7 @@ describe('ProfilePage', () => { }); it('shows error when passwords do not match', async () => { - render(); + renderWithClient(); fireEvent.change(screen.getByLabelText('Current Password'), { target: { value: 'oldpass123' }, @@ -127,7 +155,7 @@ describe('ProfilePage', () => { }); it('shows error when new password is too short', async () => { - render(); + renderWithClient(); fireEvent.change(screen.getByLabelText('Current Password'), { target: { value: 'oldpass123' }, @@ -146,7 +174,7 @@ describe('ProfilePage', () => { it('shows error when changePassword fails', async () => { mockChangePassword.mockRejectedValue(new Error('Invalid current password')); - render(); + renderWithClient(); fireEvent.change(screen.getByLabelText('Current Password'), { target: { value: 'wrongpass' }, @@ -162,7 +190,7 @@ describe('ProfilePage', () => { it('shows loading state during password change', async () => { mockChangePassword.mockImplementation(() => new Promise(() => {})); - render(); + renderWithClient(); fireEvent.change(screen.getByLabelText('Current Password'), { target: { value: 'oldpass123' }, @@ -180,7 +208,7 @@ describe('ProfilePage', () => { it('clears password fields after successful change', async () => { mockChangePassword.mockResolvedValue({ message: 'password changed' }); - render(); + renderWithClient(); fireEvent.change(screen.getByLabelText('Current Password'), { target: { value: 'oldpass123' }, @@ -197,4 +225,33 @@ describe('ProfilePage', () => { expect((screen.getByLabelText('Confirm New Password') as HTMLInputElement).value).toBe(''); }); }); -}); + + it('shows toast error when profile mutation fails', async () => { + mockUpdateProfile.mockRejectedValue(new Error('Network error')); + renderWithClient(); + + fireEvent.click(screen.getByRole('button', { name: 'Save Name' })); + + await waitFor(() => { + expect(screen.getAllByText(/Network error/).length).toBeGreaterThan(0); + }); + }); + + it('shows toast error when password mutation fails', async () => { + mockChangePassword.mockRejectedValue(new Error('Weak password')); + renderWithClient(); + + fireEvent.change(screen.getByLabelText('Current Password'), { + target: { value: 'oldpass123' }, + }); + fireEvent.change(screen.getByLabelText('New Password'), { target: { value: 'newpass123' } }); + fireEvent.change(screen.getByLabelText('Confirm New Password'), { + target: { value: 'newpass123' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Change Password' })); + + await waitFor(() => { + expect(screen.getAllByText(/Weak password/).length).toBeGreaterThan(0); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/routes/__tests__/quality-gates.test.tsx b/frontend/src/routes/__tests__/quality-gates.test.tsx index 915ed3ea..a166455f 100644 --- a/frontend/src/routes/__tests__/quality-gates.test.tsx +++ b/frontend/src/routes/__tests__/quality-gates.test.tsx @@ -2,6 +2,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QualityGatesPage } from '../quality-gates'; import { api } from '../../lib/api'; +import { ToastProvider, toast } from '../../components/toast'; vi.mock('../../lib/api', () => ({ api: { @@ -19,9 +20,20 @@ const TEAM = { id: 't1', name: 'Alpha Team' }; function renderWithClient(ui: React.ReactElement) { const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, + defaultOptions: { + queries: { retry: false }, + mutations: { + onError: (error: Error) => { + toast(error.message, 'error'); + }, + }, + }, }); - return render({ui}); + return render( + + {ui} + + ); } describe('QualityGatesPage', () => { @@ -231,4 +243,165 @@ describe('QualityGatesPage', () => { expect(await screen.findByText('Failed')).toBeInTheDocument(); }); + + it('shows toast error when delete mutation fails', async () => { + vi.mocked(api.getQualityGates).mockResolvedValue({ + quality_gates: [ + { + id: 'g1', + team_id: 't1', + name: 'Delete Me', + description: '', + rules: [{ type: 'pass_rate', params: { threshold: 90 } }], + active: true, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + ], + total: 1, + }); + vi.mocked(api.deleteQualityGate).mockRejectedValue(new Error('Cannot delete')); + + renderWithClient(); + + fireEvent.click(await screen.findByRole('button', { name: 'Delete' })); + fireEvent.click(await screen.findByRole('button', { name: 'Confirm' })); + + await waitFor(() => { + expect(screen.getByText(/Cannot delete/)).toBeInTheDocument(); + }); + }); + + it('shows toast error when evaluate mutation fails', async () => { + vi.mocked(api.getQualityGates).mockResolvedValue({ + quality_gates: [ + { + id: 'g1', + team_id: 't1', + name: 'Eval Gate', + description: '', + rules: [{ type: 'pass_rate', params: { threshold: 90 } }], + active: true, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + ], + total: 1, + }); + vi.mocked(api.evaluateQualityGate).mockRejectedValue(new Error('Server error')); + + renderWithClient(); + + fireEvent.click(await screen.findByRole('button', { name: 'Evaluate' })); + + await waitFor(() => { + const matches = screen.getAllByText('Server error'); + const inlineError = matches.find(el => el.closest('p')?.className.includes('destructive')); + expect(inlineError).toBeTruthy(); + }); + }); + + it('shows inline error message when evaluate mutation fails', async () => { + vi.mocked(api.getQualityGates).mockResolvedValue({ + quality_gates: [ + { + id: 'g1', + team_id: 't1', + name: 'Inline Gate', + description: '', + rules: [{ type: 'pass_rate', params: { threshold: 90 } }], + active: true, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + ], + total: 1, + }); + vi.mocked(api.evaluateQualityGate).mockRejectedValue(new Error('Evaluation failed')); + + renderWithClient(); + + fireEvent.click(await screen.findByRole('button', { name: 'Evaluate' })); + + const inlineErrors = await screen.findAllByText('Evaluation failed'); + const inlineError = inlineErrors.find(el => el.closest('p')?.className.includes('destructive')); + expect(inlineError).toBeTruthy(); + }); + + it('clears inline evaluate error on successful evaluation', async () => { + vi.mocked(api.getQualityGates).mockResolvedValue({ + quality_gates: [ + { + id: 'g1', + team_id: 't1', + name: 'Clear Gate', + description: '', + rules: [{ type: 'pass_rate', params: { threshold: 90 } }], + active: true, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + ], + total: 1, + }); + vi.mocked(api.evaluateQualityGate) + .mockRejectedValueOnce(new Error('First fail')) + .mockResolvedValueOnce({ + id: 'eval-1', + gate_id: 'g1', + report_id: 'r1', + passed: true, + details: { passed: true, results: [] }, + created_at: '2026-01-01T00:00:00Z', + }); + + renderWithClient(); + + fireEvent.click(await screen.findByRole('button', { name: 'Evaluate' })); + + await screen.findAllByText('First fail'); + + fireEvent.click(screen.getByRole('button', { name: 'Evaluate' })); + + await waitFor(() => { + const inlineErrors = screen.queryAllByText('First fail'); + const persisted = inlineErrors.find(el => el.closest('p')?.className.includes('destructive')); + expect(persisted).toBeUndefined(); + }); + }); + + it('invalidates evaluations cache after successful evaluation', async () => { + const invalidateFn = vi.fn(); + vi.mocked(api.getQualityGates).mockResolvedValue({ + quality_gates: [ + { + id: 'g1', + team_id: 't1', + name: 'Eval Gate', + description: '', + rules: [{ type: 'pass_rate', params: { threshold: 90 } }], + active: true, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + ], + total: 1, + }); + vi.mocked(api.evaluateQualityGate).mockResolvedValue({ + id: 'eval-1', + gate_id: 'g1', + report_id: 'r1', + passed: true, + details: { passed: true, results: [] }, + created_at: '2026-01-01T00:00:00Z', + }); + + renderWithClient(); + + fireEvent.click(await screen.findByRole('button', { name: 'Evaluate' })); + + await waitFor(() => { + expect(api.evaluateQualityGate).toHaveBeenCalledWith('t1', 'g1'); + }); + }); }); diff --git a/frontend/src/routes/__tests__/webhooks.test.tsx b/frontend/src/routes/__tests__/webhooks.test.tsx index 1ff3e5fc..e35ccee6 100644 --- a/frontend/src/routes/__tests__/webhooks.test.tsx +++ b/frontend/src/routes/__tests__/webhooks.test.tsx @@ -2,6 +2,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { WebhooksPage } from '../webhooks'; import { api } from '../../lib/api'; +import { ToastProvider, toast } from '../../components/toast'; vi.mock('../../lib/api', () => ({ api: { @@ -17,9 +18,20 @@ vi.mock('../../lib/api', () => ({ function renderWithClient(ui: React.ReactElement) { const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, + defaultOptions: { + queries: { retry: false }, + mutations: { + onError: (error: Error) => { + toast(error.message, 'error'); + }, + }, + }, }); - return render({ui}); + return render( + + {ui} + + ); } const mockTeams = { teams: [{ id: 'team-1', name: 'Test Team' }] }; @@ -365,4 +377,37 @@ describe('WebhooksPage', () => { // Second call should pass the ID of the last delivery from the first page expect(vi.mocked(api.getWebhookDeliveries)).toHaveBeenCalledWith('team-1', 'wh-1', 'del-19'); }); + + it('shows toast error when delete webhook mutation fails', async () => { + vi.mocked(api.getTeams).mockResolvedValue(mockTeams); + vi.mocked(api.getWebhooks).mockResolvedValue(mockWebhooks); + vi.mocked(api.deleteWebhook).mockRejectedValue(new Error('Cannot delete webhook')); + + renderWithClient(); + + fireEvent.click(await screen.findByRole('button', { name: 'Delete' })); + fireEvent.click(await screen.findByRole('button', { name: 'Confirm' })); + + await waitFor(() => { + expect(screen.getByText(/Cannot delete webhook/)).toBeInTheDocument(); + }); + }); + + it('shows toast success when delete webhook mutation succeeds', async () => { + vi.mocked(api.getTeams).mockResolvedValue(mockTeams); + vi.mocked(api.getWebhooks) + .mockResolvedValueOnce(mockWebhooks) + .mockResolvedValueOnce({ webhooks: [], total: 0 }); + vi.mocked(api.deleteWebhook).mockResolvedValue(undefined); + + renderWithClient(); + + expect(await screen.findByText('https://example.com/hook')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Delete' })); + fireEvent.click(await screen.findByRole('button', { name: 'Confirm' })); + + await waitFor(() => { + expect(screen.getByText('Webhook deleted.')).toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/routes/admin.tsx b/frontend/src/routes/admin.tsx index 3bf12112..22ed4fe0 100644 --- a/frontend/src/routes/admin.tsx +++ b/frontend/src/routes/admin.tsx @@ -4,6 +4,7 @@ import { api } from '../lib/api'; import { queryKeys } from '../lib/query-keys'; import { useAuthStore } from '../stores/auth-store'; import { formatDate, formatDateTime } from '../lib/date'; +import { toast } from '../components/toast'; const AUDIT_PAGE_SIZE = 20; const TH_CLASS = 'text-left p-3 text-muted-foreground text-xs uppercase tracking-wider font-medium'; @@ -257,6 +258,7 @@ function TeamsSection() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.teams.list() }); setNewTeamName(''); + toast('Team created successfully.', 'success'); }, }); diff --git a/frontend/src/routes/analytics.tsx b/frontend/src/routes/analytics.tsx index fcac6141..b4f4b95e 100644 --- a/frontend/src/routes/analytics.tsx +++ b/frontend/src/routes/analytics.tsx @@ -16,6 +16,7 @@ import { api } from '../lib/api'; import { queryKeys } from '../lib/query-keys'; import { CHART_TOOLTIP_STYLE } from './dashboard'; import { formatDate, formatDateShort } from '../lib/date'; +import { ErrorBoundary } from '../components/error-boundary'; interface TrendPoint { date: string; @@ -88,6 +89,7 @@ export function AnalyticsPage() { {/* Pass Rate Trends */}

Pass Rate Trends

+ {trendsQuery.isLoading ? ( ) : trends.length === 0 ? ( @@ -113,6 +115,7 @@ export function AnalyticsPage() { )} +
@@ -153,6 +156,7 @@ export function AnalyticsPage() { {/* Duration Distribution */}

Duration Distribution

+ {durationQuery.isLoading ? ( ) : distribution.length === 0 ? ( @@ -172,6 +176,7 @@ export function AnalyticsPage() { )} +
diff --git a/frontend/src/routes/dashboard.tsx b/frontend/src/routes/dashboard.tsx index b348dfcb..3c3eceea 100644 --- a/frontend/src/routes/dashboard.tsx +++ b/frontend/src/routes/dashboard.tsx @@ -14,6 +14,7 @@ import { BarChart2, ChevronRight, Play, TrendingUp, Zap } from 'lucide-react'; import { api } from '../lib/api'; import { queryKeys } from '../lib/query-keys'; import { formatDate, formatDateShort } from '../lib/date'; +import { ErrorBoundary } from '../components/error-boundary'; // --------------------------------------------------------------------------- // Types @@ -153,6 +154,7 @@ export function DashboardPage() { {/* ---- Trends chart ---- */}

Pass Rate Trends

+ {trendsQuery.isLoading ? (
Loading chart... @@ -181,6 +183,7 @@ export function DashboardPage() { )} +
{/* ---- Tables row ---- */} diff --git a/frontend/src/routes/profile.tsx b/frontend/src/routes/profile.tsx index a38a9003..92315025 100644 --- a/frontend/src/routes/profile.tsx +++ b/frontend/src/routes/profile.tsx @@ -1,39 +1,61 @@ import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useAuthStore } from '../stores/auth-store'; import { api } from '../lib/api'; +import { queryKeys } from '../lib/query-keys'; export function ProfilePage() { const { user, setUser } = useAuthStore(); + const queryClient = useQueryClient(); const [displayName, setDisplayName] = useState(user?.display_name ?? ''); - const [profileSuccess, setProfileSuccess] = useState(''); const [profileError, setProfileError] = useState(''); - const [profileLoading, setProfileLoading] = useState(false); + const [profileSuccess, setProfileSuccess] = useState(''); const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); - const [passwordSuccess, setPasswordSuccess] = useState(''); const [passwordError, setPasswordError] = useState(''); - const [passwordLoading, setPasswordLoading] = useState(false); + const [passwordSuccess, setPasswordSuccess] = useState(''); + + const profileMutation = useMutation({ + mutationFn: (name: string) => api.updateProfile(name), + onSuccess: (updated) => { + setUser({ id: updated.id, email: updated.email, display_name: updated.display_name, role: updated.role }); + setProfileSuccess('Display name updated.'); + setProfileError(''); + void queryClient.invalidateQueries({ queryKey: queryKeys.admin.users() }); + }, + onError: (err: Error) => { + setProfileError(err.message); + setProfileSuccess(''); + }, + }); + + const passwordMutation = useMutation({ + mutationFn: ({ current, next }: { current: string; next: string }) => + api.changePassword(current, next), + onSuccess: () => { + setPasswordSuccess('Password changed successfully.'); + setPasswordError(''); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + }, + onError: (err: Error) => { + setPasswordError(err.message); + setPasswordSuccess(''); + }, + }); - const handleProfileSubmit = async (e: React.FormEvent) => { + const handleProfileSubmit = (e: React.FormEvent) => { e.preventDefault(); setProfileError(''); setProfileSuccess(''); - setProfileLoading(true); - try { - const updated = await api.updateProfile(displayName); - setUser({ id: updated.id, email: updated.email, display_name: updated.display_name, role: updated.role }); - setProfileSuccess('Display name updated.'); - } catch (err) { - setProfileError(err instanceof Error ? err.message : 'Update failed'); - } finally { - setProfileLoading(false); - } + profileMutation.mutate(displayName); }; - const handlePasswordSubmit = async (e: React.FormEvent) => { + const handlePasswordSubmit = (e: React.FormEvent) => { e.preventDefault(); setPasswordError(''); setPasswordSuccess(''); @@ -47,18 +69,7 @@ export function ProfilePage() { return; } - setPasswordLoading(true); - try { - await api.changePassword(currentPassword, newPassword); - setPasswordSuccess('Password changed successfully.'); - setCurrentPassword(''); - setNewPassword(''); - setConfirmPassword(''); - } catch (err) { - setPasswordError(err instanceof Error ? err.message : 'Password change failed'); - } finally { - setPasswordLoading(false); - } + passwordMutation.mutate({ current: currentPassword, next: newPassword }); }; return ( @@ -93,10 +104,10 @@ export function ProfilePage() { @@ -155,13 +166,13 @@ export function ProfilePage() { ); -} +} \ No newline at end of file diff --git a/frontend/src/routes/quality-gates.tsx b/frontend/src/routes/quality-gates.tsx index 2bdee770..d31b96a4 100644 --- a/frontend/src/routes/quality-gates.tsx +++ b/frontend/src/routes/quality-gates.tsx @@ -4,6 +4,7 @@ import { AlertCircle, CheckCircle2, ShieldCheck } from 'lucide-react'; import { api } from '../lib/api'; import { queryKeys } from '../lib/query-keys'; import { formatDateTime } from '../lib/date'; +import { toast } from '../components/toast'; const RULE_TYPES = [ { value: 'pass_rate', label: 'Pass Rate (%)', placeholder: '95', hasThreshold: true }, @@ -81,6 +82,7 @@ export function QualityGatesPage() { onSuccess: () => { setConfirmDelete(null); void queryClient.invalidateQueries({ queryKey: queryKeys.qualityGates.all(teamId!) }); + toast('Quality gate deleted.', 'success'); }, }); @@ -226,12 +228,20 @@ function GateCard({ onCancelDelete: () => void; deleteIsPending: boolean; }) { + const queryClient = useQueryClient(); const [lastEvaluation, setLastEvaluation] = useState(null); + const [evaluateError, setEvaluateError] = useState(null); + const evaluateMutation = useMutation({ mutationFn: (id: string) => api.evaluateQualityGate(teamId, id) as Promise, onSuccess: result => { + setEvaluateError(null); setLastEvaluation(result); + void queryClient.invalidateQueries({ queryKey: queryKeys.qualityGates.evaluations(teamId, gate.id) }); + }, + onError: (err: Error) => { + setEvaluateError(err.message); }, }); @@ -304,6 +314,12 @@ function GateCard({ )} + {evaluateError && ( +

+ + {evaluateError} +

+ )} {lastEvaluation && lastEvaluation.details?.results && (
diff --git a/frontend/src/routes/route-tree.tsx b/frontend/src/routes/route-tree.tsx index 16224985..8ed669ec 100644 --- a/frontend/src/routes/route-tree.tsx +++ b/frontend/src/routes/route-tree.tsx @@ -23,7 +23,30 @@ function requireAuth() { } } -const rootRoute = createRootRoute(); +function requireOwner() { + requireAuth(); + const state = useAuthStore.getState(); + if (state.user?.role !== 'owner') { + throw redirect({ to: '/' }); + } +} + +const rootRoute = createRootRoute({ + errorComponent: ({ error }) => ( +
+
+

Something went wrong

+

{error.message ?? 'An unexpected error occurred.'}

+ +
+
+ ), +}); // Pathless layout route: authenticated app shell with sidebar const appLayoutRoute = createRoute({ @@ -122,7 +145,7 @@ const shardingRoute = createRoute({ const adminRoute = createRoute({ getParentRoute: () => appLayoutRoute, path: '/admin', - beforeLoad: requireAuth, + beforeLoad: requireOwner, component: AdminPage, }); diff --git a/frontend/src/routes/webhooks.tsx b/frontend/src/routes/webhooks.tsx index 75f89bb6..ee55215b 100644 --- a/frontend/src/routes/webhooks.tsx +++ b/frontend/src/routes/webhooks.tsx @@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tansta import { api } from '../lib/api'; import { queryKeys } from '../lib/query-keys'; import { formatDate } from '../lib/date'; +import { toast } from '../components/toast'; const WEBHOOK_EVENTS = [ { value: 'report.submitted', label: 'Report Submitted' }, @@ -84,6 +85,7 @@ export function WebhooksPage() { onSuccess: () => { setConfirmDelete(null); void queryClient.invalidateQueries({ queryKey: queryKeys.webhooks.all(teamId!) }); + toast('Webhook deleted.', 'success'); }, });