212 lines
18 KiB
JavaScript
212 lines
18 KiB
JavaScript
// 真实隔离 YCode App + 真实 preload/IPC/渲染进程,验证表格“添加到对话”与引用标签。
|
||
// 原版期望值来自固定 REA 归档:ev_38cca1ac66606ab07fb495035e3509f5c4d8ec3e5e2b74d0fdcfcfadb120da3b(菜单/按钮几何)
|
||
// 与 src-CyXHA0IC.css(SHA-256 a5e26b537bb50323aefbf25abddad52e288dd3868598be8d255d70886fe77cfe,菜单/标签样式)。
|
||
const fs = require('node:fs/promises')
|
||
const path = require('node:path')
|
||
const assert = require('node:assert/strict')
|
||
|
||
const TABLE_MARKDOWN = [
|
||
'| 项目 | 说明 |',
|
||
'| --- | --- |',
|
||
'| alpha | 第一行 |',
|
||
'| beta | 第二行 |'
|
||
].join('\n')
|
||
|
||
if (!process.versions.electron) {
|
||
;(async () => {
|
||
const directory = await fs.mkdtemp(path.resolve('.tmp/quote-table-'))
|
||
const data = path.join(directory, 'data')
|
||
const home = path.join(directory, 'home')
|
||
const cwd = path.join(directory, 'fixture-workspace')
|
||
await fs.mkdir(data); await fs.mkdir(home); await fs.mkdir(cwd)
|
||
const now = Date.now()
|
||
const crypto = require('node:crypto')
|
||
const quotedUserMessage = {
|
||
id: crypto.randomUUID(), role: 'user', content: '这条来自历史记录', createdAt: now - 1000, status: 'done',
|
||
quotes: [{ id: 'history-text-0:table:table-0', kind: 'table', sourceId: 'history-text-0:table:table-0', messageId: 'history', markdown: TABLE_MARKDOWN, preview: TABLE_MARKDOWN }]
|
||
}
|
||
const sessions = [
|
||
{ id: crypto.randomUUID(), title: 'Quote Live', cwd, createdAt: now, updatedAt: now + 1, messages: [
|
||
{ id: crypto.randomUUID(), role: 'user', content: '给我一个表格', createdAt: now - 2000, status: 'done' },
|
||
{ id: crypto.randomUUID(), role: 'assistant', content: `这里是表格:\n\n${TABLE_MARKDOWN}\n\n以上。`, createdAt: now - 1000, status: 'done' }
|
||
] },
|
||
{ id: crypto.randomUUID(), title: 'Quote History', cwd, createdAt: now - 5000, updatedAt: now - 4000, messages: [
|
||
quotedUserMessage,
|
||
{ id: crypto.randomUUID(), role: 'assistant', content: '好的。', createdAt: now - 900, status: 'done' }
|
||
] }
|
||
]
|
||
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions }))
|
||
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data,
|
||
YCODE_OMP_HOME: home, YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1' }
|
||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
|
||
const child = require('node:child_process').spawn(require('electron'),
|
||
['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||
let output = ''
|
||
child.stdout.on('data', bytes => { output += bytes; if (bytes.toString().includes('QUOTE_TABLE_')) process.stdout.write(bytes) })
|
||
child.stderr.on('data', bytes => { output += bytes })
|
||
const timer = setTimeout(() => child.kill(), 150000)
|
||
child.once('exit', async code => {
|
||
clearTimeout(timer)
|
||
await fs.writeFile(path.join(directory, 'process.log'), output)
|
||
console.log(JSON.stringify({ directory, exitCode: code }))
|
||
if (code) console.error(output)
|
||
process.exitCode = code ? 1 : 0
|
||
})
|
||
})()
|
||
} else {
|
||
const { app, BrowserWindow, ipcMain } = require('electron')
|
||
app.disableHardwareAcceleration()
|
||
const directory = process.env.YCODE_QA_DIRECTORY
|
||
assert.ok(path.relative(path.resolve('.tmp'), directory).startsWith('quote-table-'))
|
||
app.setPath('userData', path.join(directory, 'data')); app.setPath('sessionData', path.join(directory, 'data'))
|
||
app.setPath('home', path.join(directory, 'home'))
|
||
let main, phase = 'boot'
|
||
const checks = [], snapshots = {}, rendererErrors = []
|
||
let sentPayload = null
|
||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||
async function read(source) {
|
||
let timer
|
||
try {
|
||
return await Promise.race([main.webContents.executeJavaScript(source), new Promise((_, reject) => {
|
||
timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 100))), 15000)
|
||
})])
|
||
} catch (error) { throw Error(error.message + ' - ' + source.slice(0, 240)) } finally { clearTimeout(timer) }
|
||
}
|
||
async function wait(source, label) {
|
||
for (let index = 0; index < 250; index++) { const value = await read(source); if (value) return value; await pause(30) }
|
||
throw Error('Timeout: ' + label)
|
||
}
|
||
async function step(label) { phase = label; await fs.writeFile(path.join(directory, 'phase.txt'), label) }
|
||
function check(label, actual, expected) {
|
||
const pass = typeof expected === 'function' ? expected(actual) : JSON.stringify(actual) === JSON.stringify(expected)
|
||
checks.push({ label, pass, actual, expected: typeof expected === 'function' ? expected.toString() : expected })
|
||
}
|
||
async function shot(name) {
|
||
const capture = await main.webContents.debugger.sendCommand('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false })
|
||
await fs.writeFile(path.join(directory, name + '.png'), Buffer.from(capture.data, 'base64'))
|
||
}
|
||
const menuSource = `(()=>{const menu=document.querySelector('.cr-message-quote-table-menu');if(!menu)return null;const button=menu.querySelector('.cr-message-quote-table-action');const style=getComputedStyle(menu);const rect=menu.getBoundingClientRect();const icon=menu.querySelector('svg');return {rect:[rect.x,rect.y,rect.width,rect.height],padding:style.padding,borderRadius:style.borderRadius,border:style.borderWidth+' '+style.borderColor,background:style.backgroundColor,boxShadow:style.boxShadow,position:style.position,top:style.top,right:style.right,added:menu.getAttribute('data-message-quote-table-added'),sourceId:menu.getAttribute('data-message-quote-source-id'),label:button?button.textContent:'',ariaLabel:button?button.getAttribute('aria-label'):null,button:button?(()=>{const bs=getComputedStyle(button);const box=button.getBoundingClientRect();return {width:box.width,height:box.height,fontSize:bs.fontSize,fontWeight:bs.fontWeight,lineHeight:bs.lineHeight,gap:bs.gap,padding:bs.padding,borderRadius:bs.borderRadius,display:bs.display,color:bs.color}})():null,icon:icon?[icon.getBoundingClientRect().width,icon.getBoundingClientRect().height,icon.querySelectorAll('path').length]:null}})()`
|
||
const cardSource = `(()=>{const rail=document.querySelector('.cr-message-quote-rail');const card=document.querySelector('.cr-input-quote-card');if(!rail||!card)return null;const s=getComputedStyle(card);const rect=card.getBoundingClientRect();const accent=card.querySelector('.cr-input-quote-card__accent');const summary=card.querySelector('.cr-input-quote-card__summary');const remove=card.querySelector('.cr-input-quote-card__remove');return {railPadding:getComputedStyle(rail).padding,railGap:getComputedStyle(rail).gap,railDisplay:getComputedStyle(rail).display,count:document.querySelectorAll('.cr-input-quote-card').length,card:{width:rect.width,height:rect.height,borderRadius:s.borderRadius,padding:s.padding,gap:s.gap,display:s.display,background:s.backgroundColor},accent:accent?accent.getBoundingClientRect().width:null,summary:summary?summary.textContent:null,remove:remove?[remove.getBoundingClientRect().width,remove.getBoundingClientRect().height,remove.getAttribute('aria-label')]:null,sourceId:card.getAttribute('data-message-quote-source-id')}})()`
|
||
|
||
;(async () => {
|
||
await import('node:url').then(({ pathToFileURL }) => import(pathToFileURL(path.resolve('out/main/index.mjs')).href))
|
||
await app.whenReady()
|
||
// 记录发送负载并返回合成结果:隔离环境下没有可用模型,但引用链路必须真实经过 IPC。
|
||
ipcMain.removeHandler('chat:send')
|
||
ipcMain.handle('chat:send', async (_event, payload) => {
|
||
sentPayload = payload
|
||
const runId = 'quote-run'
|
||
const userMessage = { id: 'quote-user', role: 'user', content: payload.text, quotes: payload.quotes, createdAt: Date.now(), status: 'done', requestId: runId }
|
||
const assistant = { id: 'quote-assistant', role: 'assistant', content: '收到表格。', createdAt: Date.now(), status: 'done', requestId: runId, tools: [], parts: [] }
|
||
return { runId, assistantMessageId: assistant.id, userMessage, messages: [...(payload.history ?? []), userMessage, assistant] }
|
||
})
|
||
main = BrowserWindow.getAllWindows()[0]
|
||
main.setSize(1336, 902)
|
||
main.showInactive()
|
||
main.webContents.debugger.attach('1.3')
|
||
main.webContents.on('console-message', (_event, level, message) => { if (level >= 2) rendererErrors.push(message) })
|
||
await wait('!!document.querySelector("#root")', 'renderer root')
|
||
await pause(1500)
|
||
await wait('!!document.querySelector("[data-session-id]")', 'sidebar rows')
|
||
await step('select-live-session')
|
||
await read(`(()=>{const rows=[...document.querySelectorAll('[data-session-id]')];const row=rows.find(item=>item.textContent.includes('Quote Live'));if(!row)return false;row.click();return true})()`)
|
||
await wait(`!!document.querySelector('.cr-table-block')`, 'table block')
|
||
|
||
await step('table-action-menu')
|
||
const menu = await wait(menuSource, 'table quote menu')
|
||
snapshots.menu = menu
|
||
check('menu source id matches the original composite id', /-text-\d+:table:table-\d+$/.test(menu.sourceId ?? ''), true)
|
||
check('menu label is 添加到对话', menu.label, '添加到对话')
|
||
check('menu aria-label matches the label', menu.ariaLabel, '添加到对话')
|
||
check('menu is absolutely positioned at top 8 right 8', menu.position === 'absolute' && menu.top === '8px' && menu.right === '8px', true)
|
||
check('menu padding is 4 like the original', menu.padding, '4px')
|
||
check('menu radius is 8 like the original', menu.borderRadius, '8px')
|
||
check('menu background is the primary surface', menu.background, 'rgb(255, 255, 255)')
|
||
check('menu shows the recovered panel shadow', menu.boxShadow, 'rgba(0, 0, 0, 0.08) 0px 4px 16px 0px, rgba(0, 0, 0, 0.04) 0px 1px 4px 0px')
|
||
check('button font is 14/22 weight 500', menu.button.fontSize === '14px' && menu.button.lineHeight === '22px' && menu.button.fontWeight === '500', true)
|
||
check('button padding is 3px 8px like the original', menu.button.padding, '3px 8px')
|
||
check('button gap is 4 like the original', menu.button.gap, '4px')
|
||
check('button radius is 6 like the original', menu.button.borderRadius, '6px')
|
||
check('button height is 28 like the original', Math.round(menu.button.height), 28)
|
||
check('button width matches the original 106 within layout rounding', menu.button.width, value => Math.abs(value - 106) <= 2)
|
||
check('button icon is 16x16 with both original paths', menu.icon[0] === 16 && menu.icon[1] === 16 && menu.icon[2] === 2, true)
|
||
await read(`document.querySelector('.cr-message-quote-table-action').focus()`)
|
||
await pause(320)
|
||
check('focus-within reveals the menu like the original', await read(`getComputedStyle(document.querySelector('.cr-message-quote-table-menu')).opacity`), '1')
|
||
await shot('table-action')
|
||
|
||
await step('add-to-conversation')
|
||
await read(`document.querySelector('.cr-message-quote-table-action').click()`)
|
||
await wait(cardSource, 'composer quote card')
|
||
const card = await read(cardSource)
|
||
snapshots.card = card
|
||
check('added menu hides itself with the original --added class', await read(`!!document.querySelector('.cr-message-quote-table-menu--added') && document.querySelector('.cr-message-quote-table-menu').getAttribute('data-message-quote-table-added')==='true'`), true)
|
||
check('added table takes the brand border like the original', await read(`getComputedStyle(document.querySelector('.cr-table-block__inner')).borderTopColor`), 'rgb(64, 209, 179)')
|
||
check('composer renders exactly one quote card', card.count, 1)
|
||
check('card radius is 10 like the original', card.card.borderRadius, '10px')
|
||
check('card padding is 8 like the original', card.card.padding, '8px')
|
||
check('card gap is 8 like the original', card.card.gap, '8px')
|
||
check('card background is the item hover surface', card.card.background, 'rgb(242, 242, 242)')
|
||
check('accent bar is 3px wide like the original', Math.round(card.accent), 3)
|
||
check('card summary keeps the quoted table text', typeof card.summary === 'string' && card.summary.includes('alpha'), true)
|
||
check('remove button is 16x16 with a label', card.remove[0] === 16 && card.remove[1] === 16 && !!card.remove[2], true)
|
||
check('rail padding matches the original banner rail', card.railPadding, '0px 16px 8px')
|
||
check('rail gap is 6 like the original', card.railGap, '6px')
|
||
check('existing draft text is not overwritten by the quote', await read(`document.querySelector('.wb-composer__input').value`), '')
|
||
await shot('composer-quote')
|
||
|
||
await step('remove-quote')
|
||
await read(`document.querySelector('.cr-input-quote-card__remove').click()`)
|
||
await pause(200)
|
||
check('removing the card clears the composer rail', await read(`!!document.querySelector('.cr-input-quote-card')`), false)
|
||
check('removing the quote restores the table button', await read(`!!document.querySelector('.cr-message-quote-table-action') && !document.querySelector('.cr-message-quote-table-menu--added')`), true)
|
||
|
||
await step('send-with-quote')
|
||
await read(`document.querySelector('.cr-message-quote-table-action').click()`)
|
||
await wait(cardSource, 'quote card before send')
|
||
await read(`(()=>{const input=document.querySelector('.wb-composer__input');const setter=Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype,'value').set;setter.call(input,'看看这个表格');input.dispatchEvent(new Event('input',{bubbles:true}));return true})()`)
|
||
await pause(120)
|
||
await read(`document.querySelector('.wb-composer__send').click()`)
|
||
for (let index = 0; index < 100 && !sentPayload; index++) await pause(50)
|
||
check('chat:send receives the quote payload through IPC', !!sentPayload && Array.isArray(sentPayload.quotes) && sentPayload.quotes.length === 1, true)
|
||
check('sent quote keeps the table markdown snapshot', !!sentPayload.quotes[0].markdown.includes('| alpha |'), true)
|
||
check('sent quote keeps the composite source id', /:table:table-\d+$/.test(sentPayload.quotes[0].sourceId), true)
|
||
check('sent message keeps the typed text', sentPayload.text, '看看这个表格')
|
||
const historyCount = await read(`(()=>{const row=[...document.querySelectorAll('[data-session-id]')].find(item=>item.textContent.includes('Quote Live'));return row?row.getAttribute('data-session-id'):null})()`)
|
||
check('sent session identity is stable', typeof historyCount === 'string' && historyCount.length > 0, true)
|
||
const sentChip = await wait(`(()=>{const chip=document.querySelector('.cr-frame--right .cr-context-chips .cr-message-quote-tag');if(!chip)return null;const s=getComputedStyle(chip);const box=chip.getBoundingClientRect();const icon=chip.querySelector('.cr-message-quote-tag__icon-container svg');return {height:box.height,borderRadius:s.borderRadius,padding:s.padding,fontSize:s.fontSize,fontWeight:s.fontWeight,lineHeight:s.lineHeight,background:s.backgroundColor,cursor:s.cursor,metaType:chip.getAttribute('data-content-block-meta-type'),icon:icon?[icon.getBoundingClientRect().width,icon.getBoundingClientRect().height]:null,text:chip.textContent}})()`, 'sent quote chip')
|
||
snapshots.sentChip = sentChip
|
||
check('sent chip is 22 high like the original', Math.round(sentChip.height), 22)
|
||
check('sent chip radius is 6 like the original', sentChip.borderRadius, '6px')
|
||
check('sent chip padding is 2px 6px like the original', sentChip.padding, '2px 6px')
|
||
check('sent chip font is 13/14 weight 500', sentChip.fontSize === '13px' && sentChip.lineHeight === '14px' && sentChip.fontWeight === '500', true)
|
||
check('sent chip uses the read-only message-quote background', sentChip.background, 'rgba(0, 0, 0, 0.1)')
|
||
check('sent chip is read-only', sentChip.cursor, 'default')
|
||
check('sent chip keeps the message-quote meta type', sentChip.metaType, 'message-quote')
|
||
check('sent chip keeps the original 16x16 icon box', sentChip.icon[0] === 16 && sentChip.icon[1] === 16, true)
|
||
await shot('sent-quote')
|
||
|
||
await step('history-reload')
|
||
await read(`(()=>{const rows=[...document.querySelectorAll('[data-session-id]')];const row=rows.find(item=>item.textContent.includes('Quote History'));if(!row)return false;row.click();return true})()`)
|
||
await pause(600)
|
||
const reloaded = await wait(`(()=>{const chip=document.querySelector('.cr-frame--right .cr-context-chips .cr-message-quote-tag');if(!chip)return null;return {text:chip.textContent,readOnly:chip.classList.contains('cr-message-quote-tag--read-only'),hasClose:!!chip.querySelector('.cr-message-quote-tag__close'),bubble:document.querySelector('.cr-frame--right .cr-self-bubble').textContent}})()`, 'history quote chip')
|
||
snapshots.reloaded = reloaded
|
||
check('history message renders the persisted quote chip', typeof reloaded.text === 'string' && reloaded.text.includes('| 项目 |'), true)
|
||
check('history chip is read-only without a remove control', reloaded.readOnly === true && reloaded.hasClose === false, true)
|
||
check('history message keeps its own text', reloaded.bubble.includes('这条来自历史记录'), true)
|
||
check('no renderer error logs', rendererErrors, [])
|
||
|
||
const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL',
|
||
scope: 'Real isolated YCode App/preload/IPC/renderer. chat:send is stubbed in the test main process to record the payload because the sandbox has no model.',
|
||
sourceEvidence: ['ev_38cca1ac66606ab07fb495035e3509f5c4d8ec3e5e2b74d0fdcfcfadb120da3b', 'src-CyXHA0IC.css a5e26b537bb50323aefbf25abddad52e288dd3868598be8d255d70886fe77cfe'],
|
||
snapshots, checks, rendererErrors }
|
||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
|
||
console.log('QUOTE_TABLE_' + report.result + ' ' + directory)
|
||
for (const failure of checks.filter(item => !item.pass)) console.log('QUOTE_TABLE_FAILURE ' + JSON.stringify(failure))
|
||
app.exit(report.result === 'PASS' ? 0 : 1)
|
||
})().catch(async error => {
|
||
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ phase, error: String(error), stack: error.stack, checks, snapshots, rendererErrors }, null, 2))
|
||
console.error(error); if (main) await shot('failure').catch(() => undefined); app.exit(1)
|
||
})
|
||
}
|