chore(autonomous-session): consolidate uncommitted work from prior session

Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
This commit is contained in:
2026-05-23 00:52:59 +02:00
parent 43719b49e9
commit 221ae5784e
749 changed files with 7440 additions and 3118 deletions

View File

@@ -50,7 +50,7 @@ type Route = {
detailLinkSelector?: string;
/** Extra wait after navigation (ms) for content to settle. */
settleMs?: number;
/** If true, skip useful for known-broken or out-of-scope routes. */
/** If true, skip - useful for known-broken or out-of-scope routes. */
skip?: boolean;
};
@@ -194,7 +194,7 @@ async function ensureAdminExists(request: APIRequestContext) {
failOnStatusCode: false,
});
if (!signUp.ok()) {
// Already exists verify sign-in works
// Already exists - verify sign-in works
const signIn = await request.post('/api/auth/sign-in/email', {
headers,
data: { email: ADMIN.email, password: ADMIN.password },
@@ -249,7 +249,7 @@ async function captureRoute(
status: 'error',
error: msg.split('\n')[0],
});
console.log(`${route.path} ${msg.split('\n')[0]}`);
console.log(`${route.path} - ${msg.split('\n')[0]}`);
return;
}
@@ -296,7 +296,7 @@ async function captureRoute(
status: 'error',
error: msg.split('\n')[0],
});
console.log(`${route.path} detail ${msg.split('\n')[0]}`);
console.log(`${route.path} detail - ${msg.split('\n')[0]}`);
}
}
}
@@ -339,7 +339,7 @@ async function writeIndex(allByViewport: Map<string, Capture[]>): Promise<void>
await fs.writeFile(path.join(OUT_ROOT, 'index.md'), lines.join('\n'), 'utf-8');
}
test('mobile audit every page at iPhone viewports', async ({ browser, request }) => {
test('mobile audit - every page at iPhone viewports', async ({ browser, request }) => {
test.setTimeout(1_800_000);
await fs.mkdir(OUT_ROOT, { recursive: true });

View File

@@ -14,7 +14,7 @@ async function createYachtViaApi(page: import('@playwright/test').Page, name: st
data: {
name,
ownerType: 'client',
// ownerId left blank intentionally UI flow seeds an owner via
// ownerId left blank intentionally - UI flow seeds an owner via
// existing client list in the form. For the API-driven path here we
// need a real client; fetch one if available.
},
@@ -32,12 +32,12 @@ test.describe('destructive: yacht archive', () => {
// Build a unique name so we can find the row deterministically.
const name = `ARCHIVE-ME-${Date.now()}`;
// Create via API. The endpoint may require ownerId if so, skip with a
// Create via API. The endpoint may require ownerId - if so, skip with a
// clear message rather than fail (this test depends on a seed fixture
// that doesn't yet exist in the global setup).
const created = await createYachtViaApi(page, name);
if (!created.ok()) {
test.skip(true, `yacht create returned ${created.status()} fixture not seeded`);
test.skip(true, `yacht create returned ${created.status()} - fixture not seeded`);
return;
}

View File

@@ -8,7 +8,7 @@ test.describe('exhaustive: yachts', () => {
await login(page, 'super_admin');
});
test('list page every visible button/link is clickable without errors', async ({ page }) => {
test('list page - every visible button/link is clickable without errors', async ({ page }) => {
await navigateTo(page, '/yachts');
await page.waitForLoadState('networkidle');
@@ -17,7 +17,7 @@ test.describe('exhaustive: yachts', () => {
expect(result.clicked).toBeGreaterThan(0);
});
test('detail page every tab + button (skipping destructive)', async ({ page }) => {
test('detail page - every tab + button (skipping destructive)', async ({ page }) => {
await navigateTo(page, '/yachts');
await page.waitForLoadState('networkidle');
@@ -31,7 +31,7 @@ test.describe('exhaustive: yachts', () => {
const result = await clickEverythingOnPage(page);
expect(result.errors, JSON.stringify(result.errors, null, 2)).toEqual([]);
} else {
test.skip(true, 'no yachts seeded list is empty');
test.skip(true, 'no yachts seeded - list is empty');
}
});

View File

@@ -16,7 +16,7 @@ test.describe('exhaustive: companies', () => {
expect(result.errors, JSON.stringify(result.errors, null, 2)).toEqual([]);
});
test('detail page every tab and button', async ({ page }) => {
test('detail page - every tab and button', async ({ page }) => {
await navigateTo(page, '/companies');
await page.waitForLoadState('networkidle');

View File

@@ -8,7 +8,7 @@ test.describe('exhaustive: berth reservations', () => {
await login(page, 'super_admin');
});
test('berths list reservation-related affordances are clickable', async ({ page }) => {
test('berths list - reservation-related affordances are clickable', async ({ page }) => {
await navigateTo(page, '/berths');
await page.waitForLoadState('networkidle');
@@ -16,7 +16,7 @@ test.describe('exhaustive: berth reservations', () => {
expect(result.errors, JSON.stringify(result.errors, null, 2)).toEqual([]);
});
test('berth detail reservations tab opens and is interactive', async ({ page }) => {
test('berth detail - reservations tab opens and is interactive', async ({ page }) => {
await navigateTo(page, '/berths');
await page.waitForLoadState('networkidle');

View File

@@ -16,7 +16,7 @@ test.describe('exhaustive: client detail (refactored)', () => {
expect(result.errors, JSON.stringify(result.errors, null, 2)).toEqual([]);
});
test('detail page every tab and button (yachts / memberships / reservations / etc.)', async ({
test('detail page - every tab and button (yachts / memberships / reservations / etc.)', async ({
page,
}) => {
await navigateTo(page, '/clients');
@@ -31,7 +31,7 @@ test.describe('exhaustive: client detail (refactored)', () => {
await page.waitForURL(new RegExp(`/${PORT_SLUG}/clients/[^/]+`), { timeout: 10_000 });
await page.waitForLoadState('networkidle');
// Walk every tab the refactor added yachts/memberships/reservations tabs.
// Walk every tab - the refactor added yachts/memberships/reservations tabs.
const tabs = await page.getByRole('tab').all();
for (const tab of tabs) {
if (await tab.isVisible({ timeout: 250 }).catch(() => false)) {

View File

@@ -16,7 +16,7 @@ test.describe('exhaustive: berths with reservations panel', () => {
expect(result.errors, JSON.stringify(result.errors, null, 2)).toEqual([]);
});
test('detail page every tab including reservations', async ({ page }) => {
test('detail page - every tab including reservations', async ({ page }) => {
await navigateTo(page, '/berths');
await page.waitForLoadState('networkidle');

View File

@@ -18,7 +18,7 @@ test.describe('exhaustive: client portal', () => {
await page.waitForLoadState('networkidle');
// Some portal pages redirect to login if the user has no client linkage.
// Skip rather than fail in that case portal coverage requires its own
// Skip rather than fail in that case - portal coverage requires its own
// seeded portal fixture.
if (page.url().includes('/login')) {
test.skip(true, 'portal page redirected to login (no portal user seeded)');

View File

@@ -19,7 +19,7 @@ import { login, apiHeaders, getPortId } from '../smoke/helpers';
const SOCKET_URL =
process.env.NEXT_PUBLIC_SOCKET_URL ?? process.env.SOCKET_URL ?? 'http://localhost:3000';
test.describe('Alert engine socket fanout', () => {
test.describe('Alert engine - socket fanout', () => {
test.skip(
!process.env.RUN_ALERT_ENGINE_REALAPI,
'RUN_ALERT_ENGINE_REALAPI not set (opt-in; emits real events)',

View File

@@ -31,7 +31,7 @@ test.describe('Documenso cancel pathway', () => {
// Seed a minimal client to ensure a doc can be created. Real cancel
// testing assumes either an existing in-flight doc or the wizard flow
// has already produced one. We probe the hub for an in-flight doc and
// skip if none this lets the spec run as a smoke check rather than
// skip if none - this lets the spec run as a smoke check rather than
// a fixture-dependent integration.
const list = await page.request.get(
'/api/v1/documents?tab=awaiting_them&signatureOnly=true&limit=1',

View File

@@ -6,7 +6,7 @@ import { login, apiHeaders } from '../smoke/helpers';
/**
* Real-API end-to-end test for the Documenso documenso-template pathway.
*
* This spec exercises the SEND side of the integration only it creates a
* This spec exercises the SEND side of the integration only - it creates a
* fully-loaded interest, fires generate-and-sign, and asserts both
* (a) the CRM persisted a documensoId on the documents row, and
* (b) Documenso itself returns 200 for the freshly-created document.

View File

@@ -17,7 +17,7 @@ import { login, apiHeaders } from '../smoke/helpers';
const SMTP_HOST = process.env.SMTP_HOST;
const OTHER_PORT_FILE_ID = process.env.PHASE_A_CROSS_PORT_FILE_ID;
test.describe('Email attachments port isolation', () => {
test.describe('Email attachments - port isolation', () => {
test.skip(!SMTP_HOST || !OTHER_PORT_FILE_ID, 'cross-port fixture not configured');
test.beforeEach(async ({ page }) => {

View File

@@ -49,7 +49,7 @@ test.describe('MinIO file lifecycle', () => {
const listBody = (await list.json()) as { data: Array<{ id: string }> };
expect(listBody.data.find((f) => f.id === fileId)).toBeDefined();
// Download assert byte-equality
// Download - assert byte-equality
const dlRes = await page.request.get(`/api/v1/files/${fileId}/download`, { headers });
expect(dlRes.ok()).toBe(true);
const dlBody = await dlRes.body();

View File

@@ -22,7 +22,7 @@ import { login, apiHeaders } from '../smoke/helpers';
* routes back to the same inbox via standard +addressing.
*
* NOTE: this test bypasses EMAIL_REDIRECT_TO by sending to the IMAP user
* directly. If EMAIL_REDIRECT_TO is set, the redirect still applies but
* directly. If EMAIL_REDIRECT_TO is set, the redirect still applies - but
* the redirect target is also IMAP_USER in our dev setup, so it works out.
*/
@@ -129,7 +129,7 @@ test.describe('Portal activation: SMTP + IMAP round-trip', () => {
expect(inviteRes.ok(), `invite: ${inviteRes.status()} ${await inviteRes.text()}`).toBe(true);
// ─── 3-4. Poll IMAP and extract the activation token ─────────────────────
// Match on the +addressed recipient that's what's preserved in the TO
// Match on the +addressed recipient - that's what's preserved in the TO
// header even after the mailserver delivers it back to IMAP_USER's inbox.
const token = await fetchActivationToken({
recipientEmail: recipientEmail.toLowerCase(),

View File

@@ -9,7 +9,7 @@ import { login, apiHeaders, getPortId } from '../smoke/helpers';
*
* 1. Admin save + test-connection round-trip: writes a real OpenAI key
* to the global OCR config, calls /admin/ocr-settings/test (which
* sends a 1×1 pixel PNG essentially free in tokens), and asserts
* sends a 1×1 pixel PNG - essentially free in tokens), and asserts
* the provider responds 2xx. Validates the auth + key-storage path.
*
* 2. Real receipt parse: when REALAPI_RECEIPT_FIXTURE is set to an
@@ -24,7 +24,7 @@ import { login, apiHeaders, getPortId } from '../smoke/helpers';
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const RECEIPT_FIXTURE = process.env.REALAPI_RECEIPT_FIXTURE;
test.describe('Receipt OCR real provider', () => {
test.describe('Receipt OCR - real provider', () => {
test.skip(!OPENAI_API_KEY, 'OPENAI_API_KEY not configured');
test('admin can save an OpenAI key and the test endpoint passes', async ({ page }) => {
@@ -110,7 +110,7 @@ test.describe('Receipt OCR — real provider', () => {
};
};
expect(body.data.source).toBe('ai');
// Confidence must be a valid number 0..1 provider should always emit it.
// Confidence must be a valid number 0..1 - provider should always emit it.
expect(body.data.parsed.confidence).toBeGreaterThanOrEqual(0);
expect(body.data.parsed.confidence).toBeLessThanOrEqual(1);
// Amount, if present, should be non-negative.

View File

@@ -13,8 +13,8 @@ import { login, apiHeaders } from '../smoke/helpers';
* and verifies the attachment bytes round-trip end-to-end.
*
* Requires:
* SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS outbound transport
* IMAP_HOST / IMAP_PORT / IMAP_USER / IMAP_PASS inbound for verification
* SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS - outbound transport
* IMAP_HOST / IMAP_PORT / IMAP_USER / IMAP_PASS - inbound for verification
*/
const SMTP_HOST = process.env.SMTP_HOST;

View File

@@ -47,14 +47,16 @@ test.describe('Auth & Permissions', () => {
// The PermissionGate hides the button once permissions resolve
await page.waitForTimeout(3000);
// Check if PermissionGate is working viewer has clients.create = false
// Check if PermissionGate is working - viewer has clients.create = false
const newClientBtn = page.getByRole('button', { name: /new client/i });
const isVisible = await newClientBtn.isVisible().catch(() => false);
// If button is visible, this is an application bug PermissionGate not enforcing
// If button is visible, this is an application bug - PermissionGate not enforcing
// We log and soft-fail: the permission IS enforced server-side (API 403)
if (isVisible) {
console.warn('⚠️ APP BUG: New Client button visible to viewer — PermissionGate not enforcing client-side');
console.warn(
'⚠️ APP BUG: New Client button visible to viewer - PermissionGate not enforcing client-side',
);
// Verify server-side enforcement: clicking should fail
await newClientBtn.click();
await page.waitForTimeout(1000);
@@ -66,16 +68,25 @@ test.describe('Auth & Permissions', () => {
await login(page, 'sales_agent');
// Navigate to a URL with a non-existent port slug
// App Router will match [portSlug] dynamically the behavior depends on
// App Router will match [portSlug] dynamically - the behavior depends on
// whether the layout's server component can resolve the port
await page.goto('/non-existent-port/clients');
await page.waitForLoadState('networkidle');
// Should see 404, error, empty state, or redirect
const is404 = await page.locator('text=404').isVisible().catch(() => false);
const isNotFound = await page.getByText(/not found/i).isVisible().catch(() => false);
const is404 = await page
.locator('text=404')
.isVisible()
.catch(() => false);
const isNotFound = await page
.getByText(/not found/i)
.isVisible()
.catch(() => false);
const isLogin = page.url().includes('/login');
const isError = await page.getByText(/error|no port/i).isVisible().catch(() => false);
const isError = await page
.getByText(/error|no port/i)
.isVisible()
.catch(() => false);
// If none of these are true, it means the app loaded without a valid port
// context. This is acceptable as long as it doesn't crash.

View File

@@ -39,7 +39,7 @@ test.describe('Interest Pipeline', () => {
await clientTrigger.click();
await page.waitForTimeout(2000);
// Wait for the popover to load initial options (no search needed they load on mount)
// 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 });
@@ -116,7 +116,7 @@ test.describe('Interest Pipeline', () => {
const rowCount = await rows.count();
if (rowCount > 0) {
// Click the first row it may navigate or open a link
// Click the first row - it may navigate or open a link
const firstRow = rows.first();
const link = firstRow.locator('a').first();
@@ -131,11 +131,11 @@ test.describe('Interest Pipeline', () => {
// 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
// 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
// The table rows don't navigate - this is fine for a smoke test
console.log(' Table rows do not navigate to detail pages');
}
} else {

View File

@@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test';
import { login, navigateTo } from './helpers';
/**
* Smoke spec Documents Hub aggregated view (Wave 11.B rebuild)
* Smoke spec - Documents Hub aggregated view (Wave 11.B rebuild)
*
* Exercises the structural layout of the new DocumentsHub:
* - FolderTreeSidebar with system-root folders (Clients / Companies / Yachts)
@@ -12,11 +12,11 @@ import { login, navigateTo } from './helpers';
* renders the AggregatedSection blocks
*
* Note: This spec intentionally avoids asserting on seeded file/workflow
* counts those vary by seed state. The integration tests (Tasks 8 + 9)
* counts - those vary by seed state. The integration tests (Tasks 8 + 9)
* already verify service correctness; this spec guards UI-level rendering.
*/
test.describe('Documents hub aggregated view', () => {
test.describe('Documents hub - aggregated view', () => {
test.beforeEach(async ({ page }) => {
await login(page, 'super_admin');
});
@@ -64,7 +64,7 @@ test.describe('Documents hub — aggregated view', () => {
await page.waitForLoadState('networkidle');
// Click the Clients system root folder button (the label button inside FolderRow).
// The chevron button and the label button are siblings we target the button
// The chevron button and the label button are siblings - we target the button
// that contains the text "Clients".
const clientsBtn = page
.locator('aside button')
@@ -85,7 +85,7 @@ test.describe('Documents hub — aggregated view', () => {
// Create a fresh client via the API so we have a guaranteed entity with a
// folder. ensureEntityFolder is called by the createClient service hook.
// page.request shares the browser context cookie jar (session cookie from
// login()) the bare `request` fixture is an isolated API context that
// login()) - the bare `request` fixture is an isolated API context that
// does not carry the session cookie and would 401.
const res = await page.request.post('/api/v1/clients', {
data: {
@@ -95,13 +95,13 @@ test.describe('Documents hub — aggregated view', () => {
},
});
// A non-2xx here means smoke setup is broken (port cookie / seed) or the
// clients API regressed. Fail loud rather than skip green a silent skip
// clients API regressed. Fail loud rather than skip green - a silent skip
// masked an infra failure for weeks in the audit window. Playwright doesn't
// expose vitest's `expect.fail`, so we throw a plain Error which the
// runner promotes to a failing test the same way.
if (!res.ok()) {
throw new Error(
`Client create returned ${res.status()} ${await res.text()} entity sub-folder assertion cannot proceed`,
`Client create returned ${res.status()} ${await res.text()} - entity sub-folder assertion cannot proceed`,
);
}
const { data: client } = (await res.json()) as {

View File

@@ -4,11 +4,11 @@ import path from 'path';
import fs from 'fs';
/**
* Smoke spec Upload a file into an entity folder (Wave 11.B)
* Smoke spec - Upload a file into an entity folder (Wave 11.B)
*
* Strategy: Option B (API-fixture approach).
*
* The EntityFolderView's upload button is not wired into the new hub UI
* The EntityFolderView's upload button is not wired into the new hub UI -
* the hub focuses on aggregated read + signing. We therefore drive the
* upload path via the API directly (`page.request`) and then assert that
* the file surfaces in the entity folder view.
@@ -17,7 +17,7 @@ import fs from 'fs';
* entity view after upload) rather than a specific UI affordance.
*/
test.describe('Documents hub upload into entity folder', () => {
test.describe('Documents hub - upload into entity folder', () => {
test.beforeEach(async ({ page }) => {
await login(page, 'super_admin');
});
@@ -27,7 +27,7 @@ test.describe('Documents hub — upload into entity folder', () => {
// 1. Create a client.
// page.request shares the browser context cookie jar (session cookie from
// login()) the bare `request` fixture is an isolated API context that
// login()) - the bare `request` fixture is an isolated API context that
// does not carry the session cookie and would 401.
const clientRes = await page.request.post('/api/v1/clients', {
headers,
@@ -40,7 +40,7 @@ test.describe('Documents hub — upload into entity folder', () => {
// Playwright doesn't expose vitest's `expect.fail`; throw to fail loud.
if (!clientRes.ok()) {
throw new Error(
`Client create returned ${clientRes.status()} ${await clientRes.text()} upload test cannot proceed`,
`Client create returned ${clientRes.status()} ${await clientRes.text()} - upload test cannot proceed`,
);
}
const { data: client } = (await clientRes.json()) as {
@@ -114,10 +114,10 @@ test.describe('Documents hub — upload into entity folder', () => {
test('file uploaded with folderId (entity folder) auto-sets client_id', async ({ page }) => {
const headers = await apiHeaders(page);
// 1. Create a client ensureEntityFolder is triggered server-side when
// 1. Create a client - ensureEntityFolder is triggered server-side when
// the first file is uploaded with clientId or directly by the create hook.
// page.request shares the browser context cookie jar (session cookie from
// login()) the bare `request` fixture is an isolated API context that
// login()) - the bare `request` fixture is an isolated API context that
// does not carry the session cookie and would 401.
const clientRes = await page.request.post('/api/v1/clients', {
headers,
@@ -129,7 +129,7 @@ test.describe('Documents hub — upload into entity folder', () => {
});
if (!clientRes.ok()) {
throw new Error(
`Client create returned ${clientRes.status()} ${await clientRes.text()} folderId test cannot proceed`,
`Client create returned ${clientRes.status()} ${await clientRes.text()} - folderId test cannot proceed`,
);
}
const { data: client } = (await clientRes.json()) as {
@@ -151,11 +151,11 @@ test.describe('Documents hub — upload into entity folder', () => {
clientId: client.id,
},
});
// Seed upload failing means the files API is broken fail loud so the
// Seed upload failing means the files API is broken - fail loud so the
// infra regression surfaces in CI instead of staying green-skipped.
if (!seedUpload.ok()) {
throw new Error(
`Seed upload returned ${seedUpload.status()} ${await seedUpload.text()} folderId test cannot proceed`,
`Seed upload returned ${seedUpload.status()} ${await seedUpload.text()} - folderId test cannot proceed`,
);
}
@@ -168,11 +168,11 @@ test.describe('Documents hub — upload into entity folder', () => {
);
if (!listRes.ok()) {
throw new Error(
`File list returned ${listRes.status()} ${await listRes.text()} folderId test cannot proceed`,
`File list returned ${listRes.status()} ${await listRes.text()} - folderId test cannot proceed`,
);
}
// 4. Navigate and verify folder view shows the client entity sections.
// 4. Navigate and verify - folder view shows the client entity sections.
await navigateTo(page, '/documents');
await page.waitForLoadState('networkidle');

View File

@@ -8,8 +8,14 @@ test.describe('Error Handling', () => {
await page.goto(`/${PORT_SLUG}/this-page-does-not-exist`);
await page.waitForLoadState('networkidle');
const is404 = await page.getByText('404').isVisible().catch(() => false);
const isNotFound = await page.getByText(/not found/i).isVisible().catch(() => false);
const is404 = await page
.getByText('404')
.isVisible()
.catch(() => false);
const isNotFound = await page
.getByText(/not found/i)
.isVisible()
.catch(() => false);
expect(is404 || isNotFound).toBeTruthy();
});
@@ -41,11 +47,23 @@ test.describe('Error Handling', () => {
await page.waitForLoadState('networkidle');
await page.waitForTimeout(3000);
// Should show 404, not found, error, or empty state but NOT crash
const is404 = await page.getByText('404').isVisible().catch(() => false);
const isNotFound = await page.getByText(/not found/i).isVisible().catch(() => false);
const isError = await page.getByText(/error|not exist/i).isVisible().catch(() => false);
const noData = await page.getByText(/no client|loading/i).isVisible().catch(() => false);
// Should show 404, not found, error, or empty state - but NOT crash
const is404 = await page
.getByText('404')
.isVisible()
.catch(() => false);
const isNotFound = await page
.getByText(/not found/i)
.isVisible()
.catch(() => false);
const isError = await page
.getByText(/error|not exist/i)
.isVisible()
.catch(() => false);
const noData = await page
.getByText(/no client|loading/i)
.isVisible()
.catch(() => false);
// At minimum, the page should not be a blank crash
const body = await page.locator('body').textContent();

View File

@@ -85,7 +85,7 @@ test.describe('Global Search', () => {
await searchInput.click();
await page.waitForTimeout(500);
// Recent section may or may not be present storage backed, best-effort
// Recent section may or may not be present - storage backed, best-effort
const recentHeader = page.getByText('Recent', { exact: true });
await recentHeader
.first()

View File

@@ -81,7 +81,7 @@ test.describe('Notifications', () => {
}
}
}
// The notification creation is async via socket we verify the bell in the next test
// The notification creation is async via socket - we verify the bell in the next test
expect(true).toBeTruthy();
});
@@ -145,7 +145,7 @@ test.describe('Notifications', () => {
await firstNotif.click();
await page.waitForTimeout(2_000);
// Go back and check the unread indicator should be gone or reduced
// Go back and check - the unread indicator should be gone or reduced
await page.goBack();
await page.waitForTimeout(1_000);
}

View File

@@ -53,7 +53,7 @@ test.describe('Webhooks', () => {
await saveBtn.click();
await page.waitForTimeout(3_000);
// Secret may be shown in a toast, dialog, or inline non-strict smoke assertion
// Secret may be shown in a toast, dialog, or inline - non-strict smoke assertion
expect(true).toBeTruthy();
}
});

View File

@@ -46,7 +46,7 @@ test.describe('Custom Fields', () => {
}
}
// Fill field name (snake_case) Radix combobox inputs are hidden so use placeholder
// Fill field name (snake_case) - Radix combobox inputs are hidden so use placeholder
const nameInput = dialog.getByPlaceholder(/vessel_type/i).first();
await nameInput.fill('custom_text_test');
@@ -90,7 +90,7 @@ test.describe('Custom Fields', () => {
await clientRow.click();
await page.waitForTimeout(3_000);
// Custom fields section should be present (even if collapsed) non-strict smoke
// Custom fields section should be present (even if collapsed) - non-strict smoke
expect(true).toBeTruthy();
}
});

View File

@@ -55,7 +55,7 @@ test.describe('Client Portal', () => {
// Test 37: Portal cannot access CRM dashboard routes
test('portal auth cannot access CRM routes', async ({ page }) => {
// Navigate to CRM route without CRM auth should redirect to login
// Navigate to CRM route without CRM auth - should redirect to login
await page.goto(`/${PORT_SLUG}/clients`);
await page.waitForTimeout(3_000);
@@ -68,7 +68,7 @@ test.describe('Client Portal', () => {
.isVisible({ timeout: 2_000 })
.catch(() => false);
// If on clients page, it means we still have CRM auth from previous tests that's expected
// If on clients page, it means we still have CRM auth from previous tests - that's expected
// The key point is that portal auth (separate JWT) wouldn't grant CRM access
expect(isOnLogin || isOnClients || hasAuthError).toBeTruthy();
});

View File

@@ -25,7 +25,7 @@ test.describe('AI Features', () => {
await firstInterest.click();
await page.waitForTimeout(3_000);
// Check for score badge should NOT be visible if flag is off
// Check for score badge - should NOT be visible if flag is off
const scoreBadge = page.locator('[data-testid="interest-score"], [class*="score-badge"]');
const hotBadge = page.getByText(/^(Hot|Warm|Cool|Cold)$/).first();
@@ -125,7 +125,7 @@ test.describe('AI Features', () => {
headers: { 'Content-Type': 'application/json' },
});
// Should return 202 with jobId, or 404 if flag is disabled both are valid
// Should return 202 with jobId, or 404 if flag is disabled - both are valid
expect([200, 202, 404].includes(draftRes.status())).toBeTruthy();
if (draftRes.status() === 202) {

View File

@@ -71,7 +71,7 @@ test.describe('System Monitoring', () => {
await page.waitForTimeout(2_000);
// Try to access monitoring via API. 400 (missing port context) is also a valid
// blocking response for non-super_admins the API requires port context for
// blocking response for non-super_admins - the API requires port context for
// regular users, and super_admins bypass it. So 400/401/403 all mean "blocked".
const healthRes = await page.request.get('/api/v1/admin/health');
expect([400, 401, 403].includes(healthRes.status())).toBeTruthy();
@@ -80,7 +80,7 @@ test.describe('System Monitoring', () => {
expect([400, 401, 403].includes(queuesRes.status())).toBeTruthy();
// Try accessing the page directly. API-level (above) is the real boundary.
// UI may navigate to the page, but with APIs returning 403 no queue data renders
// UI may navigate to the page, but with APIs returning 403 no queue data renders -
// which is the observable effect of being "blocked" from data.
await navigateTo(page, '/admin/monitoring');
await page.waitForTimeout(3_000);
@@ -94,7 +94,7 @@ test.describe('System Monitoring', () => {
const wasRedirected = !url.includes('/admin/monitoring');
// Queue cards render queue names when data loads. With 403, no cards render.
// Queue names render as CardTitle elements in the main content area.
// Sidebar also has "Email"/"Documents" nav links scope to <main> to exclude sidebar.
// Sidebar also has "Email"/"Documents" nav links - scope to <main> to exclude sidebar.
const queueCardCount = await page
.locator('main')
.getByText(/^(webhooks|notifications|reports|maintenance|ai|bulk)$/i)

View File

@@ -1,5 +1,5 @@
/**
* Accessibility smoke test runs axe-core against the main authenticated
* Accessibility smoke test - runs axe-core against the main authenticated
* pages and fails CI if a new WCAG 2.1 A/AA serious-or-critical violation
* shows up. Existing violations on third-party / shadcn primitives are
* pruned via the disableRules list, kept small and audited.
@@ -25,13 +25,13 @@ const PAGES = [
// Tightened as the design system addresses each one.
const DISABLED_RULES = [
// Radix's hidden focus traps occasionally produce "tabindex >= 0 should
// not be on a non-interactive element" known shadcn dialog/sheet shape.
// not be on a non-interactive element" - known shadcn dialog/sheet shape.
'tabindex',
// Color-contrast on muted body text design pass pending.
// Color-contrast on muted body text - design pass pending.
'color-contrast',
];
test.describe('accessibility smoke', () => {
test.describe('accessibility - smoke', () => {
test.beforeEach(async ({ page }) => {
await login(page, 'super_admin');
});

View File

@@ -146,7 +146,7 @@ test.describe('Critical Path: Client → Interest → Invoice', () => {
const rowCount = await rows.count();
if (rowCount === 0) {
console.log(' No interests found skipping stage advancement');
console.log(' No interests found - skipping stage advancement');
return;
}

View File

@@ -9,7 +9,7 @@ test.describe('Role-Based UI', () => {
await page.waitForTimeout(3_000);
// Sidebar exposes a Settings link inside the Admin section (visible by
// default for super_admin). Match the link directly earlier OR-fallbacks
// default for super_admin). Match the link directly - earlier OR-fallbacks
// ambiguously matched the section header label too.
const settingsLink = page.getByRole('link', { name: 'Settings', exact: true }).first();
await expect(settingsLink).toBeVisible({ timeout: 10_000 });
@@ -83,7 +83,7 @@ test.describe('Role-Based UI', () => {
.isVisible({ timeout: 3_000 })
.catch(() => false);
const wasRedirected = !isStillOnMonitoring;
// With APIs returning 403 for non-admins, queue cards don't render no data leak.
// With APIs returning 403 for non-admins, queue cards don't render - no data leak.
// Scope to <main> because sidebar has "Email"/"Documents" nav links.
const queueCardCount = await page
.locator('main')
@@ -118,11 +118,11 @@ test.describe('Role-Based UI', () => {
const isDisabled = await newClientBtn.isDisabled().catch(() => false);
if (!isDisabled) {
console.warn(
' ⚠️ APP BUG: New Client button not gated for viewer server-side must enforce',
' ⚠️ APP BUG: New Client button not gated for viewer - server-side must enforce',
);
}
}
// Test passes regardless this validates the UI state (server is authoritative)
// Test passes regardless - this validates the UI state (server is authoritative)
expect(true).toBeTruthy();
});
@@ -138,7 +138,7 @@ test.describe('Role-Based UI', () => {
const rowVisible = await firstRow.isVisible({ timeout: 5_000 }).catch(() => false);
if (!rowVisible) {
console.log(' No client rows found skipping edit button check');
console.log(' No client rows found - skipping edit button check');
return;
}
@@ -153,7 +153,7 @@ test.describe('Role-Based UI', () => {
const url = page.url();
if (!url.includes('/clients/')) {
console.log(' Could not navigate to client detail skipping');
console.log(' Could not navigate to client detail - skipping');
return;
}
@@ -167,7 +167,7 @@ test.describe('Role-Based UI', () => {
const isDisabled = await firstEditBtn.isDisabled().catch(() => false);
if (isVisible && !isDisabled) {
console.warn(' ⚠️ Edit button visible/enabled for viewer PermissionGate should hide it');
console.warn(' ⚠️ Edit button visible/enabled for viewer - PermissionGate should hide it');
}
}

View File

@@ -73,7 +73,7 @@ test.describe('Error Recovery', () => {
await emailInput.fill('recovery@test.com');
}
// Re-submit should succeed or at minimum no longer show fullName error
// Re-submit - should succeed or at minimum no longer show fullName error
if (await submitBtn.isVisible({ timeout: 3_000 }).catch(() => false)) {
await submitBtn.click();
await page.waitForTimeout(2_000);
@@ -94,7 +94,7 @@ test.describe('Error Recovery', () => {
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2_000);
// Find the search input try multiple selectors
// Find the search input - try multiple selectors
const searchInput = page
.locator('input[type="search"]')
.first()
@@ -105,10 +105,12 @@ test.describe('Error Recovery', () => {
const searchVisible = await searchInput.isVisible({ timeout: 5_000 }).catch(() => false);
if (!searchVisible) {
console.log(' Search input not found checking global search');
console.log(' Search input not found - checking global search');
// Try the global search bar (usually a keyboard shortcut or top-bar icon)
const globalSearch = page.locator('[class*="global-search"], [data-testid*="global"]').first()
const globalSearch = page
.locator('[class*="global-search"], [data-testid*="global"]')
.first()
.or(page.getByRole('button', { name: /search/i }).first());
if (await globalSearch.isVisible({ timeout: 3_000 }).catch(() => false)) {
@@ -117,35 +119,47 @@ test.describe('Error Recovery', () => {
}
}
const activeSearch = page.locator('input[type="search"], input[placeholder*="search" i], [role="searchbox"]').first();
const activeSearch = page
.locator('input[type="search"], input[placeholder*="search" i], [role="searchbox"]')
.first();
const isActive = await activeSearch.isVisible({ timeout: 3_000 }).catch(() => false);
if (!isActive) {
console.log(' No accessible search input found skipping search validation');
console.log(' No accessible search input found - skipping search validation');
expect(true).toBeTruthy();
return;
}
// Single character should not crash
// Single character - should not crash
await activeSearch.fill('a');
await page.waitForTimeout(1_500);
const noError1 = await page.getByText(/uncaught error|cannot read|undefined/i).isVisible({ timeout: 1_000 }).catch(() => false);
const noError1 = await page
.getByText(/uncaught error|cannot read|undefined/i)
.isVisible({ timeout: 1_000 })
.catch(() => false);
expect(noError1).toBeFalsy();
// SQL injection payload should not crash or expose DB errors
// SQL injection payload - should not crash or expose DB errors
await activeSearch.fill("'; DROP TABLE clients; --");
await page.waitForTimeout(1_500);
const noSqlError = await page.getByText(/sql|syntax error|pg error|database/i).isVisible({ timeout: 1_000 }).catch(() => false);
const noSqlError = await page
.getByText(/sql|syntax error|pg error|database/i)
.isVisible({ timeout: 1_000 })
.catch(() => false);
expect(noSqlError).toBeFalsy();
// XSS payload should be escaped, not executed
// XSS payload - should be escaped, not executed
await activeSearch.fill('<script>alert("xss")</script>');
await page.waitForTimeout(1_000);
// The payload text itself (escaped) may appear, but no alert dialog
const hasAlertDialog = await page.locator('[role="alertdialog"]').filter({ hasText: 'xss' }).isVisible({ timeout: 1_000 }).catch(() => false);
const hasAlertDialog = await page
.locator('[role="alertdialog"]')
.filter({ hasText: 'xss' })
.isVisible({ timeout: 1_000 })
.catch(() => false);
expect(hasAlertDialog).toBeFalsy();
// Clear the search
@@ -153,7 +167,10 @@ test.describe('Error Recovery', () => {
await page.waitForTimeout(500);
// Page should still be functional
const body = await page.locator('body').textContent().catch(() => '');
const body = await page
.locator('body')
.textContent()
.catch(() => '');
expect(body && body.length > 10).toBeTruthy();
});
@@ -164,12 +181,24 @@ test.describe('Error Recovery', () => {
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2_000);
const is404 = await page.getByText('404').isVisible({ timeout: 3_000 }).catch(() => false);
const isNotFound = await page.getByText(/not found/i).isVisible({ timeout: 3_000 }).catch(() => false);
const isError = await page.getByText(/error/i).isVisible({ timeout: 3_000 }).catch(() => false);
const is404 = await page
.getByText('404')
.isVisible({ timeout: 3_000 })
.catch(() => false);
const isNotFound = await page
.getByText(/not found/i)
.isVisible({ timeout: 3_000 })
.catch(() => false);
const isError = await page
.getByText(/error/i)
.isVisible({ timeout: 3_000 })
.catch(() => false);
// Page should not be a blank crash must render something meaningful
const body = await page.locator('body').textContent().catch(() => '');
// Page should not be a blank crash - must render something meaningful
const body = await page
.locator('body')
.textContent()
.catch(() => '');
const hasContent = body !== null && body.length > 20;
expect(is404 || isNotFound || isError || hasContent).toBeTruthy();
@@ -182,13 +211,19 @@ test.describe('Error Recovery', () => {
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2_000);
const body = await page.locator('body').textContent().catch(() => '');
const body = await page
.locator('body')
.textContent()
.catch(() => '');
// Should render something not crash with empty body
// Should render something - not crash with empty body
expect(body && body.length > 10).toBeTruthy();
// Should not show a raw Next.js error page with stack traces
const hasStackTrace = await page.getByText(/at Object|at Module|stack trace/i).isVisible({ timeout: 1_000 }).catch(() => false);
const hasStackTrace = await page
.getByText(/at Object|at Module|stack trace/i)
.isVisible({ timeout: 1_000 })
.catch(() => false);
expect(hasStackTrace).toBeFalsy();
});
@@ -209,9 +244,15 @@ test.describe('Error Recovery', () => {
// Should be back on a functional page
const returnUrl = page.url();
const body = await page.locator('body').textContent().catch(() => '');
const body = await page
.locator('body')
.textContent()
.catch(() => '');
expect(body && body.length > 10).toBeTruthy();
// URL should differ from the 404 page (we went back)
expect(returnUrl !== `${page.url().split('//')[0]}//${page.url().split('//')[1]?.split('/')[0]}/${PORT_SLUG}/this-does-not-exist`).toBeTruthy();
expect(
returnUrl !==
`${page.url().split('//')[0]}//${page.url().split('//')[1]?.split('/')[0]}/${PORT_SLUG}/this-does-not-exist`,
).toBeTruthy();
});
});

View File

@@ -12,7 +12,7 @@ test.describe('Portal Flow', () => {
await expect(crmEmailInput).toBeVisible({ timeout: 5_000 });
await expect(crmPasswordInput).toBeVisible({ timeout: 5_000 });
// Navigate to portal login should be a different page
// Navigate to portal login - should be a different page
await page.goto('/portal/login');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1_000);
@@ -27,8 +27,10 @@ test.describe('Portal Flow', () => {
const hasPortalHeading = await portalHeading.isVisible({ timeout: 5_000 }).catch(() => false);
// Look for an email-only input (magic link no password field)
const portalEmailInput = page.locator('input[type="email"], input[placeholder*="email" i], #email').first();
// Look for an email-only input (magic link - no password field)
const portalEmailInput = page
.locator('input[type="email"], input[placeholder*="email" i], #email')
.first();
const portalPasswordInput = page.locator('input[type="password"]').first();
const hasEmail = await portalEmailInput.isVisible({ timeout: 5_000 }).catch(() => false);
@@ -39,7 +41,7 @@ test.describe('Portal Flow', () => {
// Portal should NOT require a password (magic link flow)
if (hasEmail && hasPassword) {
console.warn(' ⚠️ Portal login shows password field expected email-only magic link flow');
console.warn(' ⚠️ Portal login shows password field - expected email-only magic link flow');
}
});
@@ -57,11 +59,13 @@ test.describe('Portal Flow', () => {
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1_000);
const emailInput = page.locator('input[type="email"], input[placeholder*="email" i], #email').first();
const emailInput = page
.locator('input[type="email"], input[placeholder*="email" i], #email')
.first();
const inputVisible = await emailInput.isVisible({ timeout: 5_000 }).catch(() => false);
if (!inputVisible) {
console.log(' Portal login email input not found page may not be implemented yet');
console.log(' Portal login email input not found - page may not be implemented yet');
expect(true).toBeTruthy();
return;
}
@@ -112,7 +116,7 @@ test.describe('Portal Flow', () => {
test('portal document download endpoint requires auth', async ({ page }) => {
const response = await page.request.get('/api/portal/documents/00000000-fake-id/download');
// Must be 401 (not 500 endpoint exists and guards correctly)
// Must be 401 (not 500 - endpoint exists and guards correctly)
expect(response.status()).toBe(401);
});

View File

@@ -13,7 +13,7 @@ import { test, expect } from '@playwright/test';
// ─────────────────────────────────────────────────────────────────────────────
test.describe('API Security unauthenticated access', () => {
test.describe('API Security - unauthenticated access', () => {
test('GET /api/v1/clients returns 401 or 403 without a session', async ({ page }) => {
const response = await page.request.get('/api/v1/clients');
expect([401, 403]).toContain(response.status());
@@ -29,7 +29,9 @@ test.describe('API Security — unauthenticated access', () => {
expect([401, 403]).toContain(response.status());
});
test('GET /api/v1/notifications/unread-count returns 401 or 403 without a session', async ({ page }) => {
test('GET /api/v1/notifications/unread-count returns 401 or 403 without a session', async ({
page,
}) => {
const response = await page.request.get('/api/v1/notifications/unread-count');
expect([401, 403]).toContain(response.status());
});
@@ -55,10 +57,10 @@ test.describe('API Security — unauthenticated access', () => {
// ─────────────────────────────────────────────────────────────────────────────
test.describe('API Security error response sanitization', () => {
test.describe('API Security - error response sanitization', () => {
test('404 on a non-existent API route does not contain stack traces', async ({ page }) => {
const response = await page.request.get('/api/v1/nonexistent-endpoint-xyzzy');
// Accept any non-200 status we just care about the body content
// Accept any non-200 status - we just care about the body content
const body = await response.json().catch(() => ({ error: response.statusText() }));
const bodyStr = JSON.stringify(body);
@@ -70,7 +72,9 @@ test.describe('API Security — error response sanitization', () => {
expect(bodyStr).not.toContain('/app/src');
});
test('unauthenticated response body follows { error } shape, no internal details', async ({ page }) => {
test('unauthenticated response body follows { error } shape, no internal details', async ({
page,
}) => {
const response = await page.request.get('/api/v1/clients');
const body = await response.json().catch(() => null);
if (body) {
@@ -87,14 +91,16 @@ test.describe('API Security — error response sanitization', () => {
}
});
test('malformed JSON body to POST endpoint returns 400/422 without stack trace', async ({ page }) => {
// Send invalid JSON as body — should trigger a validation or parse error
test('malformed JSON body to POST endpoint returns 400/422 without stack trace', async ({
page,
}) => {
// Send invalid JSON as body - should trigger a validation or parse error
const response = await page.request.post('/api/v1/clients', {
headers: { 'Content-Type': 'application/json' },
data: '{ invalid json }',
});
// Must be a client error (4xx), not a 500 stack dump
// (401/403 is also acceptable auth check happens before parse)
// (401/403 is also acceptable - auth check happens before parse)
expect(response.status()).toBeLessThan(600);
const body = await response.json().catch(() => null);
if (body) {
@@ -107,7 +113,7 @@ test.describe('API Security — error response sanitization', () => {
// ─────────────────────────────────────────────────────────────────────────────
test.describe('API Security portal / CRM auth separation', () => {
test.describe('API Security - portal / CRM auth separation', () => {
test('portal dashboard endpoint returns 401 without portal JWT', async ({ page }) => {
// The portal uses a separate JWT auth flow, not the CRM session cookie.
// Even if called with no credentials, it must reject with 401.
@@ -117,12 +123,14 @@ test.describe('API Security — portal / CRM auth separation', () => {
test('CRM login credentials cannot be used to access portal endpoints', async ({ page }) => {
// Attempt to authenticate as a CRM user via Better Auth
const loginRes = await page.request.post('/api/auth/sign-in/email', {
data: {
email: 'admin@portnimara.test',
password: 'SuperAdmin12345!',
},
}).catch(() => null);
const loginRes = await page.request
.post('/api/auth/sign-in/email', {
data: {
email: 'admin@portnimara.test',
password: 'SuperAdmin12345!',
},
})
.catch(() => null);
// Whether or not login succeeded, portal endpoints should be inaccessible
// via the CRM session (portal uses a separate JWT issued by /api/portal/auth)
@@ -138,19 +146,21 @@ test.describe('API Security — portal / CRM auth separation', () => {
// ─────────────────────────────────────────────────────────────────────────────
test.describe('API Security response headers', () => {
test('API responses do not expose internal server technology via X-Powered-By', async ({ page }) => {
test.describe('API Security - response headers', () => {
test('API responses do not expose internal server technology via X-Powered-By', async ({
page,
}) => {
const response = await page.request.get('/api/v1/clients');
// Next.js sets X-Powered-By by default should be removed in production config.
// Next.js sets X-Powered-By by default - should be removed in production config.
// This test documents the expectation; it warns if the header is present.
const poweredBy = response.headers()['x-powered-by'];
if (poweredBy) {
console.warn(
`⚠️ SECURITY: X-Powered-By header exposed: "${poweredBy}". ` +
'Set headers: { "X-Powered-By": "" } in next.config.ts to suppress.',
'Set headers: { "X-Powered-By": "" } in next.config.ts to suppress.',
);
}
// Not a hard fail but the header should not be present in production
// Not a hard fail - but the header should not be present in production
// expect(poweredBy).toBeUndefined();
});

View File

@@ -15,7 +15,7 @@ test.describe('Residential', () => {
await expect(page.getByRole('heading', { name: /residential clients/i })).toBeVisible({
timeout: 10_000,
});
// Either table or empty state should render both ok.
// Either table or empty state should render - both ok.
const hasTableOrEmpty = await Promise.race([
page
.locator('table')
@@ -134,7 +134,7 @@ test.describe('Residential', () => {
expect(interestRes.status()).toBe(201);
const interestId = (await interestRes.json()).data?.id;
// Navigate to the detail route the page should render the standard
// Navigate to the detail route - the page should render the standard
// residential-interest detail layout (eyebrow + client name h1 + sections).
await navigateTo(page, `/residential/interests/${interestId}`);
await expect(page.getByText('Residential interest', { exact: true }).first()).toBeVisible({
@@ -146,7 +146,7 @@ test.describe('Residential', () => {
});
test('public residential inquiry endpoint accepts a submission', async ({ page }) => {
// Direct API call to the public endpoint confirms wiring without UI.
// Direct API call to the public endpoint - confirms wiring without UI.
// Public endpoint takes portId via query string (no auth, but the helper
// resolves the port ID for us via the admin API).
const portId = await getPortId(page);

View File

@@ -1,7 +1,7 @@
import { test, expect } from '@playwright/test';
import { login, PORT_SLUG } from './helpers';
test.describe('Berth detail Interests tab (Phase B)', () => {
test.describe('Berth detail - Interests tab (Phase B)', () => {
test.beforeEach(async ({ page }) => {
await login(page, 'super_admin');
});
@@ -15,7 +15,7 @@ test.describe('Berth detail — Interests tab (Phase B)', () => {
await expect(firstRow).toBeVisible({ timeout: 20_000 });
// The list table uses an `onRowClick` handler on the <tr> that calls
// `router.push`. Open the row's actions menu and click "View details"
// the menu item's handler routes the same way and is more reliable
// - the menu item's handler routes the same way and is more reliable
// than firing a synthetic <tr> click under React 19 + dev-mode HMR.
await firstRow.getByRole('button', { name: /open menu/i }).click();
await page.getByRole('menuitem', { name: /view details/i }).click();

View File

@@ -1,5 +1,5 @@
/**
* i18n PR11 combobox surfaces.
* i18n PR11 - combobox surfaces.
*
* Proves the new country / timezone / phone / subdivision combobox triggers
* actually render in the create sheets we wired in PR68. Doesn't exercise
@@ -36,7 +36,7 @@ test.describe('i18n combobox wiring', () => {
test('new client form swaps nationality input for CountryCombobox', async ({ page }) => {
await navigateTo(page, '/clients');
// Sheet trigger label varies by tenant stick to the topbar action.
// Sheet trigger label varies by tenant - stick to the topbar action.
await page
.locator('main')
.getByRole('button', { name: /^new client$/i })

View File

@@ -1,5 +1,5 @@
/**
* Global Setup Seed the database with test users via Better Auth API
* Global Setup - Seed the database with test users via Better Auth API
* and insert supporting data (berths, system_settings) via direct SQL.
*
* This runs BEFORE any test spec via Playwright's `dependencies` config.
@@ -48,7 +48,7 @@ async function signUpUser(email: string, password: string, name: string) {
return data.user?.id ?? data.id;
}
// User may already exist try sign-in instead
// User may already exist - try sign-in instead
const loginRes = await fetch(`${BASE}/api/auth/sign-in/email`, {
method: 'POST',
headers,

View File

@@ -12,7 +12,7 @@ import { login, navigateTo } from '../smoke/helpers';
* tests/e2e/visual/snapshots.spec.ts-snapshots/.
*
* Pages chosen are list/landing screens that don't depend on per-row
* fixture data they tolerate seed drift between runs. Detail screens
* fixture data - they tolerate seed drift between runs. Detail screens
* (yacht detail, EOI dialog, invoice form review) are intentionally
* deferred until we have stable fixtures wired up.
*/
@@ -67,7 +67,7 @@ test.describe('Visual regression', () => {
}
/**
* Hub entity-folder visual click into the Clients system root, then into
* Hub entity-folder visual - click into the Clients system root, then into
* the first visible entity sub-folder. This is a best-effort baseline:
* if no entity sub-folders exist yet the test skips with a clear message
* rather than failing.
@@ -81,7 +81,7 @@ test.describe('Visual regression', () => {
const expandBtn = page.locator('aside').getByRole('button', { name: 'Expand' }).first();
const hasExpand = await expandBtn.isVisible({ timeout: 5_000 }).catch(() => false);
if (!hasExpand) {
test.skip(true, 'No expandable folder found in sidebar hub-entity-folder baseline skipped');
test.skip(true, 'No expandable folder found in sidebar - hub-entity-folder baseline skipped');
return;
}
await expandBtn.click();
@@ -108,7 +108,7 @@ test.describe('Visual regression', () => {
if (!entityFolderBtn) {
test.skip(
true,
'No entity sub-folder visible after expanding hub-entity-folder baseline skipped',
'No entity sub-folder visible after expanding - hub-entity-folder baseline skipped',
);
return;
}