Files
pn-new-crm/tests/e2e/smoke/03-pipeline.spec.ts
Matt Ciaccio 0406778c44 fix(api): kill currentPortId persist race + dedupe admin/ports stampede
The dashboard and residential interest smoke tests were intermittently
failing with the page rendering empty/skeleton state. Root causes:

1. ui-store persisted currentPortId/Slug, but those are URL-derived state.
   After login lands on /<first-port-by-name>/dashboard, localStorage holds
   that port. Hard-navigating to /port-nimara/... rehydrated the store with
   the stale id, and useQuery fired with the wrong port before
   PortProvider's URL-sync useEffect could correct it. Drop both fields
   from partialize — PortProvider re-derives them from the route every
   navigation.

2. apiFetch's slug-to-port fallback fired N parallel /api/v1/admin/ports
   calls when N components mounted simultaneously with an empty store.
   Dedupe in-flight lookups so a stampede collapses into one round-trip.

Also tightened four flaky smoke tests that depended on a fixed 3s wait or
non-waiting isVisible({timeout}) — replaced with expect(...).toBeVisible
or expect.poll so they handle dev-mode JIT cold-start delays cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 04:38:57 +02:00

146 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect } from '@playwright/test';
import { login, navigateTo, waitForSheet } from './helpers';
test.describe('Interest Pipeline', () => {
test.beforeEach(async ({ page }) => {
await login(page, 'super_admin');
});
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 waitForSheet(page);
const clientName = `Pipeline Client ${Date.now()}`;
const sheet = page.locator('[role="dialog"]');
await sheet.locator('input[name="fullName"]').fill(clientName);
await sheet.locator('input[name="contacts.0.value"]').fill('pipeline@test.com');
await sheet.getByRole('button', { name: /create client/i }).click();
await expect(sheet).not.toBeVisible({ timeout: 10_000 });
await page.waitForTimeout(2000);
// Now create an interest
await navigateTo(page, '/interests');
await page.waitForTimeout(2000);
const newBtn = page.getByRole('button', { name: /new interest/i }).first();
await expect(newBtn).toBeVisible({ timeout: 10_000 });
await newBtn.click();
await waitForSheet(page);
const interestSheet = page.locator('[role="dialog"]');
// Click the client combobox trigger button to open the popover
const clientTrigger = interestSheet.getByRole('combobox').first();
await clientTrigger.click();
await page.waitForTimeout(2000);
// Wait for the popover to load initial options (no search needed — they load on mount)
// The options API returns all clients for this port
const cmdItems = page.locator('[cmdk-item]');
await expect(cmdItems.first()).toBeVisible({ timeout: 10_000 });
// Wait for actual client data to load (not just "Loading..." or "No clients found")
let selected = false;
for (let attempt = 0; attempt < 5; attempt++) {
const count = await cmdItems.count();
for (let i = 0; i < count; i++) {
const text = await cmdItems.nth(i).textContent();
if (text && text.includes('Pipeline')) {
await cmdItems.nth(i).click();
selected = true;
break;
}
}
if (selected) break;
await page.waitForTimeout(1000);
}
if (!selected) {
console.log(' ⚠️ No matching client found. Skipping interest creation.');
await page.keyboard.press('Escape');
await page.keyboard.press('Escape');
return;
}
await page.waitForTimeout(500);
await interestSheet.getByRole('button', { name: /create interest/i }).click();
// Wait for the sheet heading to disappear (form submitted successfully)
const sheetHeading = page.getByRole('heading', { name: 'New Interest' });
await expect(sheetHeading).not.toBeVisible({ timeout: 15_000 });
await page.waitForTimeout(2000);
});
test('interests page loads with data', async ({ page }) => {
await navigateTo(page, '/interests');
await page.waitForLoadState('networkidle');
// Should see interests page content
const heading = page.getByText(/interests/i).first();
await expect(heading).toBeVisible({ timeout: 15_000 });
// 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 }) => {
await navigateTo(page, '/interests');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(3000);
// Try clicking a table row to navigate to detail
const rows = page.locator('table tbody tr');
const rowCount = await rows.count();
if (rowCount > 0) {
// Click the first row — it may navigate or open a link
const firstRow = rows.first();
const link = firstRow.locator('a').first();
if (await link.isVisible({ timeout: 3_000 }).catch(() => false)) {
await link.click();
} else {
await firstRow.click();
}
await page.waitForTimeout(3000);
// Check if we navigated to a detail page
const url = page.url();
if (url.includes('/interests/')) {
// We're on the detail page — look for content
const content = page.getByText(/pipeline|stage|notes|activity|client/i);
await expect(content.first()).toBeVisible({ timeout: 10_000 });
} else {
// The table rows don't navigate — this is fine for a smoke test
console.log(' Table rows do not navigate to detail pages');
}
} else {
console.log(' No interest rows in table');
}
});
});