464 lines
33 KiB
JavaScript
464 lines
33 KiB
JavaScript
// Verify YCode conversation state transitions in an isolated Electron renderer.
|
|
if (!process.versions.electron) {
|
|
const env = { ...process.env }
|
|
delete env.ELECTRON_RUN_AS_NODE
|
|
const { spawn, spawnSync } = require('node:child_process')
|
|
const path = require('node:path')
|
|
const server = spawn(process.execPath, [path.join(__dirname, 'verify-ui.mjs'), '0'], {
|
|
env, windowsHide: true, stdio: ['ignore', 'pipe', 'inherit']
|
|
})
|
|
let started = false
|
|
const timer = setTimeout(() => { if (!started) { server.kill(); process.exitCode = 1 } }, 10000)
|
|
server.stdout.on('data', bytes => {
|
|
const port = bytes.toString().match(/127\.0\.0\.1:(\d+)/)?.[1]
|
|
if (!port || started) return
|
|
started = true
|
|
clearTimeout(timer)
|
|
try {
|
|
const result = spawnSync(require('electron'), [__filename], {
|
|
env: { ...env, YCODE_STATE_QA_PORT: port }, windowsHide: true, stdio: 'inherit', timeout: 60000
|
|
})
|
|
process.exitCode = result.status ?? 1
|
|
} finally { server.kill() }
|
|
})
|
|
server.on('error', error => { clearTimeout(timer); console.error(error); process.exitCode = 1 })
|
|
} else {
|
|
const { app, BrowserWindow } = require('electron')
|
|
const fs = require('node:fs/promises')
|
|
const path = require('node:path')
|
|
const assert = require('node:assert/strict')
|
|
app.setPath('userData', path.resolve('.tmp/conversation-states-qa-user-data'))
|
|
app.disableHardwareAcceleration()
|
|
|
|
let win
|
|
const errors = []
|
|
const read = code => win.webContents.executeJavaScript(code)
|
|
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
function maxDurationSeconds(value) {
|
|
return Math.max(...value.split(',').map(part => {
|
|
const duration = Number.parseFloat(part)
|
|
return part.trim().endsWith('ms') ? duration / 1000 : duration
|
|
}))
|
|
}
|
|
async function waitFor(code, label = code) {
|
|
for (let attempt = 0; attempt < 250; attempt++) {
|
|
const value = await read(code)
|
|
if (value) return value
|
|
await pause(40)
|
|
}
|
|
throw new Error('Condition failed: ' + label)
|
|
}
|
|
async function settle() {
|
|
await read('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
|
|
}
|
|
async function waitStableHeight(id, open) {
|
|
const selector = JSON.stringify(toolSelector(id) + ' .cr-tool-exp__content-shell')
|
|
let previous
|
|
let stable = 0
|
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
const height = await read('document.querySelector(' + selector + ')?.getBoundingClientRect().height')
|
|
if (height !== undefined && (open ? height > 1 : height <= 1) && previous !== undefined && Math.abs(height - previous) < 0.5) stable += 1
|
|
else stable = 0
|
|
if (stable >= 3) return height
|
|
previous = height
|
|
await pause(40)
|
|
}
|
|
throw new Error('Height did not settle for ' + id + ' open=' + open)
|
|
}
|
|
async function screenshot(name, selector) {
|
|
await read('document.querySelector(' + JSON.stringify(selector) + ').scrollIntoView({block:"center",behavior:"instant"})')
|
|
await read('document.fonts.ready')
|
|
await settle()
|
|
await new Promise(resolve => { win.webContents.once('paint', resolve); win.webContents.invalidate() })
|
|
await fs.writeFile(path.resolve('.tmp/' + name), (await win.webContents.capturePage()).toPNG())
|
|
}
|
|
async function send(text) {
|
|
await read('(()=>{const input=document.querySelector(".wb-composer__input");const set=Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,"value").set;set.call(input,' + JSON.stringify(text) + ');input.dispatchEvent(new Event("input",{bubbles:true}))})()')
|
|
await waitFor('!document.querySelector(".cr-send-button:not(.cr-send-button--stop)")?.disabled', 'composer accepts text')
|
|
await read('document.querySelector(".cr-send-button:not(.cr-send-button--stop)").click()')
|
|
await waitFor('!!window.__YCODE_CONVERSATION_STATE_TEST__?.lastRunId', 'fixture chat.send created a run')
|
|
return await read('window.__YCODE_CONVERSATION_STATE_TEST__.lastRunId')
|
|
}
|
|
async function emit(runId, event) {
|
|
await read('window.__YCODE_CONVERSATION_STATE_TEST__.emit(' + JSON.stringify({ ...event, runId }) + ')')
|
|
}
|
|
function toolSelector(id) { return '[data-tool-call-id="' + id + '"]' }
|
|
async function toolState(id) {
|
|
return read('(()=>{const root=document.querySelector(' + JSON.stringify(toolSelector(id)) + ');if(!root)return null;const head=root.querySelector(".cr-tool-head__main"),shell=root.querySelector(".cr-tool-exp__content-shell");return{status:root.dataset.toolStatus,loading:!!root.querySelector(".cr-tool-head__primary--loading"),expanded:head?.getAttribute("aria-expanded")??null,hidden:shell?.getAttribute("aria-hidden")??null,height:shell?.getBoundingClientRect().height??null,styleHeight:shell?.style.height??null,transition:shell?getComputedStyle(shell).transition:null,transitionDuration:shell?getComputedStyle(shell).transitionDuration:null,text:root.textContent}})()')
|
|
}
|
|
async function waitTool(id, status, expanded) {
|
|
const selector = JSON.stringify(toolSelector(id))
|
|
let condition = '(()=>{const root=document.querySelector(' + selector + ');return !!root&&root.dataset.toolStatus===' + JSON.stringify(status)
|
|
if (expanded !== undefined) condition += '&&root.querySelector(".cr-tool-head__main")?.getAttribute("aria-expanded")===' + JSON.stringify(expanded ? 'true' : 'false')
|
|
condition += '})()'
|
|
await waitFor(condition, id + ' status=' + status + (expanded === undefined ? '' : ' expanded=' + expanded))
|
|
}
|
|
async function clickTool(id) {
|
|
await read('document.querySelector(' + JSON.stringify(toolSelector(id) + ' .cr-tool-head__main') + ').click()')
|
|
}
|
|
async function stopRun() {
|
|
await read('document.querySelector(".cr-send-button--stop").click()')
|
|
}
|
|
function toolCall(id, name, status, args, extra = {}) {
|
|
return { id, name, args, argsRaw: JSON.stringify(args), status, ...extra }
|
|
}
|
|
async function emitTool(runId, type, call) {
|
|
await emit(runId, { type, call })
|
|
}
|
|
function interactionEvent(runId, id, toolCallId, status = 'pending') {
|
|
return { type: 'interaction', interaction: { id, requestId: id, runId, toolCallId, kind: 'approval', title: 'Fixture approval', status,
|
|
options: [{ optionId: 'allow-once', name: 'Once', kind: 'allow_once' }, { optionId: 'allow-always', name: 'Always', kind: 'allow_always' },
|
|
{ optionId: 'reject-once', name: 'Reject', kind: 'reject_once' }] } }
|
|
}
|
|
function approvalSelector(id) { return '.conversation-input-area__custom-floating .permission-floating[data-pending-id="' + id + '"]' }
|
|
async function waitInteraction(runId, interactionId, status) {
|
|
const run = 'window.__YCODE_CONVERSATION_STATE_TEST__.runs[' + JSON.stringify(runId) + ']'
|
|
const condition = run + '?.events.some(event=>event.type==="interaction"&&event.interaction.id===' + JSON.stringify(interactionId) + '&&event.interaction.status===' + JSON.stringify(status) + ')'
|
|
await waitFor(condition, interactionId + ' interaction event status=' + status)
|
|
}
|
|
async function approvalState(toolId, interactionId) {
|
|
return read('(()=>{const root=document.querySelector(' + JSON.stringify(toolSelector(toolId)) + '),floating=document.querySelector(' + JSON.stringify(approvalSelector(interactionId)) + '),composer=document.querySelector(".conversation-input-area__input-stack"),buttons=floating?[...floating.querySelectorAll(".conversation-high-credit-approval__option")]:[],inlineButtons=root?[...root.querySelectorAll(".cr-tool-btn[data-tool-decision]")]:[];const floatingRect=floating?.getBoundingClientRect(),composerRect=composer?.getBoundingClientRect();return{toolStatus:root?.dataset.toolStatus??null,inlineButtons:inlineButtons.map(button=>({decision:button.dataset.toolDecision,disabled:button.disabled})),floatingCount:document.querySelectorAll(".conversation-input-area__custom-floating .permission-floating").length,floatingForInteraction:!!floating,floatingStatus:floating?.className??null,inConversation:!!document.querySelector(".chat-thread .conversation-high-credit-approval"),aboveComposer:!!floatingRect&&!!composerRect&&floatingRect.bottom<=composerRect.top,floatingGap:floatingRect&&composerRect?composerRect.top-floatingRect.bottom:null,options:buttons.map(button=>({optionId:button.dataset.optionId??null,label:button.textContent,reject:button.classList.contains("conversation-high-credit-approval__option--reject"),disabled:button.disabled}))}})()')
|
|
}
|
|
async function approvalButtonStyles(interactionId) {
|
|
return read('(()=>{const root=document.querySelector(' + JSON.stringify(approvalSelector(interactionId)) + ');return[...(root?.querySelectorAll(".conversation-high-credit-approval__option")??[])].map(button=>{const style=getComputedStyle(button);return{optionId:button.dataset.optionId??null,reject:button.classList.contains("conversation-high-credit-approval__option--reject"),className:button.className,height:style.height,minHeight:style.minHeight,fontSize:style.fontSize,padding:style.padding,color:style.color,backgroundColor:style.backgroundColor}})})()')
|
|
}
|
|
async function clickApproval(interactionId, optionId, twice = false) {
|
|
const selector = optionId.startsWith('reject')
|
|
? approvalSelector(interactionId) + ' .conversation-high-credit-approval__option--reject'
|
|
: approvalSelector(interactionId) + ' .conversation-high-credit-approval__option[data-option-id="' + optionId + '"]'
|
|
await read('(()=>{const button=document.querySelector(' + JSON.stringify(selector) + ');if(!button)throw new Error("Approval action is missing");button.click()' + (twice ? ';button.click()' : '') + '})()')
|
|
}
|
|
async function responseRecords(interactionId) {
|
|
return read('window.__YCODE_CONVERSATION_STATE_TEST__.responses.filter(item=>item.interactionId===' + JSON.stringify(interactionId) + ')')
|
|
}
|
|
async function finishToolRun(runId, call, status) {
|
|
const finalCall = { ...call, status, output: status === 'ok' ? '' : 'fixture permission result', result: status === 'ok' ? { result: { stdout: '', exitCode: 0 } } : { result: { error: 'fixture permission result' } } }
|
|
await emitTool(runId, 'tool_result', finalCall)
|
|
await emit(runId, { type: 'done', content: '', tools: [finalCall] })
|
|
await waitFor('!document.querySelector(".cr-send-button--stop")', 'approved tool run finished')
|
|
}
|
|
async function scrollMetrics() {
|
|
return read('(()=>{const e=document.querySelector(".chat-scroll");return{top:e.scrollTop,height:e.scrollHeight,client:e.clientHeight,gap:e.scrollHeight-e.scrollTop-e.clientHeight,rect:e.getBoundingClientRect().toJSON()}})()')
|
|
}
|
|
async function wheel(deltaY) {
|
|
const rect = (await scrollMetrics()).rect
|
|
await win.webContents.debugger.sendCommand('Input.dispatchMouseEvent', {
|
|
type: 'mouseWheel', x: rect.x + rect.width / 2, y: rect.y + rect.height / 2, deltaX: 0, deltaY
|
|
})
|
|
await pause(80)
|
|
}
|
|
async function atBottom() {
|
|
return waitFor('(()=>{const e=document.querySelector(".chat-scroll");return e&&e.scrollHeight-e.scrollTop-e.clientHeight<3})()', 'conversation timeline reached bottom')
|
|
}
|
|
|
|
app.whenReady().then(async () => {
|
|
await fs.mkdir(path.resolve('.tmp'), { recursive: true })
|
|
win = new BrowserWindow({ show: false, useContentSize: true, width: 1336, height: 901,
|
|
webPreferences: { offscreen: true, contextIsolation: true } })
|
|
win.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) errors.push(String(message))
|
|
})
|
|
await win.loadURL('http://127.0.0.1:' + (process.env.YCODE_STATE_QA_PORT || '8792') + '/?fixture=conversation-states')
|
|
await waitFor('!!document.querySelector(".chat-scroll")&&document.querySelectorAll(".msg--assistant").length===14', 'fixture history rendered')
|
|
await win.webContents.debugger.attach('1.3')
|
|
await win.webContents.debugger.sendCommand('Emulation.setEmulatedMedia', {
|
|
media: 'screen', features: [{ name: 'prefers-reduced-motion', value: 'no-preference' }]
|
|
})
|
|
const normalMotionPreference = await read('matchMedia("(prefers-reduced-motion: reduce)").matches')
|
|
assert.equal(normalMotionPreference, false)
|
|
await atBottom()
|
|
assert.ok((await scrollMetrics()).height > (await scrollMetrics()).client + 500, 'fixture history creates a real scroll range')
|
|
|
|
const scrollRun = await send('scroll-follow')
|
|
const firstText = Array.from({ length: 7 }, (_, index) => 'Streaming paragraph ' + index + ' grows the assistant response while the user changes timeline position. '.repeat(4)).join('\n\n')
|
|
const secondText = Array.from({ length: 5 }, (_, index) => 'Scrolled update ' + index + ' adds new rows below the viewport. '.repeat(5)).join('\n\n')
|
|
const thirdText = Array.from({ length: 4 }, (_, index) => 'Bottom follow update ' + index + ' remains attached to the newest content. '.repeat(5)).join('\n\n')
|
|
await emit(scrollRun, { type: 'text', delta: firstText })
|
|
await atBottom()
|
|
const beforeScroll = await scrollMetrics()
|
|
for (let attempt = 0; attempt < 8 && (await scrollMetrics()).gap <= 80; attempt++) await wheel(-650)
|
|
const userScrolledUp = await scrollMetrics()
|
|
assert.ok(userScrolledUp.gap > 80, 'wheel input moved the conversation away from the bottom')
|
|
await emit(scrollRun, { type: 'text', delta: '\n\n' + secondText })
|
|
await waitFor('document.querySelector(".chat-scroll").scrollHeight>' + Math.floor(userScrolledUp.height), 'new stream content expanded the timeline')
|
|
await settle()
|
|
const stayedScrolledUp = await scrollMetrics()
|
|
assert.ok(Math.abs(stayedScrolledUp.top - userScrolledUp.top) < 8, 'stream updates do not pull a user back down after scrolling up')
|
|
assert.ok(stayedScrolledUp.gap > 80)
|
|
for (let attempt = 0; attempt < 8 && (await scrollMetrics()).gap >= 3; attempt++) await wheel(900)
|
|
await atBottom()
|
|
await emit(scrollRun, { type: 'text', delta: '\n\n' + thirdText })
|
|
await atBottom()
|
|
const scrollContent = firstText + '\n\n' + secondText + '\n\n' + thirdText
|
|
await emit(scrollRun, { type: 'done', content: scrollContent, tools: [] })
|
|
await waitFor('!document.querySelector(".cr-send-button--stop")', 'scroll run finished')
|
|
const scrollFollow = { beforeScroll: beforeScroll.top, userScrolledUp: userScrolledUp.top, afterUpdate: stayedScrolledUp.top,
|
|
returnedToBottom: true, followedAfterReturn: (await scrollMetrics()).gap < 3 }
|
|
assert.equal(scrollFollow.followedAfterReturn, true)
|
|
|
|
const autoRun = await send('write-auto-collapse')
|
|
const autoContent = 'first line\nsecond line\nthird line'
|
|
const autoRunning = toolCall('write-auto', 'write_to_file', 'running', { path: 'src/auto.ts' }, { result: { result: { newContent: 'first line\nsecond line' } } })
|
|
await emitTool(autoRun, 'tool_call', autoRunning)
|
|
await waitTool('write-auto', 'running', true)
|
|
const autoOpen = await toolState('write-auto')
|
|
await emitTool(autoRun, 'tool_result', { ...autoRunning, status: 'ok', result: { result: { newContent: autoContent } }, output: 'written' })
|
|
await waitTool('write-auto', 'success', false)
|
|
const autoClosed = await toolState('write-auto')
|
|
assert.equal(autoOpen.expanded, 'true')
|
|
assert.equal(autoClosed.hidden, 'true')
|
|
await emit(autoRun, { type: 'done', content: '', tools: [{ ...autoRunning, status: 'ok', result: { result: { newContent: autoContent } }, output: 'written' }] })
|
|
await waitFor('!document.querySelector(".cr-send-button--stop")', 'automatic-collapse write run finished')
|
|
|
|
const manualRun = await send('write-manual-collapse')
|
|
const manualFirst = toolCall('write-manual', 'write_to_file', 'running', { path: 'src/manual.ts' }, { result: { result: { newContent: 'partial content' } } })
|
|
await emitTool(manualRun, 'tool_call', manualFirst)
|
|
await waitTool('write-manual', 'running', true)
|
|
const manualOpen = await toolState('write-manual')
|
|
assert.ok(manualOpen.height > 0, 'small write expands to a measurable height')
|
|
const normalTransitionSeconds = maxDurationSeconds(manualOpen.transitionDuration)
|
|
assert.ok(Math.abs(normalTransitionSeconds - 0.28) < 0.01, 'normal small writes retain the source 0.28s height transition')
|
|
assert.ok(manualOpen.transition.includes('cubic-bezier(0.33, 1, 0.68, 1)'), 'normal small writes retain the source height easing curve')
|
|
await clickTool('write-manual')
|
|
await waitTool('write-manual', 'running', false)
|
|
await waitStableHeight('write-manual', false)
|
|
const manualClosed = await toolState('write-manual')
|
|
assert.ok(manualClosed.height <= 1, 'small write collapses to zero height')
|
|
const manualUpdate = { ...manualFirst, result: { result: { newContent: 'partial content\nmore streamed content' } }, output: 'partial content' }
|
|
await emitTool(manualRun, 'tool_call', manualUpdate)
|
|
await waitFor('document.querySelector(' + JSON.stringify(toolSelector('write-manual')) + ').textContent.includes("more streamed content")', 'manual write received another streaming tool update')
|
|
const manualAfterUpdate = await toolState('write-manual')
|
|
assert.equal(manualAfterUpdate.expanded, 'false', 'a same-position stream update must not reopen a manually collapsed write view')
|
|
await clickTool('write-manual')
|
|
await waitTool('write-manual', 'running', true)
|
|
await waitStableHeight('write-manual', true)
|
|
const manualReopened = await toolState('write-manual')
|
|
assert.ok(manualReopened.height > 0, 'small write can be expanded again after a stream update')
|
|
await stopRun()
|
|
await waitTool('write-manual', 'cancelled', false)
|
|
|
|
const commandRun = await send('command-states')
|
|
const commandRunning = toolCall('command-running', 'Bash', 'running', { command: 'npm run build' })
|
|
await emitTool(commandRun, 'tool_call', commandRunning)
|
|
await waitTool('command-running', 'running', false)
|
|
await clickTool('command-running')
|
|
await waitFor('document.querySelector(' + JSON.stringify(toolSelector('command-running') + ' .cr-tool-head__primary--loading') + ')', 'running command loading state rendered')
|
|
const commandRunningState = await toolState('command-running')
|
|
assert.equal(commandRunningState.loading, true)
|
|
await emitTool(commandRun, 'tool_result', { ...commandRunning, status: 'error', output: 'fixture command failure', result: { result: { stderr: 'fixture stderr', exitCode: 2, error: 'fixture command failure' } } })
|
|
await waitTool('command-running', 'error')
|
|
await waitFor('document.querySelector(' + JSON.stringify(toolSelector('command-running') + ' .cr-tool-exec__output') + ')', 'error command output rendered')
|
|
const commandErrorState = await toolState('command-running')
|
|
|
|
const emptyCommand = toolCall('command-empty-success', 'execute_command', 'running', { command: 'true' })
|
|
await emitTool(commandRun, 'tool_call', emptyCommand)
|
|
await waitTool('command-empty-success', 'running', false)
|
|
await emitTool(commandRun, 'tool_result', { ...emptyCommand, status: 'ok', output: '', result: { result: { stdout: '', stderr: '', exitCode: 0 } } })
|
|
await waitTool('command-empty-success', 'success')
|
|
await clickTool('command-empty-success')
|
|
await waitFor('document.querySelector(' + JSON.stringify(toolSelector('command-empty-success') + ' .cr-tool-exec__ok') + ')', 'empty successful command state rendered')
|
|
const commandEmptySuccessState = await toolState('command-empty-success')
|
|
|
|
const cancelledCommand = toolCall('command-cancelled', 'run_command', 'running', { command: 'sleep 60' })
|
|
await emitTool(commandRun, 'tool_call', cancelledCommand)
|
|
await waitTool('command-cancelled', 'running', false)
|
|
await stopRun()
|
|
await waitTool('command-cancelled', 'cancelled')
|
|
await waitFor('document.querySelector(".chat-thread")?.textContent.includes("用户已取消")', 'cancel terminal message rendered')
|
|
const commandCancelledState = await toolState('command-cancelled')
|
|
assert.equal(commandRunningState.status, 'running')
|
|
assert.equal(commandErrorState.status, 'error')
|
|
assert.equal(commandEmptySuccessState.status, 'success')
|
|
assert.equal(commandCancelledState.status, 'cancelled')
|
|
|
|
const fileApprovalRun = await send('approval-file-accept')
|
|
const fileApprovalTool = toolCall('approval-file-tool', 'write_to_file', 'running', { path: 'src/approval.ts' }, { output: '', result: { result: { newContent: 'pending file body' } } })
|
|
await emitTool(fileApprovalRun, 'tool_call', fileApprovalTool)
|
|
await waitTool('approval-file-tool', 'running', true)
|
|
await emit(fileApprovalRun, interactionEvent(fileApprovalRun, 'approval-file-request', 'approval-file-tool'))
|
|
await waitTool('approval-file-tool', 'pending')
|
|
await waitFor('document.querySelector(' + JSON.stringify(approvalSelector('approval-file-request')) + ')', 'file approval floats above the composer')
|
|
const filePending = await approvalState('approval-file-tool', 'approval-file-request')
|
|
assert.equal(filePending.toolStatus, 'pending')
|
|
assert.equal(filePending.inlineButtons.length, 0, 'file approval actions are not rendered inside the tool row')
|
|
assert.equal(filePending.floatingForInteraction, true)
|
|
assert.equal(filePending.floatingCount, 1, 'one active approval is shown at a time')
|
|
assert.equal(filePending.inConversation, false, 'approval cards are not rendered in the conversation stream')
|
|
assert.equal(filePending.aboveComposer, true, 'approval card sits above the composer')
|
|
assert.ok(filePending.floatingGap >= 0 && filePending.floatingGap <= 12, 'approval card stays visually attached to the composer')
|
|
assert.deepEqual(filePending.options.map(option => option.reject ? 'reject' : option.optionId), ['allow-once', 'allow-always', 'reject'])
|
|
const fileButtonStyles = await approvalButtonStyles('approval-file-request')
|
|
for (const button of fileButtonStyles) {
|
|
assert.equal(button.height, '44px')
|
|
assert.equal(button.fontSize, '14px')
|
|
assert.equal(button.padding, '8px 12px')
|
|
}
|
|
const fileAcceptStyle = fileButtonStyles.find(button => button.optionId === 'allow-once')
|
|
const fileRejectStyle = fileButtonStyles.find(button => button.reject)
|
|
assert.notEqual(fileAcceptStyle.color, fileRejectStyle.color)
|
|
await screenshot('conversation-file-approval-floating-2026-10-20.png', '.conversation-input-area__custom-floating')
|
|
|
|
await read('window.__YCODE_CONVERSATION_STATE_TEST__.queueResponse("failure")')
|
|
await clickApproval('approval-file-request', 'allow-once')
|
|
await waitFor('document.querySelector(' + JSON.stringify(approvalSelector('approval-file-request') + ' .conversation-high-credit-approval__error') + ')', 'failed floating approval response surfaced')
|
|
const fileRetry = await approvalState('approval-file-tool', 'approval-file-request')
|
|
assert.ok(fileRetry.options.some(option => option.optionId === 'allow-once' && !option.disabled), 'failed approval can be retried')
|
|
const fileFirstAttempt = await responseRecords('approval-file-request')
|
|
assert.equal(fileFirstAttempt.length, 1)
|
|
assert.equal(fileFirstAttempt[0].response.optionId, 'allow-once', 'file accept selects once before always')
|
|
|
|
await read('window.__YCODE_CONVERSATION_STATE_TEST__.queueResponse("defer")')
|
|
await clickApproval('approval-file-request', 'allow-once', true)
|
|
await waitFor('(()=>{const buttons=[...document.querySelectorAll(' + JSON.stringify(approvalSelector('approval-file-request') + ' .conversation-high-credit-approval__option') + ')];return buttons.length>0&&buttons.every(button=>button.disabled)})()', 'responding disables all floating approval options')
|
|
const fileDeferredResponses = await responseRecords('approval-file-request')
|
|
assert.equal(fileDeferredResponses.length, 2, 'double click submits only one request while the first response is deferred')
|
|
await read('window.__YCODE_CONVERSATION_STATE_TEST__.releaseDeferredResponse()')
|
|
await waitInteraction(fileApprovalRun, 'approval-file-request', 'answered')
|
|
const fileAcceptedResponses = await responseRecords('approval-file-request')
|
|
assert.deepEqual(fileAcceptedResponses.map(item => item.response.optionId), ['allow-once', 'allow-once'])
|
|
await waitFor('!document.querySelector(' + JSON.stringify(approvalSelector('approval-file-request')) + ')', 'accepted file approval floating card is removed')
|
|
await stopRun()
|
|
await waitFor('!document.querySelector(".cr-send-button--stop")', 'approval fixture run cancelled')
|
|
|
|
const commandApprovalResults = []
|
|
let commandButtonStyles
|
|
for (const scenario of [
|
|
{ decision: 'allow', optionId: 'allow-once', status: 'answered', toolStatus: 'ok', arrival: 'early' },
|
|
{ decision: 'skip', optionId: 'reject-once', status: 'rejected', toolStatus: 'error', arrival: 'delayed' },
|
|
{ decision: 'reject', optionId: 'reject-once', status: 'rejected', toolStatus: 'error', arrival: 'delayed' }
|
|
]) {
|
|
const toolId = 'approval-command-' + scenario.decision + '-tool'
|
|
const interactionId = 'approval-command-' + scenario.decision + '-request'
|
|
const call = toolCall(toolId, 'Bash', 'running', { command: 'fixture command' }, { output: '' })
|
|
if (scenario.arrival === 'early') {
|
|
await read('window.__YCODE_CONVERSATION_STATE_TEST__.queueEarlyBatch(' + JSON.stringify([
|
|
{ type: 'tool_call', call }, interactionEvent('', interactionId, toolId)
|
|
]) + ')')
|
|
}
|
|
const runId = await send('approval-command-' + scenario.decision)
|
|
if (scenario.arrival === 'delayed') {
|
|
await emitTool(runId, 'tool_call', call)
|
|
await waitTool(toolId, 'running', false)
|
|
await emit(runId, interactionEvent(runId, interactionId, toolId))
|
|
await waitTool(toolId, 'pending')
|
|
} else {
|
|
await waitTool(toolId, 'pending')
|
|
}
|
|
const commandPending = await toolState(toolId)
|
|
await waitFor('document.querySelector(' + JSON.stringify(approvalSelector(interactionId)) + ')', 'command approval floats above the composer')
|
|
if (scenario.arrival === 'early') commandButtonStyles = await approvalButtonStyles(interactionId)
|
|
const commandApproval = await approvalState(toolId, interactionId)
|
|
assert.equal(commandApproval.inlineButtons.length, 0, 'command approval actions are not rendered inside the tool row')
|
|
assert.equal(commandApproval.inConversation, false, 'command approval cards are not rendered in the conversation stream')
|
|
assert.equal(commandApproval.aboveComposer, true)
|
|
if (scenario.arrival === 'early') await screenshot('conversation-command-approval-floating-2026-10-20.png', '.conversation-input-area__custom-floating')
|
|
await clickApproval(interactionId, scenario.optionId)
|
|
await waitInteraction(runId, interactionId, scenario.status)
|
|
await waitFor('!document.querySelector(' + JSON.stringify(approvalSelector(interactionId)) + ')', 'command approval floating card is removed')
|
|
const responses = await responseRecords(interactionId)
|
|
assert.equal(responses.length, 1)
|
|
assert.equal(responses[0].response.optionId, scenario.optionId)
|
|
const interaction = await read('window.__YCODE_CONVERSATION_STATE_TEST__.runs[' + JSON.stringify(runId) + '].interactions[' + JSON.stringify(interactionId) + ']')
|
|
assert.equal(interaction.selectedOptionId, scenario.optionId)
|
|
await finishToolRun(runId, call, scenario.toolStatus)
|
|
commandApprovalResults.push({ decision: scenario.decision, arrival: scenario.arrival,
|
|
initialExpanded: commandPending.expanded, optionId: responses[0].response.optionId, status: interaction.status })
|
|
}
|
|
for (const button of commandButtonStyles) {
|
|
assert.equal(button.height, '44px')
|
|
assert.equal(button.fontSize, '14px')
|
|
assert.equal(button.padding, '8px 12px')
|
|
}
|
|
const commandRunStyle = commandButtonStyles.find(button => button.optionId === 'allow-once')
|
|
const commandRejectStyle = commandButtonStyles.find(button => button.reject)
|
|
assert.ok(commandRunStyle)
|
|
assert.ok(commandRejectStyle)
|
|
assert.notEqual(commandRunStyle.color, commandRejectStyle.color)
|
|
|
|
const largeRun = await send('write-large-motion')
|
|
const largeText = Array.from({ length: 180 }, (_, index) => 'export const value' + index + ' = ' + index + ';').join('\n')
|
|
const largeCall = toolCall('write-large', 'write_to_file', 'running', { path: 'src/large.ts' }, { result: { result: { newContent: largeText } } })
|
|
await emitTool(largeRun, 'tool_call', largeCall)
|
|
await waitTool('write-large', 'running', true)
|
|
await settle()
|
|
const largeOpen = await toolState('write-large')
|
|
assert.ok(largeOpen.height > 0, 'large write shell has a real open height')
|
|
assert.equal(largeOpen.styleHeight, 'auto', 'large write uses a static open height')
|
|
assert.equal(largeOpen.transition, 'none', 'large write disables its transition')
|
|
await clickTool('write-large')
|
|
await waitTool('write-large', 'running', false)
|
|
const largeClosed = await toolState('write-large')
|
|
assert.ok(largeClosed.height <= 1, 'large write shell reaches zero height when collapsed')
|
|
assert.equal(largeClosed.styleHeight, '0px')
|
|
await clickTool('write-large')
|
|
await waitTool('write-large', 'running', true)
|
|
const largeReopened = await toolState('write-large')
|
|
assert.ok(largeReopened.height > 0)
|
|
assert.equal(largeReopened.styleHeight, 'auto')
|
|
await stopRun()
|
|
await waitTool('write-large', 'cancelled')
|
|
|
|
await win.webContents.debugger.sendCommand('Emulation.setEmulatedMedia', {
|
|
media: 'screen', features: [{ name: 'prefers-reduced-motion', value: 'reduce' }]
|
|
})
|
|
assert.equal(await read('matchMedia("(prefers-reduced-motion: reduce)").matches'), true)
|
|
const reducedRun = await send('write-reduced-motion')
|
|
const reducedCall = toolCall('write-reduced', 'write_to_file', 'running', { path: 'src/reduced.ts' }, { result: { result: { newContent: 'small file\nnext line' } } })
|
|
await emitTool(reducedRun, 'tool_call', reducedCall)
|
|
await waitTool('write-reduced', 'running', true)
|
|
await settle()
|
|
const reducedOpen = await toolState('write-reduced')
|
|
assert.ok(reducedOpen.height > 0)
|
|
assert.equal(reducedOpen.transition, 'none', 'reduced motion disables the write shell transition')
|
|
await clickTool('write-reduced')
|
|
await waitTool('write-reduced', 'running', false)
|
|
const reducedClosed = await toolState('write-reduced')
|
|
assert.ok(reducedClosed.height <= 1, 'reduced-motion shell reaches zero height when collapsed')
|
|
await stopRun()
|
|
await waitTool('write-reduced', 'cancelled')
|
|
|
|
const report = {
|
|
scope: 'YCode offline fixture state regression only; WorkBuddy parity is not assessed',
|
|
normalMotionPreferenceReduced: normalMotionPreference,
|
|
scrollFollow,
|
|
writeFile: { autoOpen: autoOpen.expanded, successClosed: autoClosed.expanded, manualAfterStream: manualAfterUpdate.expanded,
|
|
manualMotion: { openHeight: manualOpen.height, transition: manualOpen.transition, transitionDuration: manualOpen.transitionDuration,
|
|
maxTransitionSeconds: normalTransitionSeconds, closedHeight: manualClosed.height, reopenedHeight: manualReopened.height },
|
|
largeOpen: { height: largeOpen.height, styleHeight: largeOpen.styleHeight, transition: largeOpen.transition },
|
|
largeClosed: { height: largeClosed.height, styleHeight: largeClosed.styleHeight },
|
|
largeReopened: { height: largeReopened.height, styleHeight: largeReopened.styleHeight },
|
|
reducedOpen: { height: reducedOpen.height, transition: reducedOpen.transition, transitionDuration: reducedOpen.transitionDuration },
|
|
reducedClosed: { height: reducedClosed.height, transition: reducedClosed.transition } },
|
|
commands: { running: commandRunningState.status, error: commandErrorState.status,
|
|
emptySuccess: commandEmptySuccessState.status, cancelled: commandCancelledState.status },
|
|
approvals: { filePending: { toolStatus: filePending.toolStatus, inlineDecisionCount: filePending.inlineButtons.length,
|
|
floatingCount: filePending.floatingCount, aboveComposer: filePending.aboveComposer, floatingGap: filePending.floatingGap,
|
|
inConversation: filePending.inConversation },
|
|
fileButtonStyles, commandButtonStyles,
|
|
fileRetryOptionIds: fileAcceptedResponses.map(item => item.response.optionId), deferredDoubleClickRequestCount: fileDeferredResponses.length,
|
|
commandDecisions: commandApprovalResults,
|
|
commandSkipRejectTransportLimitation: { skip: 'reject-once', reject: 'reject-once', distinctKernelResponse: false } },
|
|
consoleErrors: errors
|
|
}
|
|
assert.deepEqual(errors, [], 'renderer should produce zero console errors')
|
|
await fs.writeFile(path.resolve('.tmp/conversation-states-ui-report.json'), JSON.stringify(report, null, 2))
|
|
console.log(JSON.stringify(report))
|
|
win.destroy()
|
|
app.quit()
|
|
}).catch(async error => {
|
|
console.error(error)
|
|
console.error(JSON.stringify(errors))
|
|
if (win && !win.isDestroyed()) {
|
|
try {
|
|
const diagnostics = await read('({body:document.body.innerText.slice(-12000),state:window.__YCODE_CONVERSATION_STATE_TEST__?.runs})')
|
|
await fs.writeFile(path.resolve('.tmp/conversation-states-ui-failure.json'), JSON.stringify({ error: String(error), diagnostics, consoleErrors: errors }, null, 2))
|
|
} catch { /* Keep the original failure if the renderer has already exited. */ }
|
|
win.destroy()
|
|
}
|
|
app.exit(1)
|
|
})
|
|
}
|