// Authenticated critical path (docs/QA_PLAN.md B15). Runs logged-in via the // storageState produced by auth.setup.js against the seeded scratch DB. // // Selectors are grounded in real DOM (no test-ids exist yet): // - quick-pay: StatusBadge button, title "Click to mark paid" / "...unpaid" // - bills: seeded names from scripts/seedDemoData.js (e.g. "Electric Company") // - nav: sidebar link labels from client/components/layout/Sidebar.jsx const { test, expect } = require('@playwright/test'); const { STORAGE_STATE } = require('./constants'); test.use({ storageState: STORAGE_STATE }); test('tracker renders seeded bills', async ({ page }) => { await page.goto('/'); // Date-independent proof the seeded month loaded with rows. await expect(page.getByText(/\d+ bills/).first()).toBeVisible(); // At least one clickable status toggle is rendered (seeded bills are mostly // autopay → "autodraft", so the toggle usually reads "mark unpaid"). The app // renders both a desktop table row and a hidden mobile row, so scope to :visible. const toggles = page.locator('button[title="Click to mark paid"]:visible, button[title="Click to mark unpaid"]:visible'); await expect(toggles.first()).toBeVisible(); }); test("toggling a bill's paid status updates then restores it (desktop)", async ({ page, isMobile }) => { test.skip(isMobile, 'mobile rows use a different control; covered on desktop'); await page.goto('/'); // :visible avoids the hidden mobile-row duplicates the app also renders. const pay = page.locator('button[title="Click to mark paid"]:visible'); // currently unpaid const paid = page.locator('button[title="Click to mark unpaid"]:visible'); // currently paid/autodraft await expect(pay.or(paid).first()).toBeVisible(); const p0 = await pay.count(); const u0 = await paid.count(); expect(p0 + u0).toBeGreaterThan(0); // Marking a bill PAID is immediate; marking it UNPAID pops a "Remove Payment" // confirmation (the deletion-safety flow) that must be confirmed. const confirmRemoveIfPrompted = async () => { await page .getByRole('button', { name: 'Remove Payment' }) .click({ timeout: 3000 }) .catch(() => {}); }; // Toggle one bill and confirm the optimistic update reconciles (counts shift by // exactly one), then toggle back and confirm the original counts return — no // double-count, no stuck state. Direction depends on the seed's mostly-autopay bills. if (p0 > 0) { await pay.first().click(); await expect(pay).toHaveCount(p0 - 1); await expect(paid).toHaveCount(u0 + 1); await paid.first().click(); await confirmRemoveIfPrompted(); await expect(pay).toHaveCount(p0); await expect(paid).toHaveCount(u0); } else { await paid.first().click(); await confirmRemoveIfPrompted(); await expect(paid).toHaveCount(u0 - 1); await expect(pay).toHaveCount(p0 + 1); await pay.first().click(); await expect(paid).toHaveCount(u0); await expect(pay).toHaveCount(p0); } }); test('authenticated pages render without server errors or crashes', async ({ page }) => { const pageErrors = []; const serverErrors = []; page.on('pageerror', (e) => pageErrors.push(String(e))); page.on('response', (r) => { if (r.url().includes('/api/') && r.status() >= 500) serverErrors.push(`${r.status()} ${r.url()}`); }); // Mirrors the sidebar; navigate by URL so it's viewport-independent. for (const path of ['/', '/summary', '/bills', '/calendar', '/analytics', '/spending', '/snowball', '/categories']) { await page.goto(path); await page.waitForLoadState('networkidle'); await expect(page.locator('body')).not.toBeEmpty(); } expect(pageErrors, `uncaught page errors:\n${pageErrors.join('\n')}`).toEqual([]); expect(serverErrors, `5xx API responses:\n${serverErrors.join('\n')}`).toEqual([]); // TODO: once Summary's DOM is pinned, assert its paid/unpaid/remaining totals // reconcile numerically with the Tracker for the same month (QA_PLAN B5). }); test('top nav navigates to Analytics (desktop)', async ({ page, isMobile }) => { test.skip(isMobile, 'nav collapses to a menu on mobile'); await page.goto('/'); // Desktop uses a top nav ("Analytics" is a direct item; most pages live under // the "Tracker" dropdown). Click the visible Analytics nav item. const analytics = page .getByRole('link', { name: 'Analytics' }) .or(page.getByRole('button', { name: 'Analytics' })) .first(); await analytics.click(); await expect(page).toHaveURL(/\/analytics$/); });