151 lines
14 KiB
JavaScript
151 lines
14 KiB
JavaScript
// Isolated YCode renderer behavior checks. The bridge is a deterministic local fixture.
|
|
const path = require('node:path')
|
|
const fs = require('node:fs/promises')
|
|
const assert = require('node:assert/strict')
|
|
const directory = path.resolve('.tmp/workspace-ui-regression')
|
|
if (!process.versions.electron) {
|
|
;(async () => {
|
|
await fs.mkdir(directory, { recursive: true })
|
|
const entry = `
|
|
import React from 'react';import {createRoot} from 'react-dom/client';
|
|
import Sidebar from './src/renderer/src/components/Sidebar';
|
|
import {useSettingsStore as settings} from './src/renderer/src/stores/settings';
|
|
import {useChatStore as chat} from './src/renderer/src/stores/chat';
|
|
import './src/renderer/src/workbuddy/typography/tokens.css';
|
|
import './src/renderer/src/styles/tokens.css';import './src/renderer/src/styles/app.css';import './src/renderer/src/styles/sidebar.css';
|
|
const initial=[{id:'a',name:'项目A',root:'C:/fixture/A',createdAt:1},{id:'b',name:'项目B',root:'C:/fixture/B',createdAt:2}];
|
|
let backend={workspaces:initial,selectedWorkspaceId:'a'};
|
|
const calls={generate:[],attach:0,select:[],rename:[],remove:[],open:[],createSession:0,fail:false};
|
|
window.ycode={workspace:{
|
|
generate:async(name)=>{calls.generate.push(name);await new Promise(r=>setTimeout(r,25));if(calls.fail)throw Error('Fixture: directory creation failed');backend={workspaces:[...backend.workspaces,{id:'generated',name,root:'C:/fixture/'+name,createdAt:3}],selectedWorkspaceId:'generated'};return backend},
|
|
attach:async()=>{calls.attach++;return backend},
|
|
select:async(id)=>{calls.select.push(id);backend={...backend,selectedWorkspaceId:id};return backend},
|
|
rename:async(id,name)=>{calls.rename.push({id,name});backend={...backend,workspaces:backend.workspaces.map(s=>s.id===id?{...s,name}:s)};return backend},
|
|
remove:async(id)=>{calls.remove.push(id);backend={workspaces:backend.workspaces.filter(s=>s.id!==id),selectedWorkspaceId:null};return backend},
|
|
open:async(id)=>{calls.open.push(id)}
|
|
},session:{create:async()=>{calls.createSession++;throw Error('Sidebar must not persist an empty session')},messages:async()=>[],remove:async()=>{}}};
|
|
const sessions=[...Array.from({length:7},(_,i)=>({id:'a'+i,title:'任务'+i,workspaceId:'a',cwd:'C:/fixture/A',createdAt:i,updatedAt:Date.now()})),
|
|
{id:'outside',title:'独立任务',createdAt:0,updatedAt:Date.now()},{id:'removed',title:'已移除空间的任务',workspaceId:'missing',createdAt:0,updatedAt:Date.now()}];
|
|
settings.setState({...backend,selectedModelId:'fixture-model'});chat.setState({sessions});
|
|
window.qa={settings,chat,calls};
|
|
createRoot(document.getElementById('root')).render(<Sidebar collapsed={false} onToggleCollapse={()=>{}}/>);
|
|
`
|
|
await require('esbuild').build({ stdin: { contents: entry, loader: 'tsx', resolveDir: process.cwd() }, outfile: path.join(directory, 'fixture.js'), bundle: true, platform: 'browser', format: 'iife', jsx: 'automatic', logLevel: 'silent' })
|
|
await fs.writeFile(path.join(directory, 'index.html'), '<!doctype html><html data-theme="light"><meta charset="utf-8"><link rel="stylesheet" href="fixture.css"><style>#root{width:280px;height:901px;display:flex}#root>.sidebar{width:100%;height:100%}</style><div id="root"></div><script src="fixture.js"></script></html>')
|
|
const env = { ...process.env }; delete env.ELECTRON_RUN_AS_NODE
|
|
const result = require('node:child_process').spawnSync(require('electron'), [__filename], { env, windowsHide: true, stdio: 'inherit', timeout: 60000 })
|
|
process.exitCode = result.status ?? 1
|
|
})().catch(error => { console.error(error); process.exitCode = 1 })
|
|
} else {
|
|
const { app, BrowserWindow, nativeImage } = require('electron')
|
|
app.setPath('userData', path.join(directory, 'user-data'))
|
|
app.disableHardwareAcceleration()
|
|
let win
|
|
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
const read = code => win.webContents.executeJavaScript(code)
|
|
async function until(code) { for (let index = 0; index < 150; index++) { if (await read(code)) return; await pause(20) } throw Error('Missing state: ' + code) }
|
|
async function click(selector) { await read(`document.querySelector(${JSON.stringify(selector)}).click()`); await pause(30) }
|
|
async function type(value, selector = '.create-workspace-modal__input input') {
|
|
await read(`(()=>{const input=document.querySelector(${JSON.stringify(selector)});Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(input,${JSON.stringify(value)});input.dispatchEvent(new Event('input',{bubbles:true}))})()`)
|
|
await pause(20)
|
|
}
|
|
const openCreate = () => click('.conversation-section-add-btn')
|
|
app.whenReady().then(async () => {
|
|
win = new BrowserWindow({ width: 1336, height: 901, useContentSize: true, show: false, webPreferences: { contextIsolation: false, nodeIntegration: false, offscreen: true } })
|
|
await win.loadFile(path.join(directory, 'index.html'))
|
|
await until('!!document.querySelector(".conversation-section-add-btn")')
|
|
assert.equal(await read('document.querySelector(".conversation-section-label-tasks").textContent.trim()'), '任务 (2)')
|
|
assert.equal(await read('document.querySelectorAll("[data-workspace-id=a] [data-session-id]").length'), 5)
|
|
assert.equal(await read('document.querySelectorAll("[data-workspace-id=b] [data-session-id]").length'), 0)
|
|
await click('[data-workspace-id=a] .conversation-show-more-button')
|
|
assert.equal(await read('document.querySelectorAll("[data-workspace-id=a] [data-session-id]").length'), 7)
|
|
await click('.conversation-section-compact-btn')
|
|
assert.equal(await read('[...document.querySelectorAll(".workspace-group-header")].every(e=>e.getAttribute("aria-expanded")==="false")'), true)
|
|
await win.reload(); await until('!!document.querySelector(".conversation-section-add-btn")')
|
|
assert.equal(await read('[...document.querySelectorAll(".workspace-group-header")].every(e=>e.getAttribute("aria-expanded")==="false")'), true, 'collapsed state persists across page reload')
|
|
await click('.conversation-section-compact-btn')
|
|
await openCreate()
|
|
assert.equal(await read('document.activeElement.getAttribute("aria-label")'), '工作空间名称')
|
|
assert.equal(await read('document.querySelector(".create-workspace-modal__confirm").disabled'), true)
|
|
await pause(250)
|
|
const emptyImage = await win.webContents.capturePage()
|
|
await fs.writeFile(path.join(directory, 'create-dialog-empty.png'), emptyImage.toPNG())
|
|
let pixels = null
|
|
const sourcePath = path.resolve('.tmp/rea-assessment-create-space/frame-000.png')
|
|
const sourceBytes = await fs.readFile(sourcePath).catch(() => null)
|
|
if (sourceBytes) {
|
|
assert.equal(require('node:crypto').createHash('sha256').update(sourceBytes).digest('hex'), '1df565bcbe19fb98b522f0346c1159caef338a1ce65c921baf8600bf27fb27ee', 'fixed REA reference changed')
|
|
const reference = nativeImage.createFromBuffer(sourceBytes)
|
|
assert.deepEqual(emptyImage.getSize(), reference.getSize())
|
|
const source = reference.toBitmap(), candidate = emptyImage.toBitmap(), width = reference.getSize().width
|
|
const region = { x: 453, y: 350, width: 430, height: 203 }
|
|
const caretMask = { x: 462, y: 458, width: 1, height: 19 }
|
|
let compared = 0, changed = 0, maximumDelta = 0
|
|
for (let y = region.y; y < region.y + region.height; y++) for (let x = region.x; x < region.x + region.width; x++) {
|
|
if (x === caretMask.x && y >= caretMask.y && y < caretMask.y + caretMask.height) continue
|
|
const offset = (y * width + x) * 4
|
|
let delta = 0
|
|
for (let channel = 0; channel < 4; channel++) delta = Math.max(delta, Math.abs(source[offset + channel] - candidate[offset + channel]))
|
|
compared++; if (delta) changed++; maximumDelta = Math.max(maximumDelta, delta)
|
|
}
|
|
pixels = { sourceEvidenceId: 'ev_1e096ecf360249286260ceef24f1195919b8f1cbae8dd81751a2e36eb631bab2', sourcePath, region, caretMask, compared, changed, maximumDelta,
|
|
scope: 'Create dialog interior only; excludes outer shadow/background, other states and the blinking caret.' }
|
|
assert.equal(changed, 0, 'source create-dialog interior must match the pinned REA pixels exactly, except blinking caret')
|
|
}
|
|
await type(' ')
|
|
assert.equal(await read('document.querySelector(".create-workspace-modal__confirm").disabled'), true)
|
|
await type(' 测试空间 ')
|
|
await read(`document.querySelector('.create-workspace-modal__input input').dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',keyCode:229,isComposing:true,bubbles:true}))`)
|
|
assert.equal(await read('window.qa.calls.generate.length'), 0, 'IME Enter does not submit')
|
|
const metrics = await read(`(()=>{const m=document.querySelector('.create-workspace-modal');return{bounds:m.getBoundingClientRect().toJSON(),radius:getComputedStyle(m).borderRadius,padding:getComputedStyle(m).padding,inputHeight:getComputedStyle(document.querySelector('.create-workspace-modal__input')).height}})()`)
|
|
assert.equal(metrics.bounds.width, 480)
|
|
assert.equal(metrics.radius, '24px')
|
|
assert.equal(metrics.padding, '24px')
|
|
assert.equal(metrics.inputHeight, '32px')
|
|
await pause(250)
|
|
await fs.writeFile(path.join(directory, 'create-dialog.png'), (await win.webContents.capturePage()).toPNG())
|
|
await read(`(()=>{const b=document.querySelector('.create-workspace-modal__confirm');b.click();b.click()})()`)
|
|
await until('!document.querySelector(".create-workspace-modal")')
|
|
assert.deepEqual(await read('window.qa.calls.generate'), ['测试空间'], 'rapid repeated clicks create one trimmed workspace')
|
|
assert.equal(await read('window.qa.calls.createSession'), 0, 'creating a workspace does not create a conversation')
|
|
assert.equal(await read('!!document.querySelector("[data-workspace-id=generated]")'), true)
|
|
await click('[data-workspace-id=generated] .workspace-new-task-button')
|
|
assert.deepEqual(await read('window.qa.chat.getState().draftContext'), { workspaceId: 'generated', cwd: 'C:/fixture/测试空间', modelId: 'fixture-model' })
|
|
assert.equal(await read('window.qa.calls.createSession'), 0, 'space new task remains a draft until first send')
|
|
await openCreate(); await type('未保存'); await read('document.dispatchEvent(new KeyboardEvent("keydown",{key:"Escape",bubbles:true}))')
|
|
await until('!document.querySelector(".create-workspace-modal")')
|
|
assert.equal(await read('window.qa.calls.generate.length'), 1, 'Escape cancels without creating')
|
|
await openCreate(); await read('window.qa.calls.fail=true'); await type('失败空间'); await click('.create-workspace-modal__confirm')
|
|
await until('!!document.querySelector(".workspace-dialog-error")')
|
|
assert.equal(await read('document.querySelector(".create-workspace-modal__input input").value'), '失败空间')
|
|
assert.equal(await read('document.querySelector(".create-workspace-modal__confirm").disabled'), false, 'creation failure keeps an editable retry')
|
|
await click('.create-workspace-modal__cancel')
|
|
assert.equal(await read('!!document.querySelector(".workspace-attach-row:not(.conversation-show-more-button)")'), false, 'existing-folder picker belongs in the home picker, not an extra sidebar row')
|
|
await read('document.querySelector("[data-workspace-id=a] .workspace-group-header").dispatchEvent(new MouseEvent("contextmenu",{clientX:1335,clientY:900,bubbles:true,cancelable:true}))')
|
|
await until('!!document.querySelector(".workspace-more-popover")')
|
|
assert.equal(await read('(()=>{const r=document.querySelector(".workspace-more-popover").getBoundingClientRect();return r.left>=4&&r.right<=innerWidth-4&&r.top>=4&&r.bottom<=innerHeight-4})()'), true, 'context menus stay inside screen edges')
|
|
await read('document.dispatchEvent(new KeyboardEvent("keydown",{key:"Escape",bubbles:true}))')
|
|
await until('!document.querySelector(".workspace-more-popover")')
|
|
await click('[data-workspace-id=a] .workspace-more-button')
|
|
await click('.workspace-more-popover__item')
|
|
assert.deepEqual(await read('window.qa.calls.open'), ['a'])
|
|
await click('[data-workspace-id=a] .workspace-more-button')
|
|
await click('.workspace-more-popover li:nth-child(2) button')
|
|
const longName = '很长的中文空间名称'.repeat(10)
|
|
await type(longName); await click('.create-workspace-modal__confirm')
|
|
await until('!document.querySelector(".create-workspace-modal")')
|
|
assert.equal(await read('window.qa.settings.getState().workspaces.find(s=>s.id==="a").root'), 'C:/fixture/A', 'rename changes only display name')
|
|
assert.equal(await read('(()=>{const e=document.querySelector("[data-workspace-id=a] .collapsible-section-label");return e.scrollWidth>e.clientWidth})()'), true, 'long workspace names ellipsize')
|
|
await click('[data-workspace-id=a] .workspace-more-button'); await click('.workspace-more-popover li:nth-child(3) button')
|
|
assert.match(await read('document.querySelector(".create-workspace-modal__desc").textContent'), /7 个任务/)
|
|
await click('.create-workspace-modal__confirm'); await until('!document.querySelector(".create-workspace-modal")')
|
|
assert.equal(await read('!!document.querySelector("[data-workspace-id=a]")'), false)
|
|
assert.equal(await read('document.querySelector(".conversation-section-label-tasks").textContent.trim()'), '任务 (9)', 'removal retains all conversations as independent tasks')
|
|
assert.deepEqual(await read('window.qa.calls.remove'), ['a'])
|
|
const report = { result: 'PASS workspace UI', checks: ['source dimensions', 'real store bridge calls', 'group counts', '5-item show-more', 'collapse persistence', 'trim and duplicate-submit guard', 'IME', 'cancel', 'error retry', 'first-send draft boundary', 'open/rename/remove menus', 'long names', 'task retention'], createDialogMetrics: metrics, pixels }
|
|
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
|
|
console.log(JSON.stringify(report))
|
|
app.exit(0)
|
|
}).catch(error => { console.error(error); app.exit(1) })
|
|
}
|