From 4c67b9dbd4450c5fb8dfbe4c76494652bc4a9d6d Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Sun, 26 Apr 2026 14:06:10 +0200 Subject: [PATCH] test(e2e): exhaustive click-through suite + destructive narrow tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 14: adds a tier-3.5 Playwright pass that opens every refactored page, clicks every visible button/link/role=button, and asserts no console errors, no app-side network 4xx/5xx, and no click-time exceptions. Helper: - tests/helpers/click-everything.ts — shared `clickEverythingOnPage` with default skips for destructive selectors (archive, delete, transfer, sign-out), auto-closing of dialogs, and return-to-start after navigation. Exhaustive specs (tests/e2e/exhaustive/): - 01-yachts: list + detail + transfer dialog - 02-companies: list + detail + add-membership dialog - 03-reservations: berth list + detail reservations tab + reserve dialog - 04-client-detail: list + detail walking every tab - 05-eoi-generate: generate dialog opens with Documenso option - 06-invoice-form: new-invoice dialog billing-entity toggle - 07-berths: list + detail walking every tab - 08-portal: client portal yachts / memberships / reservations - 09-navigation: every primary nav target loads cleanly Destructive specs (tests/e2e/destructive/): - 01-yacht-archive: create-via-API → archive via UI → assert removed. Skips with a clear message when the global setup does not seed an owner client (avoids brittle failures while the full destructive fixture lands). Playwright config: testDir hoisted to ./tests/e2e; new `exhaustive` and `destructive` projects share the existing setup project. New scripts test:e2e / test:e2e:smoke / test:e2e:exhaustive / test:e2e:destructive in package.json drive each project independently. CI integration deferred — no .github/workflows/* exists in this repo yet, so the PR 14 task to wire a separate CI job is N/A. The new projects will pick up automatically when a workflow lands. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 4 + playwright.config.ts | 24 ++- .../e2e/destructive/01-yacht-archive.spec.ts | 76 ++++++++++ tests/e2e/exhaustive/01-yachts.spec.ts | 60 ++++++++ tests/e2e/exhaustive/02-companies.spec.ts | 59 ++++++++ tests/e2e/exhaustive/03-reservations.spec.ts | 63 ++++++++ tests/e2e/exhaustive/04-client-detail.spec.ts | 46 ++++++ tests/e2e/exhaustive/05-eoi-generate.spec.ts | 54 +++++++ tests/e2e/exhaustive/06-invoice-form.spec.ts | 36 +++++ tests/e2e/exhaustive/07-berths.spec.ts | 43 ++++++ tests/e2e/exhaustive/08-portal.spec.ts | 32 ++++ tests/e2e/exhaustive/09-navigation.spec.ts | 41 +++++ tests/helpers/click-everything.ts | 143 ++++++++++++++++++ 13 files changed, 678 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/destructive/01-yacht-archive.spec.ts create mode 100644 tests/e2e/exhaustive/01-yachts.spec.ts create mode 100644 tests/e2e/exhaustive/02-companies.spec.ts create mode 100644 tests/e2e/exhaustive/03-reservations.spec.ts create mode 100644 tests/e2e/exhaustive/04-client-detail.spec.ts create mode 100644 tests/e2e/exhaustive/05-eoi-generate.spec.ts create mode 100644 tests/e2e/exhaustive/06-invoice-form.spec.ts create mode 100644 tests/e2e/exhaustive/07-berths.spec.ts create mode 100644 tests/e2e/exhaustive/08-portal.spec.ts create mode 100644 tests/e2e/exhaustive/09-navigation.spec.ts create mode 100644 tests/helpers/click-everything.ts diff --git a/package.json b/package.json index 57749c7..da385b6 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,10 @@ "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio", "db:seed": "tsx src/lib/db/seed.ts", + "test:e2e": "playwright test", + "test:e2e:smoke": "playwright test --project=smoke", + "test:e2e:exhaustive": "playwright test --project=exhaustive", + "test:e2e:destructive": "playwright test --project=destructive", "prepare": "husky" }, "dependencies": { diff --git a/playwright.config.ts b/playwright.config.ts index 0ff64c2..e5fd3a9 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,7 +1,7 @@ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ - testDir: './tests/e2e/smoke', + testDir: './tests/e2e', fullyParallel: false, forbidOnly: !!process.env.CI, retries: 0, @@ -22,11 +22,29 @@ export default defineConfig({ projects: [ { name: 'setup', - testMatch: /global-setup\.ts/, + testMatch: /smoke\/global-setup\.ts/, }, { name: 'smoke', - testMatch: /\d{2}-.*\.spec\.ts/, + testMatch: /smoke\/\d{2}-.*\.spec\.ts/, + dependencies: ['setup'], + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1440, height: 900 }, + }, + }, + { + name: 'exhaustive', + testMatch: /exhaustive\/.*\.spec\.ts/, + dependencies: ['setup'], + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1440, height: 900 }, + }, + }, + { + name: 'destructive', + testMatch: /destructive\/.*\.spec\.ts/, dependencies: ['setup'], use: { ...devices['Desktop Chrome'], diff --git a/tests/e2e/destructive/01-yacht-archive.spec.ts b/tests/e2e/destructive/01-yacht-archive.spec.ts new file mode 100644 index 0000000..b5b8437 --- /dev/null +++ b/tests/e2e/destructive/01-yacht-archive.spec.ts @@ -0,0 +1,76 @@ +import { test, expect } from '@playwright/test'; + +import { login, navigateTo, PORT_SLUG } from '../smoke/helpers'; + +/** + * Destructive tests run against throwaway entities created via API. They + * exercise the archive / delete / cancel flows that the exhaustive suite + * intentionally skips, and assert the end state in the DB-backed UI. + */ + +async function createYachtViaApi(page: import('@playwright/test').Page, name: string) { + // Cookies from `login()` carry the better-auth session; the API trusts them. + const res = await page.request.post('/api/v1/yachts', { + data: { + name, + ownerType: 'client', + // 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. + }, + failOnStatusCode: false, + }); + return res; +} + +test.describe('destructive: yacht archive', () => { + test.beforeEach(async ({ page }) => { + await login(page, 'super_admin'); + }); + + test('archiving a yacht removes it from the active list', async ({ page }) => { + // 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 + // 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`); + return; + } + + await navigateTo(page, '/yachts'); + await page.waitForLoadState('networkidle'); + + const row = page.locator(`tbody tr:has-text("${name}")`).first(); + if (!(await row.isVisible({ timeout: 5000 }).catch(() => false))) { + test.skip(true, 'created yacht not visible in list (search/pagination?)'); + return; + } + + await row.click(); + await page.waitForURL(new RegExp(`/${PORT_SLUG}/yachts/[^/]+`), { timeout: 10_000 }); + + const archive = page.getByRole('button', { name: /archive/i }).first(); + if (!(await archive.isVisible({ timeout: 3000 }).catch(() => false))) { + test.skip(true, 'archive button not present'); + return; + } + await archive.click(); + + // Confirmation dialog. + const confirm = page + .getByRole('dialog') + .getByRole('button', { name: /archive|confirm/i }) + .first(); + if (await confirm.isVisible({ timeout: 3000 }).catch(() => false)) { + await confirm.click(); + } + + await navigateTo(page, '/yachts'); + await page.waitForLoadState('networkidle'); + await expect(page.locator(`tbody tr:has-text("${name}")`)).toHaveCount(0); + }); +}); diff --git a/tests/e2e/exhaustive/01-yachts.spec.ts b/tests/e2e/exhaustive/01-yachts.spec.ts new file mode 100644 index 0000000..8fde790 --- /dev/null +++ b/tests/e2e/exhaustive/01-yachts.spec.ts @@ -0,0 +1,60 @@ +import { test, expect } from '@playwright/test'; + +import { clickEverythingOnPage } from '../../helpers/click-everything'; +import { login, navigateTo, PORT_SLUG } from '../smoke/helpers'; + +test.describe('exhaustive: yachts', () => { + test.beforeEach(async ({ page }) => { + await login(page, 'super_admin'); + }); + + test('list page — every visible button/link is clickable without errors', async ({ page }) => { + await navigateTo(page, '/yachts'); + await page.waitForLoadState('networkidle'); + + const result = await clickEverythingOnPage(page); + expect(result.errors, JSON.stringify(result.errors, null, 2)).toEqual([]); + expect(result.clicked).toBeGreaterThan(0); + }); + + test('detail page — every tab + button (skipping destructive)', async ({ page }) => { + await navigateTo(page, '/yachts'); + await page.waitForLoadState('networkidle'); + + // Click into the first yacht row. + const firstRow = page.locator('tbody tr a, tbody tr button').first(); + if (await firstRow.isVisible({ timeout: 3000 }).catch(() => false)) { + await firstRow.click(); + await page.waitForURL(new RegExp(`/${PORT_SLUG}/yachts/[^/]+`), { timeout: 10_000 }); + await page.waitForLoadState('networkidle'); + + 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('transfer-ownership dialog opens and closes cleanly', async ({ page }) => { + await navigateTo(page, '/yachts'); + await page.waitForLoadState('networkidle'); + + const firstRow = page.locator('tbody tr a, tbody tr button').first(); + if (!(await firstRow.isVisible({ timeout: 3000 }).catch(() => false))) { + test.skip(true, 'no yachts seeded'); + return; + } + await firstRow.click(); + await page.waitForLoadState('networkidle'); + + const transferBtn = page.getByRole('button', { name: /transfer/i }).first(); + if (await transferBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await transferBtn.click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + const cancel = dialog.getByRole('button', { name: /cancel|close/i }).first(); + await cancel.click(); + await expect(dialog).toBeHidden({ timeout: 5000 }); + } + }); +}); diff --git a/tests/e2e/exhaustive/02-companies.spec.ts b/tests/e2e/exhaustive/02-companies.spec.ts new file mode 100644 index 0000000..67c9488 --- /dev/null +++ b/tests/e2e/exhaustive/02-companies.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from '@playwright/test'; + +import { clickEverythingOnPage } from '../../helpers/click-everything'; +import { login, navigateTo, PORT_SLUG } from '../smoke/helpers'; + +test.describe('exhaustive: companies', () => { + test.beforeEach(async ({ page }) => { + await login(page, 'super_admin'); + }); + + test('list page', async ({ page }) => { + await navigateTo(page, '/companies'); + await page.waitForLoadState('networkidle'); + + const result = await clickEverythingOnPage(page); + expect(result.errors, JSON.stringify(result.errors, null, 2)).toEqual([]); + }); + + test('detail page — every tab and button', async ({ page }) => { + await navigateTo(page, '/companies'); + await page.waitForLoadState('networkidle'); + + const firstRow = page.locator('tbody tr a, tbody tr button').first(); + if (!(await firstRow.isVisible({ timeout: 3000 }).catch(() => false))) { + test.skip(true, 'no companies seeded'); + return; + } + await firstRow.click(); + await page.waitForURL(new RegExp(`/${PORT_SLUG}/companies/[^/]+`), { timeout: 10_000 }); + await page.waitForLoadState('networkidle'); + + const result = await clickEverythingOnPage(page); + expect(result.errors, JSON.stringify(result.errors, null, 2)).toEqual([]); + }); + + test('add-membership dialog opens and closes', async ({ page }) => { + await navigateTo(page, '/companies'); + await page.waitForLoadState('networkidle'); + const firstRow = page.locator('tbody tr a, tbody tr button').first(); + if (!(await firstRow.isVisible({ timeout: 3000 }).catch(() => false))) { + test.skip(true, 'no companies seeded'); + return; + } + await firstRow.click(); + await page.waitForLoadState('networkidle'); + + const addMember = page + .getByRole('button', { name: /add member|add membership|link client/i }) + .first(); + if (await addMember.isVisible({ timeout: 3000 }).catch(() => false)) { + await addMember.click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + const cancel = dialog.getByRole('button', { name: /cancel|close/i }).first(); + await cancel.click(); + await expect(dialog).toBeHidden({ timeout: 5000 }); + } + }); +}); diff --git a/tests/e2e/exhaustive/03-reservations.spec.ts b/tests/e2e/exhaustive/03-reservations.spec.ts new file mode 100644 index 0000000..aa2221f --- /dev/null +++ b/tests/e2e/exhaustive/03-reservations.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from '@playwright/test'; + +import { clickEverythingOnPage } from '../../helpers/click-everything'; +import { login, navigateTo, PORT_SLUG } from '../smoke/helpers'; + +test.describe('exhaustive: berth reservations', () => { + test.beforeEach(async ({ page }) => { + await login(page, 'super_admin'); + }); + + test('berths list — reservation-related affordances are clickable', async ({ page }) => { + await navigateTo(page, '/berths'); + await page.waitForLoadState('networkidle'); + + const result = await clickEverythingOnPage(page); + expect(result.errors, JSON.stringify(result.errors, null, 2)).toEqual([]); + }); + + test('berth detail — reservations tab opens and is interactive', async ({ page }) => { + await navigateTo(page, '/berths'); + await page.waitForLoadState('networkidle'); + + const firstRow = page.locator('tbody tr a, tbody tr button').first(); + if (!(await firstRow.isVisible({ timeout: 3000 }).catch(() => false))) { + test.skip(true, 'no berths seeded'); + return; + } + await firstRow.click(); + await page.waitForURL(new RegExp(`/${PORT_SLUG}/berths/[^/]+`), { timeout: 10_000 }); + await page.waitForLoadState('networkidle'); + + const reservationsTab = page.getByRole('tab', { name: /reservations/i }).first(); + if (await reservationsTab.isVisible({ timeout: 3000 }).catch(() => false)) { + await reservationsTab.click(); + await page.waitForLoadState('networkidle'); + } + + const result = await clickEverythingOnPage(page); + expect(result.errors, JSON.stringify(result.errors, null, 2)).toEqual([]); + }); + + test('reserve dialog opens and closes', async ({ page }) => { + await navigateTo(page, '/berths'); + await page.waitForLoadState('networkidle'); + const firstRow = page.locator('tbody tr a, tbody tr button').first(); + if (!(await firstRow.isVisible({ timeout: 3000 }).catch(() => false))) { + test.skip(true, 'no berths seeded'); + return; + } + await firstRow.click(); + await page.waitForLoadState('networkidle'); + + const reserveBtn = page.getByRole('button', { name: /reserve|new reservation/i }).first(); + if (await reserveBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await reserveBtn.click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + const cancel = dialog.getByRole('button', { name: /cancel|close/i }).first(); + await cancel.click(); + await expect(dialog).toBeHidden({ timeout: 5000 }); + } + }); +}); diff --git a/tests/e2e/exhaustive/04-client-detail.spec.ts b/tests/e2e/exhaustive/04-client-detail.spec.ts new file mode 100644 index 0000000..0a26922 --- /dev/null +++ b/tests/e2e/exhaustive/04-client-detail.spec.ts @@ -0,0 +1,46 @@ +import { test, expect } from '@playwright/test'; + +import { clickEverythingOnPage } from '../../helpers/click-everything'; +import { login, navigateTo, PORT_SLUG } from '../smoke/helpers'; + +test.describe('exhaustive: client detail (refactored)', () => { + test.beforeEach(async ({ page }) => { + await login(page, 'super_admin'); + }); + + test('list page', async ({ page }) => { + await navigateTo(page, '/clients'); + await page.waitForLoadState('networkidle'); + + const result = await clickEverythingOnPage(page); + expect(result.errors, JSON.stringify(result.errors, null, 2)).toEqual([]); + }); + + test('detail page — every tab and button (yachts / memberships / reservations / etc.)', async ({ + page, + }) => { + await navigateTo(page, '/clients'); + await page.waitForLoadState('networkidle'); + + const firstRow = page.locator('tbody tr a, tbody tr button').first(); + if (!(await firstRow.isVisible({ timeout: 3000 }).catch(() => false))) { + test.skip(true, 'no clients seeded'); + return; + } + await firstRow.click(); + 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. + const tabs = await page.getByRole('tab').all(); + for (const tab of tabs) { + if (await tab.isVisible({ timeout: 250 }).catch(() => false)) { + await tab.click(); + await page.waitForLoadState('networkidle'); + } + } + + const result = await clickEverythingOnPage(page); + expect(result.errors, JSON.stringify(result.errors, null, 2)).toEqual([]); + }); +}); diff --git a/tests/e2e/exhaustive/05-eoi-generate.spec.ts b/tests/e2e/exhaustive/05-eoi-generate.spec.ts new file mode 100644 index 0000000..289919c --- /dev/null +++ b/tests/e2e/exhaustive/05-eoi-generate.spec.ts @@ -0,0 +1,54 @@ +import { test, expect } from '@playwright/test'; + +import { login, navigateTo, PORT_SLUG } from '../smoke/helpers'; + +test.describe('exhaustive: EOI generate dialog', () => { + test.beforeEach(async ({ page }) => { + await login(page, 'super_admin'); + }); + + test('dialog opens with Documenso option pre-selected', async ({ page }) => { + await navigateTo(page, '/interests'); + await page.waitForLoadState('networkidle'); + + const firstRow = page.locator('tbody tr a, tbody tr button').first(); + if (!(await firstRow.isVisible({ timeout: 3000 }).catch(() => false))) { + test.skip(true, 'no interests seeded'); + return; + } + await firstRow.click(); + await page.waitForURL(new RegExp(`/${PORT_SLUG}/interests/[^/]+`), { timeout: 10_000 }); + await page.waitForLoadState('networkidle'); + + const docsTab = page.getByRole('tab', { name: /documents/i }).first(); + if (await docsTab.isVisible({ timeout: 3000 }).catch(() => false)) { + await docsTab.click(); + await page.waitForLoadState('networkidle'); + } + + const generateBtn = page.getByRole('button', { name: /generate eoi/i }).first(); + if (!(await generateBtn.isVisible({ timeout: 3000 }).catch(() => false))) { + test.skip(true, 'Generate EOI button not present on this interest'); + return; + } + await generateBtn.click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // Template dropdown shows the Documenso option. + const trigger = dialog.locator('[role="combobox"], button[id="eoi-template"]').first(); + if (await trigger.isVisible({ timeout: 3000 }).catch(() => false)) { + await trigger.click(); + await expect(page.getByRole('option', { name: /documenso/i })).toBeVisible({ + timeout: 3000, + }); + // Close the listbox without changing selection. + await page.keyboard.press('Escape'); + } + + const cancel = dialog.getByRole('button', { name: /cancel|close/i }).first(); + await cancel.click(); + await expect(dialog).toBeHidden({ timeout: 5000 }); + }); +}); diff --git a/tests/e2e/exhaustive/06-invoice-form.spec.ts b/tests/e2e/exhaustive/06-invoice-form.spec.ts new file mode 100644 index 0000000..8145ac5 --- /dev/null +++ b/tests/e2e/exhaustive/06-invoice-form.spec.ts @@ -0,0 +1,36 @@ +import { test, expect } from '@playwright/test'; + +import { login, navigateTo } from '../smoke/helpers'; + +test.describe('exhaustive: invoice form (billing-entity picker)', () => { + test.beforeEach(async ({ page }) => { + await login(page, 'super_admin'); + }); + + test('new-invoice dialog has client/company toggle in billing-entity field', async ({ page }) => { + await navigateTo(page, '/invoices'); + await page.waitForLoadState('networkidle'); + + const newBtn = page.getByRole('button', { name: /new invoice|create invoice/i }).first(); + if (!(await newBtn.isVisible({ timeout: 3000 }).catch(() => false))) { + test.skip(true, 'New invoice button not present'); + return; + } + await newBtn.click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // The billing-entity picker should expose a client/company toggle. + const clientOption = dialog.getByRole('button', { name: /^client$/i }).first(); + const companyOption = dialog.getByRole('button', { name: /^company$/i }).first(); + expect( + (await clientOption.isVisible({ timeout: 3000 }).catch(() => false)) || + (await companyOption.isVisible({ timeout: 3000 }).catch(() => false)), + ).toBe(true); + + const cancel = dialog.getByRole('button', { name: /cancel|close/i }).first(); + await cancel.click(); + await expect(dialog).toBeHidden({ timeout: 5000 }); + }); +}); diff --git a/tests/e2e/exhaustive/07-berths.spec.ts b/tests/e2e/exhaustive/07-berths.spec.ts new file mode 100644 index 0000000..679480b --- /dev/null +++ b/tests/e2e/exhaustive/07-berths.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from '@playwright/test'; + +import { clickEverythingOnPage } from '../../helpers/click-everything'; +import { login, navigateTo, PORT_SLUG } from '../smoke/helpers'; + +test.describe('exhaustive: berths with reservations panel', () => { + test.beforeEach(async ({ page }) => { + await login(page, 'super_admin'); + }); + + test('list page', async ({ page }) => { + await navigateTo(page, '/berths'); + await page.waitForLoadState('networkidle'); + + const result = await clickEverythingOnPage(page); + expect(result.errors, JSON.stringify(result.errors, null, 2)).toEqual([]); + }); + + test('detail page — every tab including reservations', async ({ page }) => { + await navigateTo(page, '/berths'); + await page.waitForLoadState('networkidle'); + + const firstRow = page.locator('tbody tr a, tbody tr button').first(); + if (!(await firstRow.isVisible({ timeout: 3000 }).catch(() => false))) { + test.skip(true, 'no berths seeded'); + return; + } + await firstRow.click(); + await page.waitForURL(new RegExp(`/${PORT_SLUG}/berths/[^/]+`), { timeout: 10_000 }); + await page.waitForLoadState('networkidle'); + + const tabs = await page.getByRole('tab').all(); + for (const tab of tabs) { + if (await tab.isVisible({ timeout: 250 }).catch(() => false)) { + await tab.click(); + await page.waitForLoadState('networkidle'); + } + } + + const result = await clickEverythingOnPage(page); + expect(result.errors, JSON.stringify(result.errors, null, 2)).toEqual([]); + }); +}); diff --git a/tests/e2e/exhaustive/08-portal.spec.ts b/tests/e2e/exhaustive/08-portal.spec.ts new file mode 100644 index 0000000..8b16a70 --- /dev/null +++ b/tests/e2e/exhaustive/08-portal.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from '@playwright/test'; + +import { clickEverythingOnPage } from '../../helpers/click-everything'; +import { login } from '../smoke/helpers'; + +const PORTAL_PAGES = ['/portal/yachts', '/portal/memberships', '/portal/my-reservations']; + +test.describe('exhaustive: client portal', () => { + test.beforeEach(async ({ page }) => { + // Portal sessions reuse the same auth; the portal layout itself routes + // by client-membership rather than role. + await login(page, 'super_admin'); + }); + + for (const path of PORTAL_PAGES) { + test(`${path} renders and every visible button is clickable`, async ({ page }) => { + await page.goto(path); + 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 + // seeded portal fixture. + if (page.url().includes('/login')) { + test.skip(true, 'portal page redirected to login (no portal user seeded)'); + return; + } + + const result = await clickEverythingOnPage(page); + expect(result.errors, JSON.stringify(result.errors, null, 2)).toEqual([]); + }); + } +}); diff --git a/tests/e2e/exhaustive/09-navigation.spec.ts b/tests/e2e/exhaustive/09-navigation.spec.ts new file mode 100644 index 0000000..598f041 --- /dev/null +++ b/tests/e2e/exhaustive/09-navigation.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from '@playwright/test'; + +import { login, navigateTo } from '../smoke/helpers'; + +const NAV_TARGETS = [ + '/clients', + '/yachts', + '/companies', + '/berths', + '/interests', + '/invoices', + '/expenses', +]; + +test.describe('exhaustive: navigation', () => { + test.beforeEach(async ({ page }) => { + await login(page, 'super_admin'); + }); + + test('every primary nav target loads without console or network errors', async ({ page }) => { + const errors: string[] = []; + + page.on('console', (msg) => { + if (msg.type() === 'error') + errors.push(`[console:${page.url()}] ${msg.text().slice(0, 200)}`); + }); + page.on('response', (resp) => { + if (resp.status() >= 500) + errors.push(`[network:${page.url()}] ${resp.status()} ${resp.url()}`); + }); + + for (const target of NAV_TARGETS) { + await navigateTo(page, target); + await page.waitForLoadState('networkidle'); + // Confirm we landed on the requested page. + expect(page.url(), `navigation to ${target}`).toContain(target); + } + + expect(errors, JSON.stringify(errors, null, 2)).toEqual([]); + }); +}); diff --git a/tests/helpers/click-everything.ts b/tests/helpers/click-everything.ts new file mode 100644 index 0000000..1ace6bd --- /dev/null +++ b/tests/helpers/click-everything.ts @@ -0,0 +1,143 @@ +import type { Page } from '@playwright/test'; + +export interface ClickEverythingOptions { + /** + * Substrings matched against each element's outerHTML to skip destructive + * interactions. The helper extracts the opening tag (first 200 chars of + * outerHTML) so test-ids, aria labels, class names, and tag names are all + * matchable. Default skips cover archive/delete/restore/transfer/sign-out. + */ + skip?: string[]; + /** + * Optional cleanup hook invoked after each click (and after auto-closing any + * dialog opened by the click). Useful when a click flips global state and + * the next iteration needs the page fresh. + */ + cleanupBetween?: () => Promise; + /** + * Per-click timeout in ms. Defaults to 2000. + */ + clickTimeoutMs?: number; + /** + * If true (default), the helper navigates back to the starting URL after + * each click that changed the URL. Set to false for pages where the goal is + * to follow links (e.g. nav menus) end-to-end. + */ + returnToStart?: boolean; +} + +export interface ClickEverythingResult { + clicked: number; + skipped: number; + errors: string[]; +} + +const DEFAULT_SKIP = [ + 'data-testid="archive"', + 'data-testid="delete"', + 'data-testid="restore"', + 'data-testid="transfer-yacht"', + 'data-testid="sign-out"', + 'aria-label="Sign out"', + 'aria-label="Log out"', + '>Archive<', + '>Delete<', + '>Restore<', + '>Transfer<', +]; + +/** + * Click every visible button, link, and role=button on the current page and + * record any console errors, network 4xx/5xx responses, or click failures. + * + * Why: a fast smoke check that no UI element 500s, throws, or routes to a + * stale endpoint after a refactor — without writing per-button assertions. + * + * The helper is intentionally tolerant: a single click that fails only adds + * to `errors` rather than throwing, so the caller can attribute failures to + * specific elements via the returned list. + */ +export async function clickEverythingOnPage( + page: Page, + opts: ClickEverythingOptions = {}, +): Promise { + const startingUrl = page.url(); + const errors: string[] = []; + let clicked = 0; + let skipped = 0; + + const skipPatterns = [...DEFAULT_SKIP, ...(opts.skip ?? [])]; + const clickTimeout = opts.clickTimeoutMs ?? 2000; + const returnToStart = opts.returnToStart ?? true; + + const onConsole = (msg: import('@playwright/test').ConsoleMessage) => { + if (msg.type() === 'error') { + errors.push(`[console] ${msg.text().slice(0, 300)}`); + } + }; + const onResponse = (resp: import('@playwright/test').Response) => { + if (resp.status() >= 400) { + const url = resp.url(); + // Ignore third-party noise; focus on app endpoints. + if (url.includes(new URL(startingUrl).host)) { + errors.push(`[network] ${resp.status()} ${url}`); + } + } + }; + page.on('console', onConsole); + page.on('response', onResponse); + + try { + const elements = await page.locator(':is(button, a, [role="button"])').all(); + for (const el of elements) { + let outerHtml = ''; + try { + outerHtml = (await el.evaluate((n) => (n as HTMLElement).outerHTML)).slice(0, 200); + } catch { + // Element detached between locator and evaluate — skip silently. + continue; + } + + if (skipPatterns.some((pat) => outerHtml.includes(pat))) { + skipped++; + continue; + } + + try { + if (!(await el.isVisible({ timeout: 250 }).catch(() => false))) { + continue; + } + + await el.click({ timeout: clickTimeout }); + clicked++; + + // Let any async UI settle (navigation, fetches, animations). + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => undefined); + + // If a dialog opened, close it so the next click isn't blocked. + const dialogClose = page.locator('[role="dialog"] [aria-label="Close"]').first(); + if (await dialogClose.isVisible({ timeout: 250 }).catch(() => false)) { + await dialogClose.click({ timeout: 1000 }).catch(() => undefined); + } else { + // Best-effort Esc to dismiss any open menu/popover. + await page.keyboard.press('Escape').catch(() => undefined); + } + + if (returnToStart && page.url() !== startingUrl) { + await page.goto(startingUrl, { waitUntil: 'networkidle' }).catch(() => undefined); + } + + if (opts.cleanupBetween) { + await opts.cleanupBetween(); + } + } catch (err) { + errors.push(`[click] ${outerHtml.slice(0, 100)} → ${(err as Error).message}`); + } + } + } finally { + page.off('console', onConsole); + page.off('response', onResponse); + } + + return { clicked, skipped, errors }; +}