BillTracker/e2e/control-census.spec.js

119 lines
3.9 KiB
JavaScript

// Automated per-page control census (QA_PLAN Appendix E / IMP-CODE-07).
// Replaces the never-filled manual census tables: every interactive control on
// each page is enumerated (role + accessible name) and snapshotted, so a new,
// removed, or renamed control shows up as a reviewable diff each cycle instead
// of relying on a hand-maintained ledger. Numbers are normalized to '#' so
// dates/amounts/counts don't churn the snapshots; the list is deduped + sorted
// so ordering and per-row repetition don't either.
const { test, expect } = require('@playwright/test');
const { STORAGE_STATE } = require('./constants');
test.use({ storageState: STORAGE_STATE });
const PAGES = [
['tracker', '/'],
['calendar', '/calendar'],
['summary', '/summary'],
['bills', '/bills'],
['subscriptions', '/subscriptions'],
['categories', '/categories'],
['health', '/health'],
['analytics', '/analytics'],
['spending', '/spending'],
['banking', '/bank-transactions'],
['snowball', '/snowball'],
['payoff', '/payoff'],
['data', '/data'],
['settings', '/settings'],
['profile', '/profile'],
];
const CONTROL_SELECTOR = [
'button',
'a[href]',
'input',
'select',
'textarea',
'[role="button"]',
'[role="link"]',
'[role="tab"]',
'[role="switch"]',
'[role="checkbox"]',
'[role="menuitem"]',
'[role="combobox"]',
].join(', ');
// Deterministic snapshots, but page-load timing flakes under the parallel probe
// load (16 workers, one dev server) — so retry: a real control change still
// fails every retry, a load-flake passes on the next.
test.describe.configure({ retries: 2 });
for (const [name, path] of PAGES) {
test(`control census: ${name}`, async ({ page }) => {
await page.goto(path);
await page.waitForLoadState('domcontentloaded');
// Wait for the app shell (nav) to mount, then self-stabilize: sample the
// control count until it is non-trivial and unchanged across THREE
// consecutive samples (two was enough in isolation but raced under load).
await page
.getByRole('navigation')
.first()
.waitFor({ timeout: 15_000 })
.catch(() => {});
let prev = -1;
let stable = 0;
for (let i = 0; i < 30; i++) {
const count = await page.locator(CONTROL_SELECTOR).count();
if (count > 3 && count === prev) {
if (++stable >= 2) break; // 3 equal samples total
} else {
stable = 0;
}
prev = count;
await page.waitForTimeout(500);
}
const controls = await page.$$eval(CONTROL_SELECTOR, (els) =>
els
.filter((el) => {
const style = window.getComputedStyle(el);
return style.display !== 'none' && style.visibility !== 'hidden';
})
.map((el) => {
const role =
el.getAttribute('role') ||
(el.tagName === 'A'
? 'link'
: el.tagName === 'INPUT'
? `input:${el.getAttribute('type') || 'text'}`
: el.tagName.toLowerCase());
const name =
el.getAttribute('aria-label') ||
el.getAttribute('placeholder') ||
(el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60) ||
el.getAttribute('title') ||
'(unnamed)';
return `${role} | ${name}`;
}),
);
const normalized = [
...new Set(
controls.map((c) =>
c
.replace(/\d[\d,.]*/g, '#') // dates, amounts, counts
.replace(/#\s*(am|pm)/gi, '# $1')
// Collapse count-driven pluralization (# transaction vs # transactions)
// so a data-count of 1 vs N doesn't churn the snapshot — the census
// tracks *controls*, not the data rendered inside their labels.
.replace(/(# [a-z]+?)s\b/gi, '$1'),
),
),
]
.sort()
.join('\n');
expect(normalized).toMatchSnapshot(`census-${name}.txt`);
});
}