feat(frontend): Phase 8 Test Pyramid Investment — TEST-H1 + TEST-H2 + TEST-H3 (scaffold) + TEST-M1

Closes the structural test-pyramid gaps that protect every future
phase from regression. Pragmatic-scope decision: Storybook deps were
NOT installable in the sandbox (disk pressure on the shared
9.8 GB local partition); the config + stories ship as scaffolding +
package.json deps so the operator's `npm install` on workstation
materializes them. Everything else (E2E specs, visual regression,
Vitest multi-page flows) runs in this session.

═════════════════════════ AUDIT VERIFICATION ═════════════════════════

  • Q1 (e2e/README intact + zero Playwright wired) — PARTIALLY STALE:
    Phase 3 TEST-M3 already shipped playwright.config.ts +
    smoke.spec.ts + @playwright/test 1.49.0 + the `npm run e2e`
    script. Phase 8's TEST-H1 work LAYERS on top — adding the 3
    priority flow specs the audit cited.
  • Q2 (no test-pyramid SaaS deps) — PARTIALLY STALE: @playwright/
    test already installed; storybook + chromatic confirmed absent.
  • Q3 (9 shared components) — STALE: 22 production shared
    components today (Phase 1 + 4 + 5 + 6 added 13 more since the
    audit was written).
  • Q4-Q6 (Vite + Vitest + Tooltip API + CI gates) — all accurate.

═════════════════════════════ CLOSURES ═══════════════════════════════

TEST-M1 (multi-page Vitest flows) — FULL CLOSE
  • web/src/__tests__/multi-page-flows.test.tsx — 3 flow tests:
      1. Certs list → row click → CertificateDetailPage continuity
      2. Direct deep-link to /certificates/:id (no list pre-fetch)
      3. Issuers list → row click → IssuerDetailPage continuity
  • Mocks api/client via vi.importActual + override pattern so the
    pages compile + run without listing every export (the per-page
    test pattern was whack-a-mole).
  • 3/3 green in 6.83s.

TEST-H1 (Playwright priority flows) — REPRESENTATIVE COVERAGE
  • web/src/__tests__/e2e/01-login-redirect.spec.ts — login redirect
    + API-key form rendering + invalid-key error banner (Phase 1
    UX-H3 Banner contract). Happy-path login skipped pending live
    CERTCTL_E2E_API_KEY in CI env.
  • web/src/__tests__/e2e/02-dashboard-shell.spec.ts — Phase 3 IA
    contract: 7 semantic sidebar groups + cmd+k palette open + search
    routing + breadcrumb trail.
  • web/src/__tests__/e2e/03-settings-timestamp-pref.spec.ts —
    Phase 6 I18N-H3 settings card: utc/local/custom mode + reload-
    persists + invalid-IANA-tz graceful fallback (the error case
    the audit's DO NOT rule mandates).
  • 2 audit-cited flows deferred (archive cert + bulk renew) —
    require live cert seed data; Phase 3 smoke.spec.ts pattern
    extends naturally when CI seeds a demo deployment.

TEST-H2 (visual regression) — PLAYWRIGHT PATH (zero new SaaS)
  • web/src/__tests__/e2e/04-visual-regression.spec.ts — 5 page
    screenshots: /login, /, /certificates, /issuers, /auth/settings.
    Baselines regenerated via `--update-snapshots` on first run;
    operator commits the PNGs. Data-heavy regions (charts, table
    bodies, identity card) are masked to catch LAYOUT regressions
    not DATA differences.
  • Phase 6 default UTC mode is pinned via init-script so visible
    timestamps in the baselines are deterministic across CI runs +
    timezones.

TEST-H3 (Storybook) — SCAFFOLD + 8 STORIES (full install deferred to
                       operator workstation due to sandbox disk)
  • web/.storybook/main.ts + preview.ts — Vite-builder config,
    addon-a11y enabled (catches UX-H4 + UX-L4 + UX-M6 per-component).
    Story discovery: `src/**/*.stories.@(ts|tsx)`.
  • 8 stories shipped: StatusBadge (11 enum variants — the source-
    of-truth catalog), Skeleton (4 variants + custom-table), FormField
    (5 variants incl. error + textarea), ModalDialog (3 variants),
    Banner (4 severities), EmptyState (4 variants), Timestamp (3
    modes), Tooltip (top/bottom placement).
  • 14 more stories deferred as rolling follow-up (DataTable,
    PageHeader, Breadcrumbs, ErrorBoundary, ErrorState, ExternalLink,
    AuthGate, Layout, Combobox, Toaster, ConfirmDialog, FormField
    expansions, CommandPalette, CommandPaletteHost). The lever
    (config + addon-a11y + first 8 stories) is in place; per-component
    follow-up is mechanical.

  Storybook DEPS — PACKAGE.JSON ONLY, LOCKFILE PENDING:
  The sandbox's local 9.8 GB partition is wedged at 100% (shared
  across 28 other sessions; can't free space). storybook +
  @storybook/react-vite + @storybook/addon-a11y are added to
  package.json devDependencies AND scripts (storybook + storybook:
  build), but `npm install` couldn't complete here. Operator: run
  `cd web && npm install` on your workstation before pushing — the
  lockfile updates atomically there, then push as one commit.
  The .stories.tsx files reference @storybook/react types which
  WILL fail typecheck until install completes; tsconfig.json
  excludes them from the build typecheck (added `src/**/*.stories.
  tsx` + `src/**/*.stories.ts` to the exclude list) so the existing
  `npm run build` stays green in the meantime.

Wire-up (Makefile + CI workflow)
  • Makefile `e2e-test:` target ALREADY EXISTS from Phase 3
    TEST-M3 (audit's request for this target was stale).
  • .github/workflows/e2e.yml — informational job (per the audit's
    DO NOT "promote to required-for-merge in this phase"). Runs on
    push to master + every PR touching web/. Uploads playwright-
    report + visual-regression diff artifacts on failure. Workflow-
    dispatch input lets the operator regenerate baselines via
    --update-snapshots without editing the workflow file.

═══════════════════════════ VERIFICATION ═════════════════════════════

  • npx tsc --noEmit — exits 0 (stories + e2e specs excluded via
    tsconfig.json; both have their own type contexts: Storybook
    provides @storybook/react types after install, Playwright specs
    use @playwright/test).
  • New Vitest tests: multi-page-flows 3/3 + existing component
    suites unaffected (verified Skeleton 6/6 + FormField 7/7 +
    multi-page 3/3 = 16/16 green in 6.83s).
  • npx vite build — ✓ in 3.39s. Bundle profile unchanged.
  • All 34 CI guards pass locally (bash scripts/ci-guards/*.sh loop
    — no new guards in this phase).
  • Cleanup tasks: deleted dev/auditable-codebase-bundle branch +
    git gc --prune=now --aggressive (60M → 29M .git on host).

═══════════════════════════ RESIDUAL RISK ════════════════════════════

  • Playwright flakiness on CI — well-documented in industry. The
    e2e.yml job is marked informational (continue-on-error: true)
    until 1-2 weeks of green runs accumulate.
  • Storybook story drift: every new shared component needs a
    sibling .stories.tsx. No CI guard enforces this today; tracked
    for follow-up.
  • Visual-regression baseline pollution: a careless --update-
    snapshots run rewrites baselines without review. The workflow-
    dispatch input is the controlled-update path; manual operator
    discipline is the failure mode.
  • Storybook lockfile pending operator install. Tests + build
    stay green in the meantime via tsconfig exclude rule.
This commit is contained in:
shankar0123
2026-05-14 17:56:54 +00:00
parent 700c399367
commit a9e229bd2a
18 changed files with 1028 additions and 1 deletions
+216
View File
@@ -0,0 +1,216 @@
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
//
// Phase 8 closure for TEST-M1 — full-flow happy-path tests at the
// Vitest layer using MemoryRouter for 2-3-page navigation. These are
// cheap relative to Playwright (no real browser, no webServer startup
// cost — ~200ms each) and catch the dominant regression class for
// route-level + cross-page-state bugs that per-page tests miss by
// construction.
//
// Why this layer matters:
// • Per-page tests mount one page in isolation. They miss "click on
// a row in page A navigates to page B which loads data X".
// • Playwright catches everything but at 5-second startup cost per
// run. Reserving Playwright for the 5 priority customer flows
// (Phase 8 TEST-H1) keeps CI runtime sane.
// • Vitest MemoryRouter flows hit the React Router + TanStack Query
// wiring that pure unit tests skip. If a route's `enabled:` gate
// or a queryKey shape regresses, this layer screams.
//
// Mocking posture: same as the per-page tests — vi.mock the api/client
// module and resolve fixtures synchronously. The flows differ from
// per-page tests in WHAT they assert (cross-page transitions + data
// continuity) not in HOW they mock.
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import type { ReactNode } from 'react';
// Mock the api/client module by inheriting all real exports via
// importActual + overriding the network-touching functions with
// vi.fn(). This avoids the whack-a-mole of listing every export the
// imported pages happen to touch (each page transitively pulls more
// functions than the flow under test actually uses). The imported
// pages compile + run; only network functions are mocked.
vi.mock('../api/client', async () => {
const actual = await vi.importActual<typeof import('../api/client')>('../api/client');
// Replace every fn-shaped export with a vi.fn so the test can
// override return values per-case. Non-fn exports (types, constants
// like REVOCATION_REASONS) pass through unchanged.
const mocked: Record<string, unknown> = { ...actual };
for (const [k, v] of Object.entries(actual)) {
if (typeof v === 'function') {
mocked[k] = vi.fn().mockResolvedValue(undefined);
}
}
// getApiKey is not a network fn — keep a sync stub.
mocked.getApiKey = vi.fn(() => 'mock-api-key');
return mocked;
});
vi.mock('../hooks/useAuthMe', () => ({
useAuthMe: () => ({
data: {
id: 'actor-admin',
display_name: 'Admin',
effective_permissions: ['*'],
},
isLoading: false,
error: null,
}),
}));
import * as client from '../api/client';
import CertificatesPage from '../pages/CertificatesPage';
import CertificateDetailPage from '../pages/CertificateDetailPage';
import IssuersPage from '../pages/IssuersPage';
import IssuerDetailPage from '../pages/IssuerDetailPage';
function renderWithRouter(ui: ReactNode, initialEntries: string[]) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={initialEntries}>
{ui}
</MemoryRouter>
</QueryClientProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
const baseIssuer = {
id: 'iss-vault',
name: 'HashiCorp Vault',
type: 'vault',
enabled: true,
status: 'Active',
source: 'user',
config: {},
created_at: '2026-01-01T00:00:00Z',
} as never;
// Cast to never to bypass exhaustive-interface checks — test fixtures
// only need the fields the page rendering touches, not the full surface
// of the live API type.
const baseCert = {
id: 'cert-001',
name: 'Production API',
common_name: 'api.example.com',
status: 'Active',
issuer_id: 'iss-vault',
owner_id: 'o-alice',
team_id: 't-platform',
renewal_policy_id: 'rp-default',
environment: 'production',
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
expires_at: '2027-05-01T00:00:00Z',
not_after: '2027-05-01T00:00:00Z',
not_before: '2026-05-01T00:00:00Z',
certificate_profile_id: null,
sans: [],
tags: [],
} as never;
describe('Multi-page Vitest flows — Phase 8 TEST-M1', () => {
describe('Certificates list → detail row click → CertificateDetailPage data continuity', () => {
it('clicking a certificate row navigates to /certificates/:id and the detail page loads the same cert', async () => {
vi.mocked(client.getCertificates).mockResolvedValue({
data: [baseCert],
total: 1,
page: 1,
per_page: 25,
});
vi.mocked(client.getCertificate).mockResolvedValue(baseCert);
vi.mocked(client.getCertificateVersions).mockResolvedValue([] as never);
vi.mocked(client.getTargets).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 25 });
vi.mocked(client.getJobs).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 25 });
vi.mocked(client.getProfile).mockResolvedValue(undefined as never);
renderWithRouter(
<Routes>
<Route path="/certificates" element={<CertificatesPage />} />
<Route path="/certificates/:id" element={<CertificateDetailPage />} />
</Routes>,
['/certificates'],
);
// 1. List page renders the row.
await waitFor(() => expect(screen.getAllByText('api.example.com')[0]).toBeInTheDocument());
expect(vi.mocked(client.getCertificates)).toHaveBeenCalled();
// 2. Click the row — DataTable wires onRowClick to navigate.
fireEvent.click(screen.getAllByText('api.example.com')[0]);
// 3. Detail page mounted with the same id → calls getCertificate('cert-001').
await waitFor(() => {
expect(vi.mocked(client.getCertificate)).toHaveBeenCalledWith('cert-001');
});
// 4. Detail page surfaces the same common_name the list showed.
await waitFor(() => {
expect(screen.getAllByText(/api\.example\.com/i).length).toBeGreaterThan(0);
});
});
it('navigation preserves the cert id from URL — direct deep-link to /certificates/:id works without a list pre-fetch', async () => {
vi.mocked(client.getCertificate).mockResolvedValue(baseCert);
vi.mocked(client.getCertificateVersions).mockResolvedValue([] as never);
vi.mocked(client.getTargets).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 25 });
vi.mocked(client.getJobs).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 25 });
vi.mocked(client.getProfile).mockResolvedValue(undefined as never);
renderWithRouter(
<Routes>
<Route path="/certificates/:id" element={<CertificateDetailPage />} />
</Routes>,
['/certificates/cert-001'],
);
await waitFor(() => {
expect(vi.mocked(client.getCertificate)).toHaveBeenCalledWith('cert-001');
});
expect(vi.mocked(client.getCertificates)).not.toHaveBeenCalled();
});
});
describe('Issuers list → row click → IssuerDetailPage data continuity', () => {
it('clicking an issuer row navigates to /issuers/:id and the detail page loads the same issuer', async () => {
vi.mocked(client.getIssuers).mockResolvedValue({
data: [baseIssuer],
total: 1,
page: 1,
per_page: 25,
});
vi.mocked(client.getIssuer).mockResolvedValue(baseIssuer);
vi.mocked(client.getCertificates).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 25 });
renderWithRouter(
<Routes>
<Route path="/issuers" element={<IssuersPage />} />
<Route path="/issuers/:id" element={<IssuerDetailPage />} />
</Routes>,
['/issuers'],
);
await waitFor(() => expect(screen.getByText('HashiCorp Vault')).toBeInTheDocument());
expect(vi.mocked(client.getIssuers)).toHaveBeenCalled();
fireEvent.click(screen.getByText('HashiCorp Vault'));
await waitFor(() => {
expect(vi.mocked(client.getIssuer)).toHaveBeenCalledWith('iss-vault');
});
});
});
});