Files
goodbuddy/src/renderer/validation/runtime-checklist-electron.mjs
T
mesalogo d30f794184 fix: expand runtime checklists without shifting chat content
Render checklist details in an anchored floating panel with bounded scrolling, outside dismissal and Escape focus restoration. Reuse the shared overlay mechanism and update layout documentation and regression coverage.

Validation: 25 focused tests, Electron checklist layout and keyboard scenarios, and lint passed. Full-suite and typecheck blockers outside this change were reported separately.

Release note: Runtime execution checklists now expand in a floating panel without pushing conversation content down.
2026-09-30 02:36:50 +08:00

170 lines
12 KiB
JavaScript

import { app, BrowserWindow } from 'electron'
import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import process from 'node:process'
import console from 'node:console'
import { setTimeout as wait } from 'node:timers/promises'
const evidence = process.env.CHECKLIST_VISUAL_EVIDENCE
const results = []
let window
const evaluate = script => window.webContents.executeJavaScript(script, true)
const click = selector => evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`)
const check = async (name, script) => {
const passed = await evaluate(script)
results.push({ name, passed })
console.log(name, passed ? 'PASS' : 'FAIL')
}
async function key(keyCode, modifiers = []) {
if (keyCode === 'Enter') keyCode = 'Return'
window.focus()
window.webContents.focus()
window.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers })
if (keyCode === 'Return') window.webContents.sendInputEvent({ type: 'char', keyCode: '\r', modifiers })
window.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers })
await wait(100)
}
async function capture(name) {
await window.webContents.capturePage()
for (let attempt = 0; attempt < 50; attempt++) {
const settled = await evaluate(`(() => {
const panel = document.querySelector('.runtime-checklist__content');
if (!panel) return true;
const rect = panel.getBoundingClientRect();
const anchor = document.querySelector('.runtime-checklist__toggle').getBoundingClientRect();
return rect.right <= innerWidth + 1 && rect.bottom <= innerHeight + 1
&& Math.abs(rect.width - Math.min(anchor.width, visualViewport.width - 32)) < 1;
})()`)
if (settled) break
if (attempt === 49) throw new Error(`Floating panel did not settle for ${name}`)
await wait(100)
}
const measurements = await evaluate(`(() => {
const rect = selector => { const e = document.querySelector(selector); if (!e) return null; const r = e.getBoundingClientRect(); return { x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,bottom:r.bottom,clientHeight:e.clientHeight,scrollHeight:e.scrollHeight,clientWidth:e.clientWidth,scrollWidth:e.scrollWidth } };
return { viewport: {width:innerWidth,height:innerHeight,dpr:devicePixelRatio}, body:rect('body'), top:rect('.conversation-context-strips'), task:rect('.conversation-task-strip__toggle'), checklist:rect('.runtime-checklist__toggle'), list:rect('.runtime-checklist__content'), chat:rect('.chat'), input:rect('input'), focus:document.activeElement?.className };
})()`)
const image = await window.webContents.capturePage()
writeFileSync(join(evidence, `${name}.png`), image.toPNG())
results.push({ name, ...measurements })
results.push({ name: `${name}-layout`, passed: measurements.body.scrollWidth <= measurements.body.clientWidth + 1 && measurements.input.bottom <= measurements.viewport.height + 1 && measurements.chat.height > 40 && (!measurements.checklist || measurements.checklist.bottom <= measurements.top.bottom + 1) && (!measurements.task || measurements.task.y >= measurements.top.y - 1) })
console.log(name, JSON.stringify(measurements))
}
app.whenReady().then(async () => {
mkdirSync(evidence, { recursive: true })
window = new BrowserWindow({ width: 1440, height: 1000, useContentSize: true, show: true, autoHideMenuBar: true, webPreferences: { contextIsolation: true, sandbox: true } })
window.webContents.on('console-message', event => console.log('renderer:', event.message))
await window.loadURL(process.env.CHECKLIST_VISUAL_URL)
for (let attempt = 0; attempt < 100; attempt++) {
if (await evaluate('Boolean(window.checklistHarness)')) break
await wait(100)
}
if (process.env.CHECKLIST_VISUAL_BASELINE === '1') {
await evaluate('document.fonts.ready.then(() => true)')
await click('.runtime-checklist__toggle')
for (const [name, width, height, zoom] of [
['wide-100', 1440, 1000, 1], ['narrow-100', 760, 800, 1],
['wide-200', 1440, 1000, 2], ['narrow-200', 760, 800, 2]
]) {
window.setContentSize(width, height)
window.focus()
await wait(300)
window.webContents.setZoomFactor(zoom)
await window.webContents.capturePage()
let viewportReady = false
for (let attempt = 0; attempt < 40; attempt++) {
await wait(100)
viewportReady = await evaluate(`Math.abs(innerWidth - ${width / zoom}) < 3 && Math.abs(innerHeight - ${height / zoom}) < 3`)
if (viewportReady) break
}
if (!viewportReady) throw new Error(`Viewport did not settle for ${name}: ${JSON.stringify(await evaluate('({width:innerWidth,height:innerHeight,dpr:devicePixelRatio})'))}; native=${window.getContentSize()}; zoom=${window.webContents.getZoomFactor()}`)
for (const index of [0, 1, 3, 4]) {
await evaluate(`document.querySelectorAll('.runtime-checklist__content li')[${index}].scrollIntoView({block:'start'})`)
await wait(100)
const measured = await evaluate(`(() => {
const item = document.querySelectorAll('.runtime-checklist__content li')[${index}];
const label = item.querySelector('.runtime-checklist__status > span');
const text = item.querySelector('.runtime-checklist__text');
const range = document.createRange();
const rect = r => ({top:r.top,bottom:r.bottom,height:r.height,left:r.left,right:r.right});
range.selectNodeContents(label);
const labelText = rect(range.getClientRects()[0]);
range.selectNodeContents(text);
const lines = Array.from(range.getClientRects()).filter(r => r.width > 0);
const firstLine = rect(lines[0]);
const labelSpan = rect(label.getBoundingClientRect());
return {status:label.textContent, content:text.textContent, viewport:{width:innerWidth,height:innerHeight,dpr:devicePixelRatio},
lineCount:new Set(lines.map(r => r.top)).size, labelSpan, labelText, firstLine,
spanTopDelta:labelSpan.top-firstLine.top,spanBottomDelta:labelSpan.bottom-firstLine.bottom,
textTopDelta:labelText.top-firstLine.top,textBottomDelta:labelText.bottom-firstLine.bottom};
})()`)
const passed = [measured.textTopDelta, measured.textBottomDelta, measured.spanTopDelta, measured.spanBottomDelta]
.every(delta => Math.abs(delta) < 0.1)
results.push({ name, index, zoom, passed, ...measured })
console.log('baseline', JSON.stringify({ name, index, passed, ...measured, content: undefined }))
if (index === 0 || index === 3) {
writeFileSync(join(evidence, `${name}-item-${index + 1}.png`), (await window.webContents.capturePage()).toPNG())
}
}
}
writeFileSync(join(evidence, 'baseline-results.json'), JSON.stringify({ environment: {
platform:process.platform,electron:process.versions.electron,chrome:process.versions.chrome,
path:'production renderer components in Electron harness; not full desktop IPC',units:'CSS pixels'
}, results }, null, 2))
app.exit(results.some(result => !result.passed) ? 1 : 0)
return
}
await capture('01-wide-collapsed')
await click('.conversation-task-strip__toggle')
await evaluate("window.checklistChatRect = document.querySelector('.chat').getBoundingClientRect().toJSON()")
await click('.runtime-checklist__toggle')
await capture('02-wide-both-expanded')
await check('expanded-checklist-does-not-move-chat', "(() => { const rect = document.querySelector('.chat').getBoundingClientRect(); return rect.y === window.checklistChatRect.y && rect.height === window.checklistChatRect.height && getComputedStyle(document.querySelector('.runtime-checklist__content')).position === 'fixed'; })()")
await evaluate("document.querySelector('.runtime-checklist__toggle').focus()")
await key('Space')
await check('space-collapses-only-checklist', "document.querySelector('.runtime-checklist__toggle').getAttribute('aria-expanded') === 'false' && document.querySelector('.conversation-task-strip__toggle').getAttribute('aria-expanded') === 'true'")
await key('Enter')
await check('enter-expands-checklist-focuses-panel', "document.querySelector('.runtime-checklist__toggle').getAttribute('aria-expanded') === 'true' && document.activeElement.matches('.runtime-checklist__content')")
await evaluate("document.querySelector('input').focus(); window.checklistHarness.update()")
results.push({ name: 'update-keeps-input-focus', passed: await evaluate("document.activeElement === document.querySelector('input')") })
await check('outside-focus-closes-checklist', "!document.querySelector('.runtime-checklist__content')")
await click('.runtime-checklist__toggle')
await evaluate("document.querySelector('.runtime-checklist__content').scrollTop = 100000")
await capture('03-long-list-end')
window.webContents.setZoomFactor(2)
await capture('03b-wide-200-percent')
window.webContents.setZoomFactor(1)
window.setContentSize(760, 800)
await capture('04-narrow-both-expanded')
window.webContents.setZoomFactor(2)
await capture('05-narrow-200-percent')
await evaluate("document.querySelector('.runtime-checklist__content').focus()")
await key('End', ['control'])
await wait(300)
await check('keyboard-reaches-last-item-at-200-percent', "(() => {const list=document.querySelector('.runtime-checklist__content'); const last=list.querySelector('li:last-child').getBoundingClientRect(); const r=list.getBoundingClientRect(); return document.activeElement === list && last.bottom <= r.bottom + 1 && last.bottom > r.top;})()")
await key('Escape')
await check('escape-closes-and-restores-toggle-focus', "!document.querySelector('.runtime-checklist__content') && document.activeElement.matches('.runtime-checklist__toggle')")
await evaluate("document.querySelector('.conversation-task-details__actions button').focus()")
await key('Enter')
await check('task-run-action-at-200-percent', 'window.checklistHarness.runs === 1')
await capture('05b-narrow-keyboard-task-action')
await evaluate("window.checklistHarness.theme('dark')")
await click('.runtime-checklist__toggle')
await check('dark-theme-applied-to-root', "document.documentElement.dataset.theme === 'dark' && getComputedStyle(document.documentElement).getPropertyValue('--surface-raised').trim() !== '#ffffff'")
await capture('06-dark-narrow-200-percent')
window.webContents.setZoomFactor(1)
window.setContentSize(1440, 1000)
await evaluate("window.checklistHarness.scenario('next')")
results.push({ name: 'new-request-hides-old-checklist', passed: await evaluate("!document.querySelector('.runtime-checklist')") })
await capture('07-new-request')
await evaluate("window.checklistHarness.scenario('stale')")
await check('stale-terminal-request-does-not-revive-old-checklist', "!document.querySelector('.runtime-checklist')")
await evaluate("window.checklistHarness.scenario('cleared')")
results.push({ name: 'empty-hides-old-checklist', passed: await evaluate("!document.querySelector('.runtime-checklist')") })
await evaluate("window.checklistHarness.scenario('history')")
await click('.runtime-checklist__toggle')
await capture('08-cancelled-history')
await evaluate("window.checklistHarness.scenario('remote')")
await capture('09-remote-without-task')
writeFileSync(join(evidence, 'results.json'), JSON.stringify({ environment: { platform:process.platform, electron:process.versions.electron, chrome:process.versions.chrome, node:process.versions.node, path:'component harness, not full desktop IPC path', sandbox:true, contextIsolation:true, gpu:'default', checklistItems:80 }, results }, null, 2))
app.exit(results.some(result => result.passed === false) ? 1 : 0)
}).catch(error => { console.error(error); app.exit(1) })