2026-07-10 18:21:46 -05:00
|
|
|
// 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(', ');
|
|
|
|
|
|
2026-07-11 07:14:05 -05:00
|
|
|
// 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 });
|
|
|
|
|
|
2026-07-10 18:21:46 -05:00
|
|
|
for (const [name, path] of PAGES) {
|
|
|
|
|
test(`control census: ${name}`, async ({ page }) => {
|
|
|
|
|
await page.goto(path);
|
|
|
|
|
await page.waitForLoadState('domcontentloaded');
|
2026-07-11 07:14:05 -05:00
|
|
|
// 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(() => {});
|
2026-07-10 18:21:46 -05:00
|
|
|
let prev = -1;
|
2026-07-11 07:14:05 -05:00
|
|
|
let stable = 0;
|
|
|
|
|
for (let i = 0; i < 30; i++) {
|
2026-07-10 18:21:46 -05:00
|
|
|
const count = await page.locator(CONTROL_SELECTOR).count();
|
2026-07-11 07:14:05 -05:00
|
|
|
if (count > 3 && count === prev) {
|
|
|
|
|
if (++stable >= 2) break; // 3 equal samples total
|
|
|
|
|
} else {
|
|
|
|
|
stable = 0;
|
|
|
|
|
}
|
2026-07-10 18:21:46 -05:00
|
|
|
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
|
2026-07-11 07:14:05 -05:00
|
|
|
.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'),
|
2026-07-10 18:21:46 -05:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
.sort()
|
|
|
|
|
.join('\n');
|
|
|
|
|
|
|
|
|
|
expect(normalized).toMatchSnapshot(`census-${name}.txt`);
|
|
|
|
|
});
|
|
|
|
|
}
|