feat: complete WorkBuddy parity and Windows release workflow
This commit is contained in:
1 parent
143516b3a8
commit
d5512df549
434 files changed
+54977
-1854
No files matched your search
@@ -37,7 +37,7 @@ if (!process.versions.electron) {
|
||||
})
|
||||
})().catch(error => { console.error(error); process.exitCode = 1 })
|
||||
} else {
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const { app, BrowserWindow, ipcMain } = require('electron')
|
||||
const { createServer } = require('node:http')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
const directory = process.env.YCODE_QA_DIRECTORY
|
||||
@@ -47,6 +47,9 @@ if (!process.versions.electron) {
|
||||
const checks = []
|
||||
const snapshots = {}
|
||||
const rendererErrors = []
|
||||
const preStartFailureMarker = 'QA_PRE_START_FAILURE_20261017'
|
||||
let controlledFailureCalls = 0
|
||||
let controlledFailureSessionId
|
||||
let main, server, phase = 'boot'
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
const read = (source, win = main) => Promise.race([win.webContents.executeJavaScript(source),
|
||||
@@ -66,6 +69,15 @@ if (!process.versions.electron) {
|
||||
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(input,${JSON.stringify(value)});
|
||||
input.dispatchEvent(new Event('input',{bubbles:true}));return true})()`)
|
||||
}
|
||||
async function dropDraftFixtures(attachmentName, attachmentText, referencePath) {
|
||||
await read(`(()=>{const composer=document.querySelector('.wb-composer');
|
||||
if(!composer)throw new Error('Missing composer');
|
||||
const transfer=new DataTransfer();
|
||||
transfer.items.add(new File([${JSON.stringify(attachmentText)}],${JSON.stringify(attachmentName)},{type:'text/plain'}));
|
||||
transfer.setData('text/uri-list',${JSON.stringify(referencePath)});
|
||||
composer.dispatchEvent(new DragEvent('drop',{bubbles:true,cancelable:true,dataTransfer:transfer}));
|
||||
return true})()`)
|
||||
}
|
||||
const within = limit => value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value < limit
|
||||
|
||||
;(async () => {
|
||||
@@ -75,7 +87,7 @@ if (!process.versions.electron) {
|
||||
for await (const part of req) raw += part
|
||||
const body = JSON.parse(raw || '{}')
|
||||
// 「停止验收」场景需要一个更长的回合,方便在运行中切走/切回再停止。
|
||||
await pause(JSON.stringify(body).includes('停止验收') ? 5000 : 600)
|
||||
await pause(JSON.stringify(body).includes('停止验收') ? 15000 : 600)
|
||||
const content = `LOCAL_REPLY_${body.model}`
|
||||
if (!body.stream) { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content } }] })); return }
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' })
|
||||
@@ -90,7 +102,29 @@ if (!process.versions.electron) {
|
||||
await fs.mkdir(process.env.YCODE_USER_DATA_DIR, { recursive: true })
|
||||
app.setPath('userData', process.env.YCODE_USER_DATA_DIR)
|
||||
app.setPath('sessionData', process.env.YCODE_USER_DATA_DIR)
|
||||
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
|
||||
// Register every production handler normally, but let this scenario reject one
|
||||
// chat:send after the real renderer has already created its session. The
|
||||
// wrapper runs before production startup, so no turn or persisted message exists.
|
||||
const originalIpcHandle = ipcMain.handle
|
||||
ipcMain.handle = function handleWithControlledChatFailure(channel, listener) {
|
||||
if (channel !== 'chat:send') return originalIpcHandle.call(ipcMain, channel, listener)
|
||||
return originalIpcHandle.call(ipcMain, channel, async (event, payload) => {
|
||||
if (controlledFailureCalls === 0 && typeof payload?.text === 'string'
|
||||
&& payload.text.includes(preStartFailureMarker)) {
|
||||
controlledFailureCalls += 1
|
||||
controlledFailureSessionId = payload.sessionId
|
||||
throw new Error('QA controlled chat:send failure before turn startup')
|
||||
}
|
||||
return listener(event, payload)
|
||||
})
|
||||
}
|
||||
try {
|
||||
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
|
||||
// Production registers IPC handlers inside app.whenReady(), after module evaluation.
|
||||
await app.whenReady()
|
||||
} finally {
|
||||
ipcMain.handle = originalIpcHandle
|
||||
}
|
||||
await app.whenReady()
|
||||
for (let index = 0; index < 120; index++) {
|
||||
main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode')
|
||||
@@ -172,6 +206,93 @@ if (!process.versions.electron) {
|
||||
prepareClearedMs: finalMarks.prepareCleared === undefined ? null : finalMarks.prepareCleared - t0,
|
||||
replyMs: finalMarks.reply === undefined ? null : finalMarks.reply - t0 }
|
||||
|
||||
await step('create-success-send-failure')
|
||||
const originalSessionSnapshot = {
|
||||
sessions: await read('window.ycode.session.list()'),
|
||||
messages: await read(`window.ycode.session.messages(${JSON.stringify(sessionId)})`)
|
||||
}
|
||||
const startFailureWorkspace = await read('window.ycode.workspace.get()')
|
||||
await fs.mkdir(startFailureWorkspace.root, { recursive: true })
|
||||
const startFailureReferencePath = path.join(startFailureWorkspace.root, 'qa-start-failure-reference.txt')
|
||||
await fs.writeFile(startFailureReferencePath, 'WORKSPACE_REFERENCE_MUST_SURVIVE_CREATE_SUCCESS_SEND_FAILURE\n')
|
||||
|
||||
await read(`document.querySelector('[title="新建任务"]').click()`)
|
||||
await wait(`!!document.querySelector('.wb-home-composer')`, 'home surface for create-success send failure')
|
||||
const startFailureText = `${preStartFailureMarker} 正文草稿必须保留`
|
||||
const startFailureAttachmentName = 'qa-start-failure-attachment.txt'
|
||||
await dropDraftFixtures(startFailureAttachmentName, 'ATTACHMENT_MUST_SURVIVE', startFailureReferencePath)
|
||||
await wait(`(()=>{const names=[...document.querySelectorAll('.wb-composer__attachment-name')].map(node=>node.textContent);
|
||||
const refs=[...document.querySelectorAll('.wb-composer__resources .wb-composer__resource')].map(node=>node.textContent);
|
||||
return names.includes(${JSON.stringify(startFailureAttachmentName)})&&refs.some(text=>text.includes('qa-start-failure-reference.txt'))})()`,
|
||||
'attachment and workspace reference staged')
|
||||
await fill(startFailureText)
|
||||
await wait(`[...document.querySelectorAll('.wb-composer__input')].some(node=>node.offsetParent!==null&&node.value===${JSON.stringify(startFailureText)})`,
|
||||
'durable draft text entered')
|
||||
await read(`document.querySelector('button[aria-label="发送"]').click()`)
|
||||
await wait(`!!(document.querySelector('.wb-composer__error[role="alert"]')||document.querySelector('.msg__status--error'))`,
|
||||
'controlled pre-start failure surfaced')
|
||||
await wait(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null);
|
||||
const names=[...document.querySelectorAll('.wb-composer__attachment-name')].map(node=>node.textContent);
|
||||
const refs=[...document.querySelectorAll('.wb-composer__resources .wb-composer__resource')].map(node=>node.textContent);
|
||||
return input?.value===${JSON.stringify(startFailureText)}
|
||||
&&names.includes(${JSON.stringify(startFailureAttachmentName)})
|
||||
&&refs.some(text=>text.includes('qa-start-failure-reference.txt'))})()`,
|
||||
'durable draft restored after controlled failure')
|
||||
await pause(250)
|
||||
const sessionsAfterControlledFailure = await read('window.ycode.session.list()')
|
||||
snapshots.createSuccessSendFailure = await read(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null);
|
||||
const picker=document.querySelector('.cr-workspace-picker');
|
||||
const composerError=document.querySelector('.wb-composer__error');const statusError=document.querySelector('.msg__status--error');
|
||||
return {inputValue:input?.value??null,home:!!document.querySelector('.wb-home-composer'),
|
||||
attachments:[...document.querySelectorAll('.wb-composer__attachment-name')].map(node=>node.textContent),
|
||||
resources:[...document.querySelectorAll('.wb-composer__resources .wb-composer__resource')].map(node=>node.textContent),
|
||||
workspaceEnabled:!!picker&&!picker.disabled&&picker.getAttribute('aria-disabled')!=='true',
|
||||
error:composerError?.textContent??statusError?.textContent??null}})()`)
|
||||
check('chat:send failure consumed exactly one controlled request', controlledFailureCalls, 1)
|
||||
check('controlled failure happened after session creation', typeof controlledFailureSessionId === 'string' && controlledFailureSessionId.length > 0, true)
|
||||
check('empty failed session was atomically deleted', sessionsAfterControlledFailure.some(item => item.id === controlledFailureSessionId), false)
|
||||
check('existing completed session list remains unchanged', sessionsAfterControlledFailure, originalSessionSnapshot.sessions)
|
||||
check('existing completed session messages remain unchanged',
|
||||
await read(`window.ycode.session.messages(${JSON.stringify(sessionId)})`), originalSessionSnapshot.messages)
|
||||
check('controlled failure returns to home', snapshots.createSuccessSendFailure.home, true)
|
||||
check('controlled failure preserves body draft', snapshots.createSuccessSendFailure.inputValue, startFailureText)
|
||||
check('controlled failure preserves attachment draft',
|
||||
snapshots.createSuccessSendFailure.attachments.includes(startFailureAttachmentName), true)
|
||||
check('controlled failure preserves workspace reference draft',
|
||||
snapshots.createSuccessSendFailure.resources.some(text => text.includes('qa-start-failure-reference.txt')), true)
|
||||
check('controlled failure returns with an enabled workspace picker', snapshots.createSuccessSendFailure.workspaceEnabled, true)
|
||||
check('controlled failure error is visible',
|
||||
snapshots.createSuccessSendFailure.error, value => typeof value === 'string' && value.includes('QA controlled chat:send failure'))
|
||||
|
||||
await read(`(()=>{document.querySelector('.cr-workspace-picker')?.click();return true})()`)
|
||||
await wait(`!!document.querySelector('.source-workspace-picker-portal [role="menu"]')`, 'recovered workspace menu opens')
|
||||
const recoveredWorkspaceMenu = await read(`(()=>{const menu=document.querySelector('.source-workspace-picker-portal [role="menu"]');
|
||||
return {options:menu?menu.querySelectorAll('[data-workspace-option]').length:0,
|
||||
labels:menu?[...menu.querySelectorAll('[data-workspace-option]')].map(node=>node.textContent.trim()):[]}})()`)
|
||||
check('recovered new-task workspace menu is actionable', recoveredWorkspaceMenu.options >= 2, true)
|
||||
await read(`(()=>{document.querySelector('.cr-workspace-picker')?.click();return true})()`)
|
||||
await wait(`!document.querySelector('.source-workspace-picker-portal [role="menu"]')`, 'workspace menu closes')
|
||||
|
||||
await read(`document.querySelector('button[aria-label="发送"]').click()`)
|
||||
await wait(`window.ycode.session.list().then(list=>list.length===2)`, 'retry creates a real session')
|
||||
const sessionsAfterRetry = await read('window.ycode.session.list()')
|
||||
const retrySessionId = sessionsAfterRetry.find(item => item.id !== sessionId)?.id
|
||||
check('retry creates one replacement session', typeof retrySessionId === 'string' && !!retrySessionId, true)
|
||||
const retryDone = await wait(`window.ycode.session.messages(${JSON.stringify(retrySessionId)}).then(list=>{const last=list.at(-1);return last&&last.status==='done'&&last})`,
|
||||
'retry turn finishes')
|
||||
const retryMessages = await read(`window.ycode.session.messages(${JSON.stringify(retrySessionId)})`)
|
||||
const retryUser = retryMessages.find(item => item.role === 'user' && item.content?.includes(preStartFailureMarker))
|
||||
snapshots.createSuccessSendFailureRetry = { sessionId: retrySessionId, assistant: retryDone, user: retryUser }
|
||||
check('retry keeps the body draft', retryUser?.content, startFailureText)
|
||||
check('retry commits the preserved attachment',
|
||||
retryUser?.attachments?.some(item => item.name === startFailureAttachmentName), true)
|
||||
check('retry commits the preserved workspace reference',
|
||||
retryUser?.resources?.some(item => item.endsWith('qa-start-failure-reference.txt')), true)
|
||||
check('retry reply is rendered', retryDone.content, 'LOCAL_REPLY_model-one')
|
||||
check('existing completed session list still unchanged after retry',
|
||||
await read('window.ycode.session.list()').then(list => list.find(item => item.id === sessionId)),
|
||||
originalSessionSnapshot.sessions.find(item => item.id === sessionId))
|
||||
|
||||
await step('send-failure')
|
||||
// 清空模型注册表:发送会在 chat:send 的模型解析阶段被拒绝(早于任何回合/网络请求),
|
||||
// 这是「发送失败」路径;同时避免误连外部网关。
|
||||
@@ -226,7 +347,37 @@ if (!process.versions.electron) {
|
||||
snapshots.switchedBack = await read(`(()=>({user:[...document.querySelectorAll('.cr-self-bubble')].some(node=>node.textContent.includes(${JSON.stringify(stopMessage)})),
|
||||
streaming:!!document.querySelector('.wb-cr-streaming-footer'),stop:!!document.querySelector('button[aria-label="停止"]')}))()`)
|
||||
check('switching tasks mid-turn keeps the message and the running turn', snapshots.switchedBack.user && snapshots.switchedBack.streaming && snapshots.switchedBack.stop, true)
|
||||
await read(`document.querySelector('button[aria-label="停止"]').click()`)
|
||||
|
||||
await step('workbench-escape-precedence')
|
||||
await read(`window.dispatchEvent(new CustomEvent('ycode:open-artifacts',{detail:{sessionId:${JSON.stringify(stopSessionId)}}}))`)
|
||||
await wait(`!!document.querySelector('.yc-workbench')`, 'right workbench opens during generation')
|
||||
await read(`(()=>{const button=document.querySelector('.yc-workbench button[aria-label="全屏"]');
|
||||
if(!button)throw new Error('Missing fullscreen button');button.click();return true})()`)
|
||||
await wait(`document.querySelector('.yc-workbench')?.classList.contains('yc-workbench--fullscreen')===true`,
|
||||
'right workbench enters fullscreen')
|
||||
const escapeFocusInsideWorkbench = await read(`(()=>{const button=document.querySelector('.yc-workbench button[aria-label="退出全屏"]');
|
||||
button?.focus();return !!button&&document.activeElement===button&&!!document.activeElement.closest('.yc-workbench')})()`)
|
||||
check('fullscreen Escape starts with focus inside the workbench', escapeFocusInsideWorkbench, true)
|
||||
main.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' })
|
||||
main.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' })
|
||||
await wait(`document.querySelector('.yc-workbench')?.classList.contains('yc-workbench--fullscreen')===false`,
|
||||
'fullscreen Escape exits fullscreen')
|
||||
await pause(250)
|
||||
const statusAfterFullscreenEscape = await read(`window.ycode.session.messages(${JSON.stringify(stopSessionId)}).then(list=>list.at(-1)?.status)`)
|
||||
snapshots.fullscreenEscape = {
|
||||
fullscreen: await read(`document.querySelector('.yc-workbench')?.classList.contains('yc-workbench--fullscreen')===true`),
|
||||
assistantStatus: statusAfterFullscreenEscape,
|
||||
stopVisible: await read(`!!document.querySelector('button[aria-label="停止"]')`)
|
||||
}
|
||||
check('fullscreen Escape does not cancel the running turn',
|
||||
snapshots.fullscreenEscape.fullscreen === false && snapshots.fullscreenEscape.assistantStatus === 'streaming'
|
||||
&& snapshots.fullscreenEscape.stopVisible === true, true)
|
||||
|
||||
const normalEscapeFocusInsideWorkbench = await read(`(()=>{const button=document.querySelector('.yc-workbench button[aria-label="收起面板"]');
|
||||
button?.focus();return !!button&&document.activeElement===button&&!!document.activeElement.closest('.yc-workbench')})()`)
|
||||
check('non-fullscreen Escape starts with focus inside the workbench', normalEscapeFocusInsideWorkbench, true)
|
||||
main.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' })
|
||||
main.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' })
|
||||
await wait(`window.ycode.session.messages(${JSON.stringify(stopSessionId)}).then(list=>list.at(-1)?.status==='cancelled')`, 'turn cancelled')
|
||||
await pause(250)
|
||||
snapshots.stopped = await read(`(()=>({userBubbles:[...document.querySelectorAll('.cr-self-bubble')].map(node=>node.textContent.trim()),
|
||||
|
||||
Reference in new issue
Block a user