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.
170 lines
12 KiB
JavaScript
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) })
|