BillTracker/e2e/critical-path.spec.js

104 lines
4.5 KiB
JavaScript
Raw Normal View History

// 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$/);
});