// Real isolated YCode main/preload/renderer verification for native append delivery. // The production build is loaded unchanged. A loopback Anthropic-compatible server // holds the first response while two durable queue messages are sent as guidance. const fs = require('node:fs/promises') const path = require('node:path') const assert = require('node:assert/strict') if (!process.versions.electron) { ;(async () => { const directory = await fs.mkdtemp(path.resolve('.tmp/claude-append-app-')) const data = path.join(directory, 'data') const home = path.join(directory, 'home') await Promise.all([fs.mkdir(data, { recursive: true }), fs.mkdir(home, { recursive: true })]) const probe = require('node:net').createServer() await new Promise((resolve, reject) => { probe.once('error', reject) probe.listen(0, '127.0.0.1', resolve) }) const address = probe.address() if (!address || typeof address === 'string') throw new Error('failed to reserve loopback port') const appendPort = address.port await new Promise(resolve => probe.close(resolve)) const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_APPEND_PORT: String(appendPort), YCODE_QA_OFFSCREEN: '1', YCODE_DISABLE_GPU: '1', YCODE_DISABLE_SYSTEM_INTEGRATION: '1', HOME: home, USERPROFILE: home, DISABLE_TELEMETRY: '1', CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '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('APPEND_E2E_')) process.stdout.write(bytes) }) child.stderr.on('data', bytes => { output += bytes if (bytes.toString().includes('APPEND_E2E_')) process.stderr.write(bytes) }) const timer = setTimeout(() => child.kill(), 180000) 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.slice(-12000)) process.exitCode = code ? 1 : 0 }) })().catch(error => { console.error(error) process.exitCode = 1 }) } else { const { app, BrowserWindow } = require('electron') const { createServer } = require('node:http') const { pathToFileURL } = require('node:url') const fsSync = require('node:fs') const directory = process.env.YCODE_QA_DIRECTORY assert.ok(directory, 'YCODE_QA_DIRECTORY is required') const appendPort = Number(process.env.YCODE_APPEND_PORT) assert.ok(Number.isInteger(appendPort) && appendPort > 0, 'YCODE_APPEND_PORT is required') const relative = path.relative(path.resolve('.tmp'), path.resolve(directory)) assert.ok(relative && !relative.startsWith('..') && !path.isAbsolute(relative), 'unsafe QA directory') const data = path.join(directory, 'data') const home = path.join(directory, 'home') const workspace = path.join(home, 'YCodeWorkspace') const modelId = 'native-append-loopback' const modelBId = 'native-append-loopback-b' const modelCId = 'native-append-loopback-c' // Each profile keeps its own wire model id so the loopback server can prove // which model actually served a request. const wireModelA = 'claude-native-append-a' const wireModelB = 'claude-native-append-b' const wireModelC = 'claude-native-append-c' // Each profile gets its own base path so the request URL alone proves which // model served a turn, independently of the request body fields. const modelPathA = '/m-a' const modelPathB = '/m-b' const modelPathC = '/m-c' app.disableHardwareAcceleration() app.setPath('userData', data) app.setPath('sessionData', data) app.setPath('home', home) let hidingWindows = true app.on('browser-window-created', (_event, win) => { win.setSkipTaskbar(true) win.on('show', () => { if (!hidingWindows || win.isDestroyed()) return setImmediate(() => { if (!win.isDestroyed()) win.hide() }) }) }) const pause = ms => new Promise(resolve => setTimeout(resolve, ms)) const checks = [] const rendererErrors = [] const requests = [] const serverEvents = [] const queueTimeline = [] const openSockets = new Set() let firstResponseGate let firstResponseReleased = false const firstResponse = new Promise(resolve => { firstResponseGate = resolve }) let stage = 'boot' function setStage(value) { stage = value serverEvents.push({ type: 'stage', stage: value }) } function releaseFirstResponse() { if (firstResponseReleased) return firstResponseReleased = true firstResponseGate() } 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 }) } function sse(res, event, value) { res.write(`event: ${event}\ndata: ${JSON.stringify(value)}\n\n`) } function messageStart(res, id) { sse(res, 'message_start', { type: 'message_start', message: { id, type: 'message', role: 'assistant', model: modelId, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 12, output_tokens: 1 } } }) } function textBlock(res, text) { sse(res, 'content_block_start', { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }) sse(res, 'content_block_delta', { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } }) sse(res, 'content_block_stop', { type: 'content_block_stop', index: 0 }) } function messageEnd(res) { sse(res, 'message_delta', { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 4 } }) sse(res, 'message_stop', { type: 'message_stop' }) res.end() } function plainText(res, id, text) { messageStart(res, id) textBlock(res, text) messageEnd(res) } function messageTexts(message) { if (!message || typeof message !== 'object') return [] const content = message.content if (typeof content === 'string') return [content] if (!Array.isArray(content)) return [] return content.flatMap(block => block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string' ? [block.text] : []) } function userTexts(body) { if (!Array.isArray(body.messages)) return [] return body.messages.flatMap(message => message?.role === 'user' ? messageTexts(message) : []) } async function read(source, win) { let timer try { return await Promise.race([ win.webContents.executeJavaScript(source), new Promise((_, reject) => { timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 140))), 20000) }) ]) } finally { clearTimeout(timer) } } async function waitFor(source, label, win, attempts = 500) { for (let index = 0; index < attempts; index += 1) { const value = await read(source, win) if (value) return value await pause(40) } throw Error('Timeout: ' + label) } async function waitForMain(predicate, label, attempts = 500) { for (let index = 0; index < attempts; index += 1) { const value = await predicate() if (value) return value await pause(40) } throw Error('Timeout: ' + label) } async function readQueue(win, sessionId) { return read(`window.ycode.chat.queue.get(${JSON.stringify(sessionId)})`, win) } /** * Click the send-now action of one specific queue row. The row is located by * its rendered preview text, which is unique per queued message, so a * concurrent send-now on another row can never be mistaken for this one. */ async function clickSendNowForRow(win, previewText) { const clicked = await read(`(()=>{ const rows=[...document.querySelectorAll('.conversation-message-queue-item')]; const row=rows.find(node=>node.querySelector('.conversation-queue-preview-text')?.textContent?.includes(${JSON.stringify(previewText)})); if(!row)return {ok:false,reason:'row-not-found',rows:rows.length}; const button=row.querySelector('[aria-label="立即发送"]'); if(!button)return {ok:false,reason:'no-button',status:row.textContent??''}; if(button.disabled)return {ok:false,reason:'disabled'}; button.click(); return {ok:true} })()`, win) assert.equal(clicked.ok, true, `send-now for row ${previewText}: ${JSON.stringify(clicked)}`) } /** * A row leaves the queue only after the native identity is acknowledged. * While the first model response is still gated, that acknowledgement cannot * arrive, so the row stays visible in the delivering state. */ async function waitForRowStatus(win, sessionId, queueId, status) { for (let index = 0; index < 500; index += 1) { const snapshot = await readQueue(win, sessionId) const item = snapshot.items.find(entry => entry.id === queueId) if (item?.status === status) return snapshot if (!item) { const error = new Error(`queue row ${queueId} left the queue before reaching ${status}`) error.snapshot = snapshot throw error } await pause(40) } throw Error(`Timeout: queue row ${queueId} did not reach ${status}`) } async function setComposer(win, text) { const set = await read(`(()=>{ const input=document.querySelector('.wb-composer__input'); if(!(input instanceof HTMLTextAreaElement))return false; const setter=Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set; setter.call(input,${JSON.stringify(text)}); input.dispatchEvent(new Event('input',{bubbles:true})); input.focus(); return true })()`, win) assert.equal(set, true, 'composer textarea must exist') await waitFor(`!document.querySelector('.cr-input-toolbar__send:not(.cr-send-button--stop)')?.disabled`, 'composer accepts ' + text, win) } async function clickComposerSend(win) { const clicked = await read(`(()=>{ const button=document.querySelector('.cr-input-toolbar__send:not(.cr-send-button--stop)'); if(!button||button.disabled)return false; button.click(); return true })()`, win) assert.equal(clicked, true, 'composer send button must be enabled') } async function queueFromUi(win, text, expectedCount) { await setComposer(win, text) await clickComposerSend(win) await waitFor(`document.querySelectorAll('.conversation-message-queue-item').length===${expectedCount}`, `queue count ${expectedCount} after ${text}`, win) } async function screenshot(win, name) { win.webContents.invalidate() await pause(120) const image = await win.webContents.capturePage() await fs.writeFile(path.join(directory, name + '.png'), image.toPNG()) return !image.isEmpty() } async function run() { setStage('start') const watchdog = setTimeout(() => { fsSync.writeFileSync(path.join(directory, 'watchdog.json'), JSON.stringify({ stage, checks, queueTimeline, requests: requests.map(request => ({ index: request.index, userTexts: request.userTexts, completed: request.completed, aborted: request.aborted, answer: request.answer })), serverEvents }, null, 2)) console.error('APPEND_E2E_ERROR watchdog timeout at stage ' + stage) app.exit(1) }, 150000) watchdog.unref?.() fsSync.mkdirSync(workspace, { recursive: true }) fsSync.writeFileSync(path.join(workspace, 'README.md'), 'native append app verification\n', 'utf8') const server = createServer((req, res) => { openSockets.add(req.socket) req.socket.on('close', () => openSockets.delete(req.socket)) let raw = '' req.on('data', chunk => { raw += chunk }) req.on('end', async () => { let body = {} try { body = JSON.parse(raw || '{}') } catch { body = { raw } } if (!req.url?.includes('/messages')) { res.writeHead(200, { 'content-type': 'application/json' }) res.end('{}') return } const texts = userTexts(body) const servedModel = req.url?.includes(modelPathB) ? 'B' : req.url?.includes(modelPathC) ? 'C' : req.url?.includes(modelPathA) ? 'A' : 'unknown' const captured = { index: requests.length + 1, url: req.url, body, servedModel, wireModel: typeof body.model === 'string' ? body.model : null, userTexts: texts, completed: false, aborted: false } requests.push(captured) serverEvents.push({ type: 'request', index: captured.index, texts, servedModel }) res.on('close', () => { if (!captured.completed) { captured.aborted = true serverEvents.push({ type: 'aborted', index: captured.index }) } }) res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' }) res.flushHeaders() if (captured.index === 1) await firstResponse if (res.destroyed) return const hasA = texts.some(text => text.includes('E2E_APPEND_A')) const hasB = texts.some(text => text.includes('E2E_APPEND_B')) const afterSwitch = texts.some(text => text.includes('E2E_AFTER_MODEL_SWITCH')) const answer = afterSwitch ? 'E2E_ANSWER_AFTER_SWITCH' : captured.index === 1 ? 'E2E_ANSWER_ONE' : hasA && hasB ? 'E2E_ANSWER_MERGED' : 'E2E_ANSWER_UNEXPECTED' captured.completed = true captured.answer = answer serverEvents.push({ type: 'response', index: captured.index, answer }) plainText(res, `msg-native-append-${captured.index}`, answer) }) }) const serverReady = new Promise((resolve, reject) => { server.once('listening', resolve) server.once('error', reject) }) server.listen(appendPort, '127.0.0.1') const baseUrl = `http://127.0.0.1:${appendPort}` fsSync.mkdirSync(data, { recursive: true }) fsSync.writeFileSync(path.join(data, 'models.json'), JSON.stringify({ version: 1, models: [ { id: modelId, name: 'Native Append Loopback A', providerId: 'custom', model: wireModelA, baseUrl: baseUrl + modelPathA, authMode: 'api-key', apiKeyPlain: 'ycode-loopback-append-key', protocol: 'anthropic', supportsToolCall: true, supportsImages: false, supportsReasoning: false, contextWindowTokens: 200000, maxInputTokens: 180000, maxOutputTokens: 4096, temperature: 0, maxToolRounds: 1 }, { id: modelBId, name: 'Native Append Loopback B', providerId: 'custom', model: wireModelB, baseUrl: baseUrl + modelPathB, authMode: 'api-key', apiKeyPlain: 'ycode-loopback-append-key', protocol: 'anthropic', supportsToolCall: true, supportsImages: false, supportsReasoning: false, contextWindowTokens: 200000, maxInputTokens: 180000, maxOutputTokens: 4096, temperature: 0, maxToolRounds: 1 }, { id: modelCId, name: 'Native Append Loopback C', providerId: 'custom', model: wireModelC, baseUrl: baseUrl + modelPathC, authMode: 'api-key', apiKeyPlain: 'ycode-loopback-append-key', protocol: 'anthropic', supportsToolCall: true, supportsImages: false, supportsReasoning: false, contextWindowTokens: 200000, maxInputTokens: 180000, maxOutputTokens: 4096, temperature: 0, maxToolRounds: 1 } ], selectedModelId: modelId }, null, 2), 'utf8') fsSync.writeFileSync(path.join(data, 'config.json'), JSON.stringify({ baseUrl, model: 'claude-native-append', temperature: 0, maxToolRounds: 1, apiKeyPlain: 'ycode-loopback-append-key', workspaceRoot: workspace, preventSleep: false, autoLaunch: false }, null, 2), 'utf8') let main try { await import(pathToFileURL(path.resolve('out/main/index.mjs')).href) await serverReady await app.whenReady() for (let index = 0; index < 300; index += 1) { main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode') if (main && !main.webContents.isLoading()) break await pause(25) } assert.ok(main, 'main window') main.webContents.on('console-message', (...args) => { const event = args[0] const level = event?.level ?? args[1] const message = event?.message ?? args[2] if (level === 'error' || level === 3) rendererErrors.push(String(message)) }) main.setSize(1336, 902) await waitFor('!!window.ycode', 'preload bridge', main) await waitFor(`!!document.querySelector('.conversation-list')&&!!document.querySelector('.wb-composer__input')`, 'renderer ready', main) check('all created windows stay hidden during verification', main.isVisible(), false) setStage('create-session') const session = await read(`window.ycode.session.create('Native append E2E', ${JSON.stringify({ cwd: workspace, modelId, agentMode: 'craft', permissionMode: 'default-sandbox' })})`, main) assert.ok(session?.id, 'session creation') await read(`(()=>{ const url=new URL(location.href); url.searchParams.set('window','task'); url.searchParams.set('taskId',${JSON.stringify(session.id)}); location.href=url.href; return true })()`, main).catch(() => undefined) await waitFor(`document.querySelector('[data-session-id=${JSON.stringify(session.id)}]')?.getAttribute('data-selected')==='true'`, 'created session selected', main) await waitFor(`!!document.querySelector('.wb-composer__input')`, 'composer after selection', main) setStage('send-initial') await setComposer(main, 'E2E_INITIAL') await clickComposerSend(main) await waitForMain(() => requests[0], 'first model request') check('first request reaches the loopback model', requests[0].url.includes('/messages'), true) setStage('queue-guidance') await queueFromUi(main, 'E2E_APPEND_A', 1) await queueFromUi(main, 'E2E_APPEND_B', 2) const beforeSendNow = await read(`window.ycode.chat.queue.get(${JSON.stringify(session.id)})`, main) check('two queued guidance messages are visible in the composer', beforeSendNow.items.length, 2) check('queued guidance retains both texts', beforeSendNow.items.map(item => item.payload.text), ['E2E_APPEND_A', 'E2E_APPEND_B']) check('queue screenshot captured while the first response is active', await screenshot(main, 'append-queue'), true) // Both guidance messages are handed to the running turn while the first // model response is still gated. A row only leaves the queue after the // native identity is acknowledged, so both rows must still be visible. setStage('send-now-a') await clickSendNowForRow(main, 'E2E_APPEND_A') const afterSendNowA = await waitForRowStatus(main, session.id, beforeSendNow.items[0].id, 'delivering') queueTimeline.push({ stage: 'after-send-now-a', items: afterSendNowA.items.map(item => ({ id: item.id, text: item.payload.text, status: item.status })) }) check('the first guidance message is handed to the running turn', afterSendNowA.items[0].status, 'delivering') check('the second guidance message is still queued while the first is delivering', afterSendNowA.items[1]?.status, 'pending') setStage('send-now-b') await clickSendNowForRow(main, 'E2E_APPEND_B') const afterSendNowB = await waitForRowStatus(main, session.id, beforeSendNow.items[1].id, 'delivering') queueTimeline.push({ stage: 'after-send-now-b', items: afterSendNowB.items.map(item => ({ id: item.id, text: item.payload.text, status: item.status })) }) check('both guidance messages are delivered to the running turn concurrently', afterSendNowB.items.map(item => item.status), ['delivering', 'delivering']) check('no guidance row is removed before the native identity is acknowledged', afterSendNowB.items.length, 2) // ---- Model switch while the turn is still running. // The active run must keep model A; only later requests may use the new choice. setStage('model-switch-b') const switchB = await read(`window.ycode.session.updateContext(${JSON.stringify(session.id)}, { modelId: ${JSON.stringify(modelBId)} })`, main) check('the session accepts model B while a run is active', switchB?.modelId, modelBId) setStage('model-switch-c') const switchC = await read(`window.ycode.session.updateContext(${JSON.stringify(session.id)}, { modelId: ${JSON.stringify(modelCId)} })`, main) check('the session accepts model C while a run is active', switchC?.modelId, modelCId) const modelSwitchRequests = requests.length setStage('release-first-response') releaseFirstResponse() await waitForMain(() => requests.length >= 2 ? requests[1] : undefined, 'append model request') setStage('await-queue-drain') const drained = await waitForMain(async () => { const snapshot = await readQueue(main, session.id) return snapshot.items.length === 0 ? snapshot : undefined }, 'acknowledged guidance leaves the durable queue') const afterSendNow = drained await waitFor(`document.querySelector('.chat-thread')?.textContent.includes('E2E_ANSWER_MERGED')`, 'merged answer rendered', main, 800) await waitForMain(async () => { const active = await read(`window.ycode.chat.activeRun(${JSON.stringify(session.id)})`, main) return active === null }, 'native run completed') check('final conversation screenshot captured', await screenshot(main, 'append-final'), true) const messages = await read(`window.ycode.session.messages(${JSON.stringify(session.id)})`, main) const finalQueue = await read(`window.ycode.chat.queue.get(${JSON.stringify(session.id)})`, main) const users = messages.filter(message => message.role === 'user') const assistants = messages.filter(message => message.role === 'assistant') const queuedIds = beforeSendNow.items.map(item => item.id) const finalText = await read(`document.querySelector('.chat-thread')?.textContent??''`, main) const secondTexts = requests[1].userTexts const initialPosition = secondTexts.findIndex(text => text.includes('E2E_INITIAL')) const aPosition = secondTexts.findIndex(text => text.includes('E2E_APPEND_A')) const bPosition = secondTexts.findIndex(text => text.includes('E2E_APPEND_B')) const lastUser = [...requests[1].body.messages].reverse().find(message => message?.role === 'user') const lastUserText = messageTexts(lastUser).join('\n') const lastUserBlocks = Array.isArray(lastUser?.content) ? lastUser.content : [] const guidanceBlocks = lastUserBlocks.filter(block => block?.type === 'text' && (block.text?.includes('E2E_APPEND_A') || block.text?.includes('E2E_APPEND_B'))) check('first request was not interrupted by queue guidance', requests[0].aborted, false) check('first request completed before the append request', requests[0].completed, true) check('the append guidance produced a second model request', requests.length >= 2, true) check('append request contains the initial message before both guidance messages', initialPosition >= 0 && aPosition > initialPosition && bPosition > aPosition, true) check('both guidance messages are merged into one native user content group', lastUserText.includes('E2E_APPEND_A') && lastUserText.includes('E2E_APPEND_B'), true) check('the merged guidance arrives as separate text blocks in one user message', guidanceBlocks.length >= 2, true) check('the second model request is the append request', requests[1].answer, 'E2E_ANSWER_MERGED') check('the merged answer is the second assistant turn', assistants.at(-1)?.content?.includes('E2E_ANSWER_MERGED'), true) check('the initial answer remains in history', assistants.some(message => message.content?.includes('E2E_ANSWER_ONE')), true) check('both queued user message identities persist in history', queuedIds.every(id => users.some(message => message.id === id)), true) check('queued user messages keep their guidance text', queuedIds.map(id => users.find(message => message.id === id)?.content), ['E2E_APPEND_A', 'E2E_APPEND_B']) check('both appended messages share the owning run identity', new Set(users.filter(message => queuedIds.includes(message.id)).map(message => message.requestId)).size, 1) check('final durable queue is empty', finalQueue.items.length, 0) check('both guidance messages were acknowledged and removed from the queue', beforeSendNow.items.every(item => !finalQueue.items.some(entry => entry.id === item.id)), true) check('final renderer shows both guidance messages and the merged answer', ['E2E_APPEND_A', 'E2E_APPEND_B', 'E2E_ANSWER_MERGED'].every(text => finalText.includes(text)), true) check('no renderer errors were logged', rendererErrors, []) // ---- Independent message after the switch must use the last selected model. setStage('model-switch-followup') await setComposer(main, 'E2E_AFTER_MODEL_SWITCH') await clickComposerSend(main) // The follow-up message is queued while the run is active and dispatched // afterwards, so wait for the request that actually carries its text // instead of trusting the request count. const followUp = await waitForMain(() => requests.find(request => request.index > modelSwitchRequests && request.userTexts.some(text => text.includes('E2E_AFTER_MODEL_SWITCH'))), 'independent message after the model switch') check('the independent message after switching is served by the last selected model', followUp.servedModel, 'C') await waitForMain(async () => { const list = await read(`window.ycode.session.messages(${JSON.stringify(session.id)})`, main) return list.some(message => message.role === 'assistant' && message.modelId === modelCId && message.status === 'done' && String(message.content ?? '').includes('E2E_ANSWER_AFTER_SWITCH')) ? list : undefined }, 'post-switch assistant persisted') // The composer must also release the running state before the final captures. await waitForMain(async () => { const active = await read(`window.ycode.chat.activeRun(${JSON.stringify(session.id)})`, main) return active === null }, 'post-switch run released') await waitFor(`!document.querySelector('.cr-send-button--stop')&&!document.querySelector('[aria-label="停止"]')`, 'composer stop control released', main) const switchMessages = await read(`window.ycode.session.messages(${JSON.stringify(session.id)})`, main) const switchAssistants = switchMessages.filter(message => message.role === 'assistant') const lastAssistant = switchAssistants.at(-1) check('the post-switch assistant records the last selected model identity', lastAssistant?.modelId, modelCId) check('the running turn kept the original model identity', switchAssistants.find(message => message.content?.includes('E2E_ANSWER_ONE'))?.modelId, modelId) check('the guidance turn kept the original model identity', switchAssistants.find(message => message.content?.includes('E2E_ANSWER_MERGED'))?.modelId, modelId) check('the running turn request was served by model A', requests[0].servedModel, 'A') check('the guidance turn request was served by model A', requests[1].servedModel, 'A') check('the post-switch request was served by model C', followUp.servedModel, 'C') // ---- Dark and narrow captures of the real composer. setStage('captures') await read(`(()=>{ const key='ycode:theme'; localStorage.setItem(key,'dark'); window.dispatchEvent(new StorageEvent('storage',{key,newValue:'dark'})); return true })()`, main) // The composer and every other themed surface must follow the shared // theme state, not just the document element. await waitFor(`document.querySelector('.chat-pane[data-cr-theme="dark"]')!==null`, 'dark theme applied to the chat pane', main) check('the dark theme is applied to the real renderer', await read(`document.querySelector('.chat-pane')?.getAttribute('data-cr-theme')??null`, main), 'dark') check('dark conversation screenshot captured', await screenshot(main, 'append-final-dark'), true) main.setSize(720, 900) await pause(300) check('narrow composer keeps the permission control visible and unobscured', await read(`(()=>{ const button=document.querySelector('.yc-source-permission-trigger .cr-permission-setting'); if(!button)return false; const box=button.getBoundingClientRect(); const hit=document.elementFromPoint(box.x+box.width/2,box.y+box.height/2); return box.width>0&&box.height>0&&box.x>=0&&box.right<=innerWidth&&!!hit&&button.contains(hit); })()`, main), true) check('narrow conversation screenshot captured', await screenshot(main, 'append-final-narrow'), true) main.setSize(1336, 902) await pause(200) const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL', scope: 'Production out/main, out/preload, and out/renderer; isolated Electron userData/home; local loopback Anthropic SSE; real Claude SDK/CLI; two UI-queued guidance messages sent through chat.queue.sendNow while the first response was active.', sessionId: session.id, requests: requests.map(request => ({ index: request.index, url: request.url, servedModel: request.servedModel, wireModel: request.wireModel, userTexts: request.userTexts, messageRoles: Array.isArray(request.body.messages) ? request.body.messages.map(message => message?.role) : [], completed: request.completed, aborted: request.aborted, answer: request.answer })), beforeSendNow, afterSendNow, queueTimeline, finalQueue, messages, switchMessages, serverEvents, checks, rendererErrors, screenshots: ['append-queue.png', 'append-final.png', 'append-final-dark.png', 'append-final-narrow.png'], limitations: [ 'Native result user_message_uuids are verified indirectly through persisted guidance identities, merged request content, the second assistant segment, and an empty queue.', 'The loopback server returns plain text only; tool calls, images, cancellation, and upstream provider failures are outside this E2E scope.' ] } await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2)) console.log('APPEND_E2E_' + report.result + ' ' + directory) for (const failure of checks.filter(item => !item.pass)) { console.log('APPEND_E2E_FAILURE ' + JSON.stringify(failure)) } clearTimeout(watchdog) app.exit(report.result === 'PASS' ? 0 : 1) } finally { clearTimeout(watchdog) hidingWindows = false // A gated SSE response keeps the socket open, so close() would wait for // it. Destroy the sockets explicitly instead of hanging the harness. for (const socket of openSockets) socket.destroy() openSockets.clear() await new Promise(resolve => server.close(() => resolve())) } } run().catch(async error => { hidingWindows = false try { await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ stage, error: String(error), stack: error.stack, checks, queueTimeline, requests: requests.map(request => ({ index: request.index, userTexts: request.userTexts, completed: request.completed, aborted: request.aborted, answer: request.answer })), serverEvents, rendererErrors }, null, 2)) } catch { /* The failure report is best effort. */ } console.error('APPEND_E2E_ERROR', error) app.exit(1) }) }