feat(conversation): replicate table quote-to-chat action (TODO 02)

This commit is contained in:
xiaohan.yuan committed 2026-10-17 05:57:49 +08:00
1 parent e335588826
commit ed31a7c1b4
20 files changed
+939 -46

No files matched your search

+6 -1
View File
@@ -115,8 +115,13 @@ assert.deepEqual(oversizedAttachmentNames([
{ name: 'over.bin', size: MAX_ATTACHMENT_BYTES + 1 }
]), ['over.bin'])
assert.deepEqual(buildComposerSendPayload(' prompt ', [], ['C:/work/file.ts'], 'full-access'), {
text: 'prompt', attachments: [], resources: ['C:/work/file.ts'], mode: 'full-access'
text: 'prompt', attachments: [], resources: ['C:/work/file.ts'], quotes: [], mode: 'full-access'
})
const quotedPayload = buildComposerSendPayload('', [], [], 'full-access', [
{ id: 'm1-text-0:table:table-0', kind: 'table', sourceId: 'm1-text-0:table:table-0', messageId: 'm1', markdown: '| a |\n| - |\n| 1 |', preview: '| a |' }
])
assert.deepEqual(quotedPayload.quotes.map((quote) => quote.sourceId), ['m1-text-0:table:table-0'])
assert.equal(quotedPayload.text, '', '引用单独存在时正文可以为空')
assert.equal(parseResourcePath('file:///C:/work/file.ts'), 'C:\\work\\file.ts')
assert.equal(parseResourcePath('C:\\work\\file.ts'), 'C:\\work\\file.ts')
assert.deepEqual(readReferenceUris({ getData: (type) => type === 'application/vnd.code.uri-list' ? 'file:///C:/a.ts\r\nfile:///C:/b.ts' : '' }), ['file:///C:/a.ts', 'file:///C:/b.ts'])
+5 -3
View File
@@ -31,9 +31,11 @@ async function main(): Promise<void> {
assert.doesNotMatch(render('before<script>window.bad = true</script>after'), /<script|window\.bad/)
assert.match(render('Waiting $x +', true), /markdown-content__loading-char/)
const table = '| a | b |\n| - | - |\n| 1 | 2 |'
const tableWithAction = (streaming: boolean): string => renderToStaticMarkup(<MarkdownContent content={table} streaming={streaming} onQuote={() => undefined} />)
assert.match(tableWithAction(true), /class="markdown-content__table-quote" disabled=""/)
assert.doesNotMatch(tableWithAction(false), /class="markdown-content__table-quote" disabled/)
const tableWithAction = (streaming: boolean): string => renderToStaticMarkup(<MarkdownContent content={table} messageId="fixture-message" sourceId="fixture-message-text-0" streaming={streaming} onQuote={() => undefined} />)
assert.match(tableWithAction(true), /class="cr-message-quote-table-action" disabled=""[^>]*aria-label="添加到对话"/)
assert.match(tableWithAction(false), /class="cr-message-quote-table-action"[^>]*aria-label="添加到对话">/)
assert.doesNotMatch(tableWithAction(false), /class="cr-message-quote-table-action" disabled/)
assert.match(tableWithAction(false), /data-message-quote-source-id="fixture-message-text-0:table:table-0"/)
const opacity = render('abcdef', true)
assert.match(opacity, /style="opacity:0\.6/)
assert.match(opacity, /style="opacity:0\.1/)
@@ -0,0 +1,11 @@
{
"include_fonts": false,
"include_message_content": true,
"track_selectors": [".cr-table-block", ".cr-message-quote-table-menu", ".cr-message-quote-table-action", ".cr-input-banner-rail", ".cr-input-quote-card"],
"actions": [
{ "kind": "hover", "step_id": "hover-table", "selector": ".cr-table-block", "index": 0, "frames": 1 },
{ "kind": "wait", "step_id": "menu-visible", "duration_ms": 250, "frames": 1 },
{ "kind": "click", "step_id": "add-table-to-conversation", "selector": ".cr-message-quote-table-action", "index": 0, "frames": 0 },
{ "kind": "wait", "step_id": "quote-card-rendered", "duration_ms": 600, "frames": 2 }
]
}
+3 -3
View File
@@ -13,7 +13,7 @@ if(!process.versions.electron){
const contentNodes=sourceLayout.message_content.nodes
const paragraph=contentNodes.filter(node=>node.node_index>=1622&&node.node_index<1630&&node.node_name==='#text').map(node=>node.text).join('')
const sourceMarkdown='✅ **取消链路修复并双验证通过**:\n\n'+markdown+'\n\n'+paragraph
const originalFixture=`<div data-table-native-source-flow style={{position:'absolute',inset:0,transform:'translateY(11220px)'}}><div className="chat-pane cr-theme" data-cr-theme="light" style={{position:'absolute',left:324.28125,top:-10834.671875,width:436.703125,height:'auto',minHeight:0,display:'block',overflow:'visible'}}><MarkdownContent content={source} onQuote={text=>window.qa.quoted.push(text)}/></div></div>`
const originalFixture=`<div data-table-native-source-flow style={{position:'absolute',inset:0,transform:'translateY(11220px)'}}><div className="chat-pane cr-theme" data-cr-theme="light" style={{position:'absolute',left:324.28125,top:-10834.671875,width:436.703125,height:'auto',minHeight:0,display:'block',overflow:'visible'}}><MarkdownContent content={source} messageId="fixture-message" sourceId="fixture-message-text-0" onQuote={quote=>window.qa.quoted.push(quote)}/></div></div>`
const sourceFixture=`<div className="chat-pane cr-theme" data-cr-theme="light" style={{position:'absolute',inset:0,display:'block',overflow:'visible'}}>
<div className="cr-message-list-viewport" data-source-node="772" style={{position:'absolute',left:284.28125,top:94,width:526.703125,height:639}}>
<div className="cr-message-list cr-message-list--virtual-anchored" data-source-scroll data-source-node="773" style={{overflow:${JSON.stringify(layerCase==='source-clip-flow'?'hidden':'auto')}}}>
@@ -24,7 +24,7 @@ if(!process.versions.electron){
<div className="cr-document__virtual-item cr-document__virtual-item--flow" data-source-node="1545" style={{height:477.5}}>
<div className="cr-frame cr-frame--left" style={{height:477.5,paddingTop:16,paddingBottom:16}}><div className="cr-frame__content" style={{height:445.5}}><div className="cr-agent" style={{height:445.5}}>
<div style={{height:36.5}}/><div className="cr-agent__body-row" style={{height:409}}><div className="cr-agent__body" style={{height:409}}><div className="cr-agent__content" style={{height:293.75}}>
<div style={{height:26.25}}/><div className="cr-text-block cr-text-block--spaced-top" style={{marginTop:12}}><MarkdownContent content={sourceMarkdown} onQuote={text=>window.qa.quoted.push(text)}/></div>
<div style={{height:26.25}}/><div className="cr-text-block cr-text-block--spaced-top" style={{marginTop:12}}><MarkdownContent content={sourceMarkdown} messageId="source-message" sourceId="source-message-text-0" onQuote={quote=>window.qa.quoted.push(quote)}/></div>
</div></div></div>
</div></div></div>
</div>
@@ -108,7 +108,7 @@ if(!process.versions.electron){
await fs.writeFile(path.join(output,'candidate-roi'+suffix+'.png'),candidate.crop(roi).toPNG())
win.webContents.sendInputEvent({type:'mouseMove',x:600,y:402})
await new Promise(resolve=>setTimeout(resolve,180))
const quote=await win.webContents.executeJavaScript("(()=>{const e=document.querySelector('.markdown-content__table-quote'),r=e.getBoundingClientRect();return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2),opacity:getComputedStyle(e).opacity}})()")
const quote=await win.webContents.executeJavaScript("(()=>{const e=document.querySelector('.cr-message-quote-table-action'),r=e.getBoundingClientRect();return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2),opacity:getComputedStyle(e.closest('.cr-message-quote-table-menu')).opacity}})()")
assert.equal(quote.opacity,'1')
win.webContents.sendInputEvent({type:'mouseDown',button:'left',clickCount:1,x:quote.x,y:quote.y})
win.webContents.sendInputEvent({type:'mouseUp',button:'left',clickCount:1,x:quote.x,y:quote.y})
+211
View File
@@ -0,0 +1,211 @@
// 真实隔离 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)
})
}