BillTracker/e2e/control-census.spec.js

100 lines
3.1 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(', ');
for (const [name, path] of PAGES) {
test(`control census: ${name}`, async ({ page }) => {
await page.goto(path);
await page.waitForLoadState('domcontentloaded');
// Self-stabilizing settle: sample the control count until it is non-trivial
// and unchanged across two consecutive samples (fixed timeouts race the
// data queries; networkidle is flaky with background refetches).
let prev = -1;
for (let i = 0; i < 20; i++) {
const count = await page.locator(CONTROL_SELECTOR).count();
if (count > 3 && count === prev) break;
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'),
),
),
]
.sort()
.join('\n');
expect(normalized).toMatchSnapshot(`census-${name}.txt`);
});
}