diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index f1cff44..d77d451 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -10,21 +10,33 @@ export interface ApiFetchOptions extends Omit { * Avoids re-fetching `/api/v1/admin/ports` on every request when the Zustand * store hasn't hydrated yet (fresh browser context, e2e tests, hard reload). */ const slugToIdCache = new Map(); +/** Dedupe in-flight admin/ports lookups so a stampede of parallel apiFetch + * calls (typical on dashboard mount) collapses into a single network round- + * trip instead of N. */ +let inFlightPortsLookup: Promise | null> | null = null; async function resolvePortIdFromSlug(slug: string): Promise { const cached = slugToIdCache.get(slug); if (cached) return cached; - try { - const res = await fetch('/api/v1/admin/ports', { credentials: 'include' }); - if (!res.ok) return null; - const body = (await res.json()) as { data?: Array<{ id: string; slug: string }> }; - const port = body.data?.find((p) => p.slug === slug); - if (!port) return null; - slugToIdCache.set(slug, port.id); - return port.id; - } catch { - return null; + if (!inFlightPortsLookup) { + inFlightPortsLookup = (async () => { + try { + const res = await fetch('/api/v1/admin/ports', { credentials: 'include' }); + if (!res.ok) return null; + const body = (await res.json()) as { data?: Array<{ id: string; slug: string }> }; + return body.data ?? null; + } catch { + return null; + } + })().finally(() => { + inFlightPortsLookup = null; + }); } + const ports = await inFlightPortsLookup; + const port = ports?.find((p) => p.slug === slug); + if (!port) return null; + slugToIdCache.set(slug, port.id); + return port.id; } /** diff --git a/src/stores/ui-store.ts b/src/stores/ui-store.ts index 5d84d5a..5fecb9e 100644 --- a/src/stores/ui-store.ts +++ b/src/stores/ui-store.ts @@ -24,10 +24,12 @@ export const useUIStore = create()( }), { name: 'pn-crm-ui', + // currentPortId/Slug are URL-derived, not user preferences. PortProvider + // re-populates them from the route on every navigation. Persisting them + // creates a hydration race where queries fire with a stale port id from + // the previous session before the URL-derived effect runs. partialize: (state) => ({ sidebarCollapsed: state.sidebarCollapsed, - currentPortId: state.currentPortId, - currentPortSlug: state.currentPortSlug, darkMode: state.darkMode, }), }, diff --git a/tests/e2e/smoke/03-pipeline.spec.ts b/tests/e2e/smoke/03-pipeline.spec.ts index 8ed1f45..887ae2a 100644 --- a/tests/e2e/smoke/03-pipeline.spec.ts +++ b/tests/e2e/smoke/03-pipeline.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import { login, navigateTo, waitForSheet, PORT_SLUG } from './helpers'; +import { login, navigateTo, waitForSheet } from './helpers'; test.describe('Interest Pipeline', () => { test.beforeEach(async ({ page }) => { @@ -9,7 +9,10 @@ test.describe('Interest Pipeline', () => { test('create a client and interest', async ({ page }) => { // First create a client await navigateTo(page, '/clients'); - await page.getByRole('button', { name: /new client/i }).first().click(); + await page + .getByRole('button', { name: /new client/i }) + .first() + .click(); await waitForSheet(page); const clientName = `Pipeline Client ${Date.now()}`; @@ -76,16 +79,31 @@ test.describe('Interest Pipeline', () => { test('interests page loads with data', async ({ page }) => { await navigateTo(page, '/interests'); await page.waitForLoadState('networkidle'); - await page.waitForTimeout(3000); // Should see interests page content const heading = page.getByText(/interests/i).first(); - await expect(heading).toBeVisible({ timeout: 10_000 }); + await expect(heading).toBeVisible({ timeout: 15_000 }); - // Check for table or board view - const hasTable = await page.locator('table').isVisible({ timeout: 3_000 }).catch(() => false); - const hasBoard = await page.getByText(/open|board|kanban/i).isVisible({ timeout: 3_000 }).catch(() => false); - expect(hasTable || hasBoard).toBeTruthy(); + // Wait for either the table or pipeline board to render (dev-mode JIT can + // take >3s on first hit). `isVisible()` is non-waiting; use `expect.poll` + // to actually wait for one of the views to appear. + await expect + .poll( + async () => { + const table = await page + .locator('table') + .isVisible() + .catch(() => false); + const board = await page + .locator('[data-testid="pipeline-board"], [class*="board"]') + .first() + .isVisible() + .catch(() => false); + return table || board; + }, + { timeout: 15_000 }, + ) + .toBe(true); }); test('interest detail page works', async ({ page }) => { diff --git a/tests/e2e/smoke/10-dashboard.spec.ts b/tests/e2e/smoke/10-dashboard.spec.ts index 8f61389..7bf3659 100644 --- a/tests/e2e/smoke/10-dashboard.spec.ts +++ b/tests/e2e/smoke/10-dashboard.spec.ts @@ -12,9 +12,10 @@ test.describe('Dashboard', () => { // navigate to the port-scoped dashboard to verify the real content renders. await navigateTo(page, '/'); expect(page.url()).toContain(`/${PORT_SLUG}`); - // Should see the dashboard shell (KPI cards are always rendered at the top) + // Should see the dashboard shell (KPI cards are always rendered at the top). + // Dev-mode JIT compilation can push first-hit render past 10s. await expect(page.getByText(/total clients/i).first()).toBeVisible({ - timeout: 10_000, + timeout: 15_000, }); // Should NOT see the old placeholder text await expect(page.getByText('Coming in Layer')) @@ -25,9 +26,11 @@ test.describe('Dashboard', () => { // Test 2: All 4 KPI cards render test('all 4 KPI cards render without errors', async ({ page }) => { await navigateTo(page, '/'); - await page.waitForTimeout(3_000); - // Look for KPI-related text/elements — the cards should contain numbers or labels + // Wait for the KPI cards to actually render (dev mode JIT can take >3s on + // a cold dashboard hit). The cards expose stable label text we can poll on. + await expect(page.getByText('Total Clients')).toBeVisible({ timeout: 15_000 }); + const cards = page.locator('[class*="card"], [data-testid*="kpi"]'); const cardCount = await cards.count(); expect(cardCount).toBeGreaterThanOrEqual(4); diff --git a/tests/e2e/smoke/19-system-monitoring.spec.ts b/tests/e2e/smoke/19-system-monitoring.spec.ts index 753d3b3..c5331a0 100644 --- a/tests/e2e/smoke/19-system-monitoring.spec.ts +++ b/tests/e2e/smoke/19-system-monitoring.spec.ts @@ -27,7 +27,12 @@ test.describe('System Monitoring', () => { test('all BullMQ queues listed with stats', async ({ page }) => { await login(page, 'super_admin'); await navigateTo(page, '/admin/monitoring'); - await page.waitForTimeout(3_000); + + // Anchor on a queue-only name (not in sidebar) to confirm the panel + // has finished loading before counting matches. + await expect(page.getByText('webhooks', { exact: false }).first()).toBeVisible({ + timeout: 15_000, + }); // Expected queue names from QUEUE_CONFIGS const queueNames = [ @@ -46,7 +51,7 @@ test.describe('System Monitoring', () => { let foundCount = 0; for (const name of queueNames) { const queueCard = page.getByText(name, { exact: false }).first(); - if (await queueCard.isVisible({ timeout: 2_000 }).catch(() => false)) { + if (await queueCard.isVisible().catch(() => false)) { foundCount++; } } diff --git a/tests/e2e/smoke/24-admin-features.spec.ts b/tests/e2e/smoke/24-admin-features.spec.ts index 536967d..f15324b 100644 --- a/tests/e2e/smoke/24-admin-features.spec.ts +++ b/tests/e2e/smoke/24-admin-features.spec.ts @@ -162,7 +162,13 @@ test.describe('Admin Features', () => { test('monitoring dashboard shows queue overview with expected queues', async ({ page }) => { await navigateTo(page, '/admin/monitoring'); - await page.waitForTimeout(3_000); + + // Wait for the queue overview section to render. The queue cards expose + // names that don't appear in the sidebar (e.g. webhooks, maintenance) so + // we anchor on one of those to confirm the panel has loaded. + await expect(page.getByText('webhooks', { exact: false }).first()).toBeVisible({ + timeout: 15_000, + }); // All 10 expected queue names from QUEUE_CONFIGS const expectedQueues = [ @@ -181,7 +187,7 @@ test.describe('Admin Features', () => { let foundCount = 0; for (const queueName of expectedQueues) { const queueEl = page.getByText(queueName, { exact: false }).first(); - const visible = await queueEl.isVisible({ timeout: 2_000 }).catch(() => false); + const visible = await queueEl.isVisible().catch(() => false); if (visible) foundCount++; }