Files
ycode/scripts/test-capability-slash-ui.mjs
T

89 lines
10 KiB
JavaScript

// Real ComposerControls + Slate in isolated Electron. No model, network or user storage.
import fs from 'node:fs'
import path from 'node:path'
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { spawn } from 'node:child_process'
const require = createRequire(import.meta.url), electron = require('electron')
if (typeof electron === 'string') {
const directory = fs.mkdtempSync(path.resolve('.tmp/capability-slash-'))
const env = { ...process.env }; delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
const child = spawn(electron, ['--no-sandbox', path.resolve('scripts/test-capability-slash-ui.mjs'), directory], { env, windowsHide: true, stdio: 'inherit' })
const timer = setTimeout(() => child.kill(), 90000)
process.exitCode = await new Promise(resolve => child.once('exit', code => { clearTimeout(timer); resolve(code ?? 1) }))
} else {
void (async () => {
const { app, BrowserWindow } = electron, directory = path.resolve(process.argv.at(-1))
assert.ok(path.relative(path.resolve('.tmp'), directory).startsWith('capability-slash-'))
app.setPath('userData', path.join(directory, 'data')); app.disableHardwareAcceleration()
const source = file => JSON.stringify(path.resolve(file).replaceAll('\\', '/'))
fs.writeFileSync(path.join(directory, 'fixture.tsx'), `import React,{useState}from'react';import{createRoot}from'react-dom/client';
import{ComposerControls}from${source('src/renderer/src/workbuddy/composer/ComposerControls')};
import{withCapabilitySelection}from${source('src/renderer/src/workbuddy/composer/capability-selection')};
import${source('src/renderer/src/styles/tokens.css')};import${source('src/renderer/src/styles/app.css')};
window.ycode={resources:{},chat:{}};
function Test(){const[state,set]=useState({text:'',skills:[]}),[version,bump]=useState(0);window.__qa={state,setup:s=>{set(s);bump(v=>v+1)},toggle:()=>set(s=>withCapabilitySelection(s,{id:'archify',name:'archify',kind:'skill'},{source:'menu'}))};return <div className="chat-pane cr-theme" style={{padding:40}}><ComposerControls key={version} value={state.text} userContent={state.userContent} skills={state.skills} attachments={state.attachments??[]} selectedCapabilities={state.skills.map(s=>({id:s.id,kind:'skill'}))} capabilityItems={[{id:'archify',name:'archify',kind:'skill'},{id:'existing',name:'existing',kind:'skill'}]} onChange={text=>set(s=>({...s,text}))} onUserContentChange={userContent=>set(s=>({...s,userContent}))} onSelectCapability={(item,intent)=>set(s=>withCapabilitySelection(s,item,intent))} onRemoveSkill={id=>set(s=>({...s,skills:s.skills.filter(v=>v.id!==id)}))} onSend={()=>{throw Error('Unexpected send')}} /></div>}createRoot(document.getElementById('root')).render(<Test/>);`)
await require('esbuild').build({ entryPoints: [path.join(directory, 'fixture.tsx')], bundle: true, platform: 'browser', format: 'esm', jsx: 'automatic', outfile: path.join(directory, 'fixture.js'), loader: { '.svg': 'file', '.png': 'file', '.jpg': 'file', '.woff': 'file', '.woff2': 'file', '.ttf': 'file' }, logLevel: 'warning' })
fs.writeFileSync(path.join(directory, 'index.html'), '<html data-theme="light"><link rel="stylesheet" href="fixture.css"><body><div id="root"></div><script type="module" src="fixture.js"></script></body></html>')
await app.whenReady()
const win = new BrowserWindow({ show: false, width: 1100, height: 850, webPreferences: { offscreen: true, backgroundThrottling: false } }), checks = [], errors = []
win.webContents.on('console-message', event => { if (event.level === 'error') errors.push(event.message) })
const read = code => win.webContents.executeJavaScript(code), pause = ms => new Promise(resolve => setTimeout(resolve, ms))
const wait = async code => { for (let n = 0; n < 150; n++) { if (await read(code)) return; await pause(30) } throw Error('Timeout ' + code) }
const check = (name, actual, expected = true) => { assert.deepEqual(actual, expected, name); checks.push(name) }
const key = async (keyCode, modifiers = []) => { win.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers }); win.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers }); await pause(60) }
const setup = async state => { await read(`__qa.setup(${JSON.stringify(state)})`); await pause(160); await read('document.querySelector(".wb-composer__input").focus()') }
const choose = async (keyboard = false) => {
await wait('!!document.querySelector(".yc-composer-slash-picker")')
if (keyboard) { await read('document.querySelector(".yc-capability-picker input").focus()'); await key('Enter') }
else await read('[...document.querySelectorAll(".yc-capability-picker button")].find(e=>e.textContent.includes("archify")).click()')
await wait('!document.querySelector(".yc-composer-slash-picker")'); await pause(200)
}
try {
await win.loadFile(path.join(directory, 'index.html')); await wait('!!window.__qa')
await setup({ text: '', skills: [] }); await win.webContents.insertText('/archify'); await choose()
check('mouse selection consumes the textarea slash', await read('__qa.state.text'), '')
check('structured content has no stale slash', await read('__qa.state.userContent.filter(v=>v.type==="text").map(v=>v.text).join("")'), '')
check('one selected skill survives transition into Slate', await read('__qa.state.skills.map(v=>v.id)'), ['archify'])
check('the menu stays closed after editor remount', await read('!!document.querySelector(".yc-composer-slash-picker")'), false)
check('the real rich editor is focused', await read('!!document.activeElement.closest(".cr-reference-editor")'))
await setup({ text: '', skills: [] }); await win.webContents.insertText('/'); await choose(true)
check('Enter consumes a bare slash without sending', await read('__qa.state.text'), '')
await setup({ text: 'left RIGHT', skills: [] })
await read('document.querySelector("textarea").setSelectionRange(5,5)'); await win.webContents.insertText('/archify'); await choose()
check('selection preserves text after the caret', await read('__qa.state.text'), 'left RIGHT')
await setup({ text: 'before ', skills: [{ id: 'existing', name: 'existing' }], userContent: [{ type: 'skill', id: 'existing' }, { type: 'text', text: 'before ' }] })
await key('End', ['control']); await win.webContents.insertText('/archify'); await choose(true)
check('rich text selection preserves the existing skill', await read('__qa.state.skills.map(v=>v.id)'), ['existing', 'archify'])
check('rich text selection preserves surrounding text', await read('__qa.state.text'), 'before ')
check('rich text references survive token deletion', await read('__qa.state.userContent.filter(v=>v.type==="skill").map(v=>v.id).sort()'), ['archify', 'existing'])
await win.webContents.insertText('next'); await wait('__qa.state.text.includes("next")')
check('typing continues at the consumed trigger position', await read('__qa.state.text'), 'before next')
await setup({ text: '', skills: [{ id: 'archify', name: 'archify' }] })
await key('End', ['control']); await win.webContents.insertText('/archify'); await choose()
check('slash selection of an already selected skill never toggles it off', await read('__qa.state.skills.map(v=>v.id)'), ['archify'])
await read('__qa.toggle()'); await pause(100)
check('the add-menu policy still toggles an existing selection', await read('__qa.state.skills.length'), 0)
await setup({ text: 'first\nsecond ', skills: [], attachments: [{ id: 'fixture-file', name: 'keep.txt', mimeType: 'text/plain', size: 4 }], userContent: [{ type: 'attachment', id: 'fixture-file' }, { type: 'text', text: 'first\nsecond ' }] })
await key('End', ['control']); await win.webContents.insertText('/archify'); await choose()
check('multiline rich text survives slash deletion', await read('__qa.state.text'), 'first\nsecond ')
check('attachment metadata survives slash selection', await read('__qa.state.attachments.map(v=>v.id)'), ['fixture-file'])
check('attachment inline reference survives slash selection', await read('__qa.state.userContent.some(v=>v.type==="attachment"&&v.id==="fixture-file")'))
await setup({ text: '', skills: [] }); await win.webContents.insertText('/'); await wait('!!document.querySelector(".yc-composer-slash-picker")'); await read('document.querySelector(".yc-capability-picker input").focus()'); await key('Escape')
await pause(250); check('Escape closes the menu until the trigger is edited', await read('!!document.querySelector(".yc-composer-slash-picker")'), false)
await win.webContents.insertText('a'); await wait('!!document.querySelector(".yc-composer-slash-picker")')
check('editing the dismissed trigger reopens the menu', true)
await setup({ text: '', skills: [] })
await read('document.querySelector("textarea").dispatchEvent(new CompositionEvent("compositionstart",{bubbles:true}))')
await win.webContents.insertText('/'); await pause(150)
check('IME composition does not open a slash menu', await read('!!document.querySelector(".yc-composer-slash-picker")'), false)
await read('document.querySelector("textarea").dispatchEvent(new CompositionEvent("compositionend",{bubbles:true}))'); await wait('!!document.querySelector(".yc-composer-slash-picker")')
check('composition completion allows slash detection', true)
check('no renderer errors', errors, [])
fs.writeFileSync(path.join(directory, 'report.json'), JSON.stringify({ result: 'PASS', checks, errors }, null, 2)); console.log(JSON.stringify({ result: 'PASS', checks: checks.length, directory })); app.exit(0)
} catch (error) {
fs.writeFileSync(path.join(directory, 'failure.json'), JSON.stringify({ error: String(error.stack ?? error), checks, errors, state: await read('__qa.state'), focus: await read('({active:document.activeElement.outerHTML,selection:document.getSelection()?.toString(),anchor:document.getSelection()?.anchorNode?.parentElement?.outerHTML})'), html: await read('document.body.innerHTML') }, null, 2)); console.error(error); app.exit(1)
}
})().catch(error => { console.error(error); electron.app.exit(1) })
}