Files
ycode/scripts/test-skill-picker-embedded.mjs
T

72 lines
16 KiB
JavaScript

/** Native component fixture with a controlled skills host; never accesses WorkBuddy or user data. */
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'
import {createHash} from 'node:crypto'
const require=createRequire(import.meta.url),electronModule=require('electron'),electron=typeof electronModule==='object'&&electronModule.app?electronModule:null
const root=path.resolve('.'),base=path.join(root,'.tmp')
if(!electron){
const output=fs.mkdtempSync(path.join(base,'skill-picker-embedded-')),env={...process.env};delete env.ELECTRON_RUN_AS_NODE;delete env.ELECTRON_RENDERER_URL
const child=spawn(electronModule,['--no-sandbox','--disable-gpu-sandbox',path.resolve('scripts/test-skill-picker-embedded.mjs'),output],{env,windowsHide:true,stdio:'inherit'})
const timer=setTimeout(()=>child.kill(),60000);process.exitCode=await new Promise(resolve=>child.once('exit',code=>{clearTimeout(timer);resolve(code??1)}))
}else void(async()=>{
const {app,BrowserWindow}=electron,output=path.resolve(process.argv.at(-1));assert.ok(path.relative(base,output).startsWith('skill-picker-embedded-')&&!path.relative(base,output).includes('..'))
fs.mkdirSync(path.join(output,'data'));app.setPath('userData',path.join(output,'data'));app.disableHardwareAcceleration();await app.whenReady()
const sourceFile=path.join(root,'src/renderer/src/capabilities/SourceSkillPicker.tsx'),sourceSha256=createHash('sha256').update(fs.readFileSync(sourceFile)).digest('hex')
fs.writeFileSync(path.join(output,'fixture.tsx'),`
import React,{useState}from'react';import{createRoot}from'react-dom/client';import Picker from ${JSON.stringify(sourceFile.replaceAll('\\','/'))};
import ${JSON.stringify(path.join(root,'src/renderer/src/styles/tokens.css').replaceAll('\\','/'))};import ${JSON.stringify(path.join(root,'src/renderer/src/styles/app.css').replaceAll('\\','/'))};
const w=window as any;const skill=(id,name,description)=>({id,name,description,path:'test://'+id,scope:'workspace',enabled:true,userInvocable:true,disableModelInvocation:false,source:'discovered',userModified:false,payload:'HOST_'+id});
const A='C:/scope-a',B='C:/scope-b',ERROR='C:/scope-error',PENDING='C:/scope-pending';
const data={[A]:[skill('alpha','Alpha','Latin技能'),skill('beta','Beta 中文设计','design skill')],[B]:[skill('gamma','Gamma','space B'),skill('delta','Delta','space B')]};
w.__skillQA={events:[],parentKeys:[],requests:[],pending:{},A,B,ERROR,PENDING,data};const q=w.__skillQA;
w.ycode={skills:{list:cwd=>{q.requests.push(cwd);if(cwd===ERROR)return Promise.reject(Error('CONTROLLED_SCOPE_ERROR'));if(cwd===PENDING)return new Promise((resolve,reject)=>q.pending[cwd]={resolve,reject});return Promise.resolve(data[cwd]||[])}}};
localStorage.setItem('ycode.skills.menu.v1:c:/scope-b',JSON.stringify({pinned:['delta'],recent:[]}));
function Fixture(){const[open,setOpen]=useState(false),[parent,setParent]=useState(true),[embedded,setEmbedded]=useState(true),[cwd,setCwd]=useState(A);Object.assign(q,{open:(mode=true,space=A)=>{setCwd(space);setEmbedded(mode);setParent(true);setOpen(true)},setCwd,parent,openState:open});const close=()=>{q.events.push(['close']);setOpen(false);setParent(false)};return <div className="chat-pane" style={{height:'100vh'}}><button id="outside" style={{position:'absolute',left:20,top:20}}>Outside</button><button id="anchor" style={{position:'absolute',left:400,top:650}}>Skills</button>{parent&&<div id="parent-menu" onKeyDown={event=>{q.parentKeys.push(event.key);if(event.key==='Escape'){event.preventDefault();event.stopPropagation();q.events.push(['parentEscape']);setOpen(false)}}}><div id="source-submenu" style={{position:'absolute',left:400,top:200,width:240}}>{open&&<Picker cwd={cwd} embedded={embedded} anchor={document.getElementById('anchor')} onClose={close} onSelect={item=>q.events.push(['select',item])}/>}</div></div>}</div>}
window.addEventListener('ycode:open-capabilities',(event:any)=>q.events.push(['manage',event.detail]));createRoot(document.getElementById('root')!).render(<Fixture/>);
`)
await require('esbuild').build({entryPoints:[path.join(output,'fixture.tsx')],bundle:true,platform:'browser',format:'iife',jsx:'automatic',outfile:path.join(output,'fixture.js'),logLevel:'warning'})
fs.writeFileSync(path.join(output,'index.html'),'<html data-theme="light"><head><meta charset="utf-8"><link rel="stylesheet" href="fixture.css"><style>html,body,#root{height:100%;margin:0}</style></head><body><div id="root"></div><script src="fixture.js"></script></body></html>')
const win=new BrowserWindow({width:1052,height:901,frame:false,useContentSize:true,show:false,webPreferences:{contextIsolation:true,nodeIntegration:false,backgroundThrottling:false}}),checks=[],errors=[]
win.webContents.on('console-message',event=>{if(event.level==='error'||event.level>=3)errors.push(event.message)})
const read=code=>win.webContents.executeJavaScript(code),pause=ms=>new Promise(resolve=>setTimeout(resolve,ms))
const check=(label,actual,expected=true)=>{assert.deepEqual(actual,expected,label);checks.push({label,pass:true})}
const wait=async code=>{for(let i=0;i<120;i++){if(await read(code))return;await pause(25)}throw Error('Timeout: '+code)}
const key=async code=>{const keyCode=code.replace('Arrow','');win.webContents.sendInputEvent({type:'keyDown',keyCode});if(code==='Enter')win.webContents.sendInputEvent({type:'char',keyCode:'\r'});win.webContents.sendInputEvent({type:'keyUp',keyCode});await pause(30)}
const click=async selector=>{const hover=await read(`(()=>{const e=document.querySelector(${JSON.stringify(selector)})?.closest('[data-skill-option]');if(!e)return null;const r=e.getBoundingClientRect();return{x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)}})()`);if(hover){win.webContents.sendInputEvent({type:'mouseMove',...hover});await pause(80);await wait(`document.querySelector(${JSON.stringify(selector)})?.getBoundingClientRect().width>0`)}const p=await read(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e)throw Error('Missing '+${JSON.stringify(selector)});const r=e.getBoundingClientRect();return{x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)}})()`);win.webContents.sendInputEvent({type:'mouseMove',...p});await pause(100);win.webContents.sendInputEvent({type:'mouseDown',...p,button:'left',clickCount:1});win.webContents.sendInputEvent({type:'mouseUp',...p,button:'left',clickCount:1});await pause(35)}
const query=async text=>{await click('.yc-skill-picker input');await read('document.querySelector(".yc-skill-picker input").select()');if(text)await win.webContents.insertText(text);else await key('Backspace');await pause(35)}
const open=async(mode=true,space='A')=>{await read(`__skillQA.open(${mode},__skillQA.${space})`);await wait('!!document.querySelector(".yc-skill-picker input")');await pause(50)}
const capture=async name=>fs.writeFileSync(path.join(output,name+'.png'),(await win.webContents.capturePage()).toPNG())
try{
await win.loadFile(path.join(output,'index.html'));win.showInactive();await wait('!!window.__skillQA');await open();await wait('document.querySelectorAll("[data-skill-option]").length===2')
check('embedded panel stays under source submenu DOM',await read('!!document.getElementById("source-submenu").querySelector(".yc-skill-picker")'))
check('embedded panel is not fixed or portal positioned',await read('getComputedStyle(document.querySelector(".yc-skill-picker [role=menu]")).position'),'static')
check('embedded search autofocus remains enabled',await read('document.activeElement.matches(".yc-skill-picker input")'))
await click('#outside');check('embedded does not close on outside pointer',await read('!!document.querySelector(".yc-skill-picker")'))
win.setContentSize(1000,901);await pause(40);check('embedded does not close on resize',await read('!!document.querySelector(".yc-skill-picker")'))
await query('ALPHA');check('Latin skill query remains functional',await read('document.querySelectorAll("[data-skill-option]").length'),1)
await read('document.querySelector(".yc-skill-picker input").setSelectionRange(3,3)');await key('Home');check('Home keeps native input caret control',await read('[document.activeElement.tagName,document.activeElement.selectionStart]'),['INPUT',0]);await key('End');check('End keeps native input caret control',await read('document.activeElement.selectionStart'),5)
await key('Escape');check('first Escape clears query while retaining parent/submenu',await read('[document.querySelector(".yc-skill-picker input")?.value,!!document.getElementById("parent-menu"),!!document.querySelector(".yc-skill-picker")]'),['',true,true]);check('query Escape never reached parent',await read('__skillQA.events.filter(e=>e[0]==="parentEscape").length'),0)
await key('Escape');check('empty Escape delegates one level to parent',await read('[!!document.getElementById("parent-menu"),!!document.querySelector(".yc-skill-picker"),__skillQA.events.filter(e=>e[0]==="close").length,__skillQA.events.filter(e=>e[0]==="parentEscape").length]'),[true,false,0,1])
await open();await read('document.querySelector(".yc-skill-picker input").dispatchEvent(new CompositionEvent("compositionstart",{bubbles:true}))');await key('ArrowDown');check('IME ArrowDown does not navigate skill rows',await read('document.activeElement.tagName'),'INPUT');await key('Enter');check('IME Enter does not select or close',await read('__skillQA.events.filter(e=>e[0]==="select"||e[0]==="close").length'),0);check('IME keys do not leak into parent keyboard handler',await read('__skillQA.parentKeys.includes("Enter")||__skillQA.parentKeys.includes("ArrowDown")'),false)
await read('document.querySelector(".yc-skill-picker input").dispatchEvent(new CompositionEvent("compositionend",{bubbles:true}))');await key('ArrowDown');check('normal ArrowDown focuses first actual skill',await read('document.activeElement.dataset.skillId'),'alpha')
await read('document.querySelector(".yc-skill-picker input").dispatchEvent(new CompositionEvent("compositionstart",{bubbles:true}))');await key('Enter');check('IME Enter on a focused row is guarded',await read('__skillQA.events.filter(e=>e[0]==="select").length'),0);await read('document.querySelector(".yc-skill-picker input").dispatchEvent(new CompositionEvent("compositionend",{bubbles:true}))')
await key('End');check('row End uses child navigation only',await read('document.activeElement.dataset.skillId'),'beta');await key('Home');await key('Enter');check('keyboard selection delegates actual host skill payload',await read('__skillQA.events.find(e=>e[0]==="select")[1].payload'),'HOST_alpha');check('selection invokes parent close callback',await read('!document.getElementById("parent-menu")'))
await open();await query('中文');check('Chinese query finds matching skill',await read('document.querySelector("[data-skill-option]").dataset.skillId'),'beta');await key('Escape');await click('[data-skill-id="beta"] button');check('pin does not select/close and persists only A',await read('[__skillQA.events.filter(e=>e[0]==="select").length,!!document.querySelector(".yc-skill-picker"),JSON.parse(localStorage.getItem("ycode.skills.menu.v1:c:/scope-a")).pinned]'),[1,true,['beta']]);await query('中文');await capture('embedded-query')
await read('__skillQA.setCwd(__skillQA.B)');await wait('document.querySelector("[data-skill-id=delta]")');check('cwd switch resets query and pin order from B memory',await read('[document.querySelector(".yc-skill-picker input").value,document.querySelector("[data-skill-option]").dataset.skillId]'),['','delta']);check('cwd switch does not copy A recents into B',await read('JSON.parse(localStorage.getItem("ycode.skills.menu.v1:c:/scope-b")).recent'),[])
await read('__skillQA.setCwd(__skillQA.ERROR)');await wait('!!document.querySelector(".yc-skill-picker [role=alert]")');check('load failure belongs to actual failing scope',await read('document.querySelector(".yc-skill-picker [role=alert]").textContent'),'CONTROLLED_SCOPE_ERROR');await read('__skillQA.setCwd(__skillQA.B)');await wait('document.querySelector("[data-skill-id=delta]")');check('new scope clears old error',await read('!document.querySelector(".yc-skill-picker [role=alert]")'))
await read('__skillQA.setCwd(__skillQA.PENDING)');await wait('!!__skillQA.pending[__skillQA.PENDING]');await read('__skillQA.setCwd(__skillQA.B)');await wait('document.querySelector("[data-skill-id=delta]")');await read('__skillQA.pending[__skillQA.PENDING].resolve(__skillQA.data[__skillQA.A])');await pause(50);check('late old-scope list cannot replace new space list',await read('[...document.querySelectorAll("[data-skill-option]")].map(e=>e.dataset.skillId)'),['delta','gamma'])
await read('__skillQA.setCwd(__skillQA.PENDING)');await wait('!!__skillQA.pending[__skillQA.PENDING]');await read('__skillQA.setCwd(__skillQA.B)');await wait('document.querySelector("[data-skill-id=delta]")');await read('__skillQA.pending[__skillQA.PENDING].reject(Error("STALE_SCOPE_ERROR"))');await pause(50);check('late old-scope error cannot poison current list',await read('!document.querySelector(".yc-skill-picker [role=alert]")&&!!document.querySelector("[data-skill-id=delta]")'))
await read('document.querySelector("[data-skill-id=delta] button").focus()');await key('Enter');check('keyboard pin button does not select its containing skill',await read('__skillQA.events.filter(e=>e[0]==="select").length'),1);check('keyboard pin button toggles B memory only',await read('[JSON.parse(localStorage.getItem("ycode.skills.menu.v1:c:/scope-b")).pinned,JSON.parse(localStorage.getItem("ycode.skills.menu.v1:c:/scope-a")).pinned]'),[[],['beta']])
await capture('embedded-scope-b');await click('._actionRow_1mrhu_231');check('manage delegates real capabilities navigation event',await read('__skillQA.events.find(e=>e[0]==="manage")[1]'),{tab:'skills'});check('manage invokes parent close',await read('!document.getElementById("parent-menu")'))
await open(false);await wait('document.querySelectorAll("[data-skill-option]").length===2');check('standalone keeps body/chat-pane portal behavior',await read('!document.getElementById("source-submenu").contains(document.querySelector(".yc-skill-picker"))'));check('standalone keeps fixed placement and 320px width',await read('(()=>{const e=document.querySelector(".yc-skill-picker [role=menu]"),s=getComputedStyle(e);return[s.position,e.getBoundingClientRect().width]})()'),['fixed',320]);await capture('standalone')
await query('Alpha');await key('Escape');check('standalone nonempty Escape clears search first',await read('document.querySelector(".yc-skill-picker input")?.value'),'');await key('Escape');check('standalone empty Escape closes its menu',await read('!document.querySelector(".yc-skill-picker")'))
await open(false);await click('#outside');check('standalone outside pointer still closes',await read('!document.querySelector(".yc-skill-picker")'));await open(false);win.setContentSize(1052,901);await pause(50);check('standalone resize still closes',await read('!document.querySelector(".yc-skill-picker")'))
await open(false);await read('window.__skillOriginalSetItem=Storage.prototype.setItem;Storage.prototype.setItem=function(){throw Error("CONTROLLED_MEMORY_STORAGE_UNAVAILABLE")};true');await click('[data-skill-id="alpha"]');await read('Storage.prototype.setItem=window.__skillOriginalSetItem;true');check('standalone skill addition survives unavailable optional storage',await read('__skillQA.events.filter(e=>e[0]==="select").at(-1)[1].id'),'alpha');check('standalone select still closes',await read('!document.querySelector(".yc-skill-picker")'));check('no renderer exceptions',errors,[])
check('source unchanged during fixture',createHash('sha256').update(fs.readFileSync(sourceFile)).digest('hex'),sourceSha256)
fs.writeFileSync(path.join(output,'report.json'),JSON.stringify({result:'PASS',checks,errors,sourceSha256,limitations:'Actual Electron component + controlled skills host and parent menu. Full production composer integration and WorkBuddy pixel comparison are outside this focused fixture.'},null,2));console.log(JSON.stringify({result:'PASS',checks:checks.length,output}));win.destroy();app.exit(0)
}catch(error){await capture('failure');fs.writeFileSync(path.join(output,'report.json'),JSON.stringify({result:'FAIL',error:error.stack,checks,errors,html:await read('document.body.innerHTML')},null,2));console.error(JSON.stringify({result:'FAIL',error:String(error),output}));win.destroy();app.exit(1)}
})().catch(error=>{console.error(error);electron.app.exit(1)})