100 lines
3.1 KiB
JavaScript
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`);
|
||
|
|
});
|
||
|
|
}
|