Files
ycode/scripts/test-mode-chip-native-electron.cjs
T

102 lines
7.9 KiB
JavaScript

/** Electron-only CJS entry: do not rebuild fixtures or use ESM top-level await here.
* Keeps app.whenReady() out of Electron's ESM loader lifecycle (B01 T01).
*/
const fs = require('node:fs')
const path = require('node:path')
const assert = require('node:assert/strict')
const { createHash } = require('node:crypto')
if (!process.versions.electron) throw new Error('Run node scripts/test-mode-chip-native.mjs instead')
const { app, BrowserWindow } = require('electron')
const base = path.resolve('.tmp')
const output = path.resolve(process.argv.at(-1))
assert.ok(path.dirname(output) === base && path.basename(output).startsWith('mode-chip-native-'))
const file = 'src/renderer/src/workbuddy/composer/ModeChip.tsx'
const checks = [], samples = [], errors = []
let win, timer, currentPhase = 'startup', finished = false
function fail(error) {
if (finished) return
finished = true; clearTimeout(timer)
const detail = `Phase: ${currentPhase}\n${error.stack || error}`
fs.writeFileSync(path.join(output, 'failure.txt'), detail)
console.error(detail)
app.exit(1)
}
function phase(name, timeoutMs) {
currentPhase = name; clearTimeout(timer)
const detail = { phase: name, time: new Date().toISOString(), checksCompleted: checks.length }
fs.writeFileSync(path.join(output, 'phase.json'), JSON.stringify(detail, null, 2))
console.log(JSON.stringify(detail))
if (timeoutMs) timer = setTimeout(() => fail(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs)
}
process.on('uncaughtException', fail)
process.on('unhandledRejection', fail)
// Set private data paths synchronously, before Electron can become ready.
for (const dir of ['home', 'data', 'session']) fs.mkdirSync(path.join(output, dir), { recursive: true })
app.setPath('home', path.join(output, 'home'))
app.setPath('userData', path.join(output, 'data'))
app.setPath('sessionData', path.join(output, 'session'))
app.disableHardwareAcceleration()
async function run() {
try {
phase('app-ready', 15000)
await app.whenReady()
if (finished) return
phase('load-fixture', 15000)
win = new BrowserWindow({ width: 800, height: 300, show: false, webPreferences: {
contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false
} })
win.webContents.on('console-message', event => { if (event.level === 'error' || event.level >= 3) errors.push(event.message) })
win.webContents.on('render-process-gone', (_event, details) => fail(new Error(`Renderer exited: ${JSON.stringify(details)}`)))
win.webContents.on('unresponsive', () => fail(new Error('Renderer unresponsive')))
await win.loadFile(path.join(output, 'index.html'))
if (finished) return
win.showInactive()
phase('fixture-mounted', 10000)
const read = code => {
if (finished) throw new Error('Native test already stopped')
return win.webContents.executeJavaScript(code)
}
const pause = () => new Promise(resolve => setTimeout(resolve, 80))
const check = (label, actual, expected = true) => { assert.deepEqual(actual, expected, label); checks.push(label) }
const key = async keyCode => {
win.focus(); win.webContents.focus(); await pause()
win.webContents.sendInputEvent({ type: 'keyDown', keyCode })
if (keyCode === 'Enter' || keyCode === 'Space') {
win.webContents.sendInputEvent({ type: 'char', keyCode: keyCode === 'Enter' ? '\r' : ' ' })
}
win.webContents.sendInputEvent({ type: 'keyUp', keyCode }); await pause()
}
const sample = () => read(`(()=>{const c=document.querySelector('.yc-mode-chip'),i=c.querySelector('.yc-mode-chip__mode-icon'),b=c.querySelector('button'),s=getComputedStyle(c),r=e=>{const x=e.getBoundingClientRect();return{x:x.x,y:x.y,width:x.width,height:x.height}};return{chip:r(c),icon:r(i),button:b?r(b):null,background:s.backgroundColor,radius:s.borderRadius,color:s.color,expectedColor:s.getPropertyValue('--cr-text-secondary').trim(),font:s.fontSize,weight:s.fontWeight,line:s.lineHeight,padding:s.paddingLeft,iconOpacity:getComputedStyle(i).opacity,clearOpacity:b?getComputedStyle(b).opacity:null}})()`)
await pause()
for (const theme of ['light', 'dark']) for (const mode of ['plan', 'ask']) {
if (finished) return
phase(`checks:${theme}:${mode}`, 10000)
await read(`document.documentElement.dataset.theme='${theme}'; document.querySelector('.chat-pane').dataset.crTheme='${theme}'; qa.setMode('${mode}');qa.setDisabled(false);document.querySelector('#before').focus()`)
win.webContents.sendInputEvent({ type: 'mouseMove', x: 1, y: 1 }); await pause()
const normal = await sample(); samples.push({ theme, mode, state: 'normal', ...normal })
check('transparent', normal.background, 'rgba(0, 0, 0, 0)'); check('pill', parseFloat(normal.radius) >= normal.chip.height / 2)
check('height', normal.chip.height, 32); check('font', normal.font, '13px'); check('weight', normal.weight, '500'); check('line', normal.line, '20px'); check('padding', normal.padding, '8px')
check('same slot', normal.icon, normal.button); check('icon width', normal.icon.width, 16); check('default icon', normal.iconOpacity, '1'); check('default close', normal.clearOpacity, '0')
check('secondary color', await read(`(()=>{const c=document.querySelector('.yc-mode-chip'),p=document.createElement('span');p.style.color='var(--cr-text-secondary)';c.append(p);const ok=getComputedStyle(p).color===getComputedStyle(c).color;p.remove();return ok})()`))
win.webContents.sendInputEvent({ type: 'mouseMove', x: Math.round(normal.chip.x + normal.chip.width / 2), y: Math.round(normal.chip.y + normal.chip.height / 2) }); await pause()
const hover = await sample(); samples.push({ theme, mode, state: 'hover', ...hover }); check('hover icon hidden', hover.iconOpacity, '0'); check('hover X', hover.clearOpacity, '1'); check('no hover layout shift', hover.chip, normal.chip)
win.webContents.sendInputEvent({ type: 'mouseMove', x: 1, y: 1 }); await read(`document.querySelector('.yc-mode-chip').focus()`); await pause()
const focus = await sample(); check('focus icon hidden', focus.iconOpacity, '0'); check('focus X', focus.clearOpacity, '1'); check('no focus layout shift', focus.chip, normal.chip)
await fs.promises.writeFile(path.join(output, `${theme}-${mode}-focus.png`), (await win.webContents.capturePage()).toPNG())
await key('Tab'); check('Tab reaches native clear', await read(`document.activeElement.matches('.yc-mode-chip__clear')`))
const before = await read('qa.clears'); await key(mode === 'plan' ? 'Enter' : 'Space'); check('native keyboard exit', await read('qa.clears'), before + 1); check('craft hidden', await read(`!document.querySelector('.yc-mode-chip')`))
await read(`qa.setMode('${mode}');qa.setDisabled(true)`); await pause(); check('disabled has no clear', await read(`!document.querySelector('.yc-mode-chip button')`)); check('disabled not tabbable', await read(`document.querySelector('.yc-mode-chip').tabIndex`), -1)
}
if (finished) return
phase('checks:narrow', 10000)
win.setSize(320, 300); await read(`qa.setMode('ask');qa.setDisabled(false)`); await pause(); const narrow = await sample(); check('narrow slot preserved', narrow.icon, narrow.button); check('narrow no overflow', narrow.chip.x + narrow.chip.width <= 320)
await fs.promises.writeFile(path.join(output, 'narrow.png'), (await win.webContents.capturePage()).toPNG()); check('no renderer errors', errors, [])
if (finished) return
phase('write-report', 10000)
fs.writeFileSync(path.join(output, 'report.json'), JSON.stringify({ checks, samples, errors, sourceSha256: createHash('sha256').update(fs.readFileSync(file)).digest('hex'), scope: 'Own-YCode isolated component browser test, not full app/store integration or original-app pixel acceptance' }, null, 2))
phase('complete'); finished = true; app.exit(0)
} catch (error) { fail(error) }
}
// Intentionally not awaited at module scope: Electron can finish its startup lifecycle.
void run()