762 lines
33 KiB
JavaScript
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)
|
|
})
|
|
}
|