blob: be2edad03593b7cbda8dd0d4aa9064c19c7b7727 [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.
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const DESKTOP_DIR = join(ROOT, 'apps', 'desktop');
const FIXTURES = [
'module-skills',
'module-mcp',
'module-daily-review',
'plan-reminders',
'settings-general',
'fetched-empty',
'settings-data',
'settings-gateway',
// 使用统计 restyle: the range/refresh row, underline tab bar, and stats
// tables now sit under the alignment auditor's watch.
'settings-usage',
'turn-narrative',
'settings-permissions',
// #1233 deferral: bot QR-onboarding modal in its deterministic waiting state.
'settings-bots-onboarding',
];
const BOOT_TIMEOUT_MS = Number(process.env.AUDIT_BOOT_TIMEOUT_MS ?? 30_000);
const SETTLE_MS = Number(process.env.AUDIT_SETTLE_MS ?? 2_500);
let port = Number(process.env.AUDIT_PORT_BASE ?? 14_600);
let totalIssues = 0;
let fixtureErrors = 0;
// Resolve via the electron package export so Linux CI gets
// `electron/dist/electron` and macOS gets `Electron.app/.../Electron`.
// Hardcoding the .app path is what broke the ubuntu-latest e2e job in #695.
async function resolveElectronBin() {
try {
const bin = (await import('electron')).default;
if (typeof bin === 'string') return bin;
} catch (err) {
console.error('[audit-alignment] electron not resolvable (run `npm install`):', err);
process.exit(2);
}
console.error(
'[audit-alignment] electron resolved but exposed no binary path; run `npm install`.',
);
process.exit(2);
}
function launchArgs(debugPort, userDataDir) {
// Chromium/Electron switches must come before the app path. Mirror the
// Playwright e2e launch: cwd=apps/desktop, app=''.
const args = [`--remote-debugging-port=${debugPort}`, `--user-data-dir=${userDataDir}`];
// Headless Linux runners (GitHub Actions) need these; macOS/Windows e2e
// already pass without them. Playwright's chromium launcher adds the same
// set — raw electron spawn does not.
if (process.platform === 'linux') {
args.push('--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage');
}
args.push('.');
return args;
}
function tail(buf, n = 600) {
const s = buf.toString();
return s.length <= n ? s : s.slice(-n);
}
/** Poll CDP until a page target appears, or the process exits / times out. */
async function waitForPageTarget(debugPort, child, stderrBuf, stdoutBuf) {
const started = Date.now();
while (Date.now() - started < BOOT_TIMEOUT_MS) {
if (child.exitCode !== null || child.signalCode !== null) {
throw new Error(
`electron exited code=${child.exitCode} signal=${child.signalCode}` +
` stderr=${JSON.stringify(tail(stderrBuf))}` +
` stdout=${JSON.stringify(tail(stdoutBuf))}`,
);
}
try {
const list = await (await fetch(`http://127.0.0.1:${debugPort}/json/list`)).json();
const page = list.find((t) => t.type === 'page' && t.webSocketDebuggerUrl);
if (page) return page;
} catch {
// not listening yet
}
await new Promise((r) => setTimeout(r, 250));
}
throw new Error(
`CDP timeout after ${BOOT_TIMEOUT_MS}ms` +
` stderr=${JSON.stringify(tail(stderrBuf))}` +
` stdout=${JSON.stringify(tail(stdoutBuf))}`,
);
}
function cdpSend(ws) {
let id = 0;
return (method, params) =>
new Promise((res, rej) => {
const i = ++id;
const onMessage = (e) => {
let d;
try {
d = JSON.parse(e.data);
} catch (err) {
rej(err);
return;
}
if (d.id !== i) return;
ws.removeEventListener('message', onMessage);
if (d.error) rej(new Error(d.error.message ?? JSON.stringify(d.error)));
else res(d.result);
};
ws.addEventListener('message', onMessage);
ws.send(JSON.stringify({ id: i, method, params }));
});
}
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 rads=horiz.map(({cs})=>cs.borderRadius);
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));
})()`;
const ELECTRON = await resolveElectronBin();
for (const fx of FIXTURES) {
const P = port++;
const userDataDir = join(tmpdir(), `maka-audit-${P}-${process.pid}`);
const stderrBuf = {
s: '',
toString() {
return this.s;
},
};
const stdoutBuf = {
s: '',
toString() {
return this.s;
},
};
const child = spawn(ELECTRON, launchArgs(P, userDataDir), {
cwd: DESKTOP_DIR,
env: {
...process.env,
MAKA_E2E_FIXTURE: fx,
MAKA_E2E_FIXTURE_THEME: 'light',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
child.stderr?.on('data', (d) => {
stderrBuf.s += d;
if (stderrBuf.s.length > 8_000) stderrBuf.s = stderrBuf.s.slice(-4_000);
});
child.stdout?.on('data', (d) => {
stdoutBuf.s += d;
if (stdoutBuf.s.length > 8_000) stdoutBuf.s = stdoutBuf.s.slice(-4_000);
});
// Surface spawn failures (ENOENT etc.) into the try/catch instead of an
// unhandled 'error' event that crashes the process before fixtureErrors++.
const launchError = new Promise((_, rej) => child.once('error', rej));
try {
const page = await Promise.race([
waitForPageTarget(P, child, stderrBuf, stdoutBuf),
launchError,
]);
// Fixture paint settles after the page target appears; fixed 8.5s was both
// slow on warm macOS and still insufficient when boot itself never finished.
await new Promise((r) => setTimeout(r, SETTLE_MS));
const ws = new WebSocket(page.webSocketDebuggerUrl);
await new Promise((r, j) => {
ws.addEventListener('open', r, { once: true });
ws.addEventListener('error', () => j(new Error('cdp websocket error')), { once: true });
});
const send = cdpSend(ws);
const r = await send('Runtime.evaluate', { expression: EXPR, returnByValue: true });
console.log('==', fx, '==');
const arr = JSON.parse(r.result.value);
for (const i of arr) console.log(JSON.stringify(i));
totalIssues += arr.length;
if (!arr.length) console.log('(clean)');
try {
ws.close();
} catch {
/* ignore */
}
} catch (e) {
console.log('==', fx, '== ERROR', e.message);
fixtureErrors++;
}
child.kill('SIGKILL');
}
// 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);