blob: ee128d27da962ec8afb53daa866d4d50c2188e58 [file]
// Systematic row-alignment auditor (design governance tool).
//
// For every e2e-fixture fixture, finds horizontal clusters of interactive
// controls and reports:
// - height mismatch (same control type sharing a row)
// - centerline drift (mixed control types sharing a row)
// - radius mismatch (same-row controls on different radius families;
// role=switch is pill by design and exempt)
// Usage: node scripts/audit-alignment.mjs (expects a built renderer)
// Rule of thumb: mixed types align CENTERS; same types also match heights.
//
// Deliberate change from the original raw-spawn launcher: fixture windows now
// launch through `withFixtureWindow`, which means MAKA_E2E=1, a throwaway
// userData dir, and a sandboxed $HOME. The audit used to run against the real
// userData path — a developer with Maka open lost the single-instance lock —
// and could enumerate the real `~/.maka/skills`. Fixture pages render fixture
// data either way, so control geometry is unaffected; the isolation is the
// point.
import { withFixtureWindow } from './fixture-window.mjs';
// Each fixture names the element that means "this surface has rendered", so
// the audit never measures a half-mounted page. The module pages share one
// shell (`.maka-module-main`), every settings scenario mounts the settings
// modal (`.settingsSurface`), and turn-narrative is the chat surface.
const FIXTURES = [
['module-skills', '.maka-module-main'],
['module-mcp', '.maka-module-main'],
['module-daily-review', '.maka-module-main'],
['plan-reminders', '.maka-module-main'],
['settings-general', '.settingsSurface'],
['fetched-empty', '.settingsSurface'],
['settings-data', '.settingsSurface'],
// 使用统计 restyle: the range/refresh row, underline tab bar, and stats
// tables now sit under the alignment auditor's watch.
['settings-usage', '.settingsSurface'],
['turn-narrative', '.maka-session-workbar'],
['settings-permissions', '.settingsSurface'],
// #1233 deferral: bot QR-onboarding modal in its deterministic waiting state.
['settings-bots-onboarding', '.settingsSurface'],
];
// 2500ms, not the capture harness's 1000ms: this audit is a CI gate, and its
// slowest surfaces (the QR-onboarding modal, the usage stats tables) populate
// after their settings shell mounts. The readiness selector proves the shell
// rendered; the settle budget is what covers content that arrives after it,
// and CI runners are the slow end of the fleet.
const SETTLE_MS = Number(process.env.AUDIT_SETTLE_MS ?? 2_500);
let totalIssues = 0;
let fixtureErrors = 0;
const EXPR = `(()=>{
const controls=[...document.querySelectorAll('button,[role=button],[role=switch],input,select,[role=combobox],[role=tab]')].filter(e=>{
const r=e.getBoundingClientRect();
const cs=getComputedStyle(e);
return r.width>0 && r.height>8 && cs.visibility!=='hidden' && cs.display!=='none';
});
const clusters=new Map();
for(const e of controls){
const p=e.parentElement; if(!p) continue;
if(!clusters.has(p)) clusters.set(p,[]);
clusters.get(p).push(e);
}
const issues=[];
for(const [p,els] of clusters){
if(els.length<2) continue;
const rects=els.map(e=>({e,r:e.getBoundingClientRect(),cs:getComputedStyle(e)}));
// horizontal cluster: vertical ranges overlap pairwise with the first
const base=rects[0].r;
const horiz=rects.filter(({r})=>Math.min(r.bottom,base.bottom)-Math.max(r.top,base.top) > Math.min(r.height,base.height)*0.5);
if(horiz.length<2) continue;
const type=(e)=>e.getAttribute('role')||e.tagName;
const sameType=new Set(horiz.map(({e})=>type(e))).size===1;
const hs=horiz.map(({r})=>+r.height.toFixed(1));
const cys=horiz.map(({r})=>+(r.top+r.height/2).toFixed(1));
const label=(e)=>((e.getAttribute('aria-label')||e.textContent||e.className||'').trim().slice(0,16));
const hSpread=Math.max(...hs)-Math.min(...hs);
const cySpread=Math.max(...cys)-Math.min(...cys);
const radSet=[...new Set(horiz.filter(({e})=>e.getAttribute('role')!=='switch').map(({cs})=>cs.borderRadius).filter(x=>!x.includes('%')&&parseFloat(x)<100))];
if(hSpread>2.5 && sameType) issues.push({kind:'height',parent:p.className.split(' ')[0]||p.tagName,spread:+hSpread.toFixed(1),items:horiz.map(({e,r})=>label(e)+':'+r.height.toFixed(0))});
if(cySpread>1.5 && (!sameType || hSpread<=2.5)) issues.push({kind:'center',parent:p.className.split(' ')[0]||p.tagName,spread:+cySpread.toFixed(1),items:horiz.map(({e,r})=>label(e)+':'+(r.top+r.height/2).toFixed(0))});
if(radSet.length>1 && hSpread<=2.5) issues.push({kind:'radius',parent:p.className.split(' ')[0]||p.tagName,items:horiz.map(({e,cs})=>label(e)+':'+cs.borderRadius)});
}
return JSON.stringify(issues.slice(0,12));
})()`;
for (const [fixture, readySelector] of FIXTURES) {
try {
const issues = await withFixtureWindow(
fixture,
{ theme: 'light', readySelector, settleMs: SETTLE_MS },
async ({ evaluate }) => JSON.parse(await evaluate(EXPR)),
);
console.log('==', fixture, '==');
for (const issue of issues) console.log(JSON.stringify(issue));
totalIssues += issues.length;
if (!issues.length) console.log('(clean)');
} catch (err) {
console.log('==', fixture, '== ERROR', err.message);
fixtureErrors++;
}
}
// CI semantics: alignment findings fail the run; fixture-level launch errors
// fail too (a fixture that can't boot means the audit didn't actually cover it).
if (totalIssues > 0 || fixtureErrors > 0) {
console.log(`FAIL: ${totalIssues} alignment issue(s), ${fixtureErrors} fixture error(s)`);
process.exit(1);
}
console.log('alignment audit: all fixtures clean');
process.exit(0);