Files
ycode/scripts/verify-claude-append-app.cjs
T

762 lines
33 KiB
JavaScript

// 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)
})
}