chore: establish YCode replication baseline and TODO
This commit is contained in:
commit
6dc59f89ba
1118 files changed
+196002
No files matched your search
@@ -0,0 +1,477 @@
|
||||
// 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-high-credit-approval[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)) + '),generic=document.querySelector(' + JSON.stringify(approvalSelector(interactionId)) + ');const buttons=root?[...root.querySelectorAll(".cr-tool-btn[data-tool-decision]")]:[];return{toolStatus:root?.dataset.toolStatus??null,inlineButtons:buttons.map(button=>({decision:button.dataset.toolDecision,disabled:button.disabled})),genericCount:document.querySelectorAll(".conversation-high-credit-approval").length,genericForInteraction:!!generic,genericStatus:generic?.className??null}})()')
|
||||
}
|
||||
async function approvalButtonStyles(toolId) {
|
||||
return read('(()=>{const root=document.querySelector(' + JSON.stringify(toolSelector(toolId)) + ');const alphaOf=color=>{const canvas=document.createElement("canvas"),context=canvas.getContext("2d");context.clearRect(0,0,1,1);context.fillStyle=color;context.fillRect(0,0,1,1);return context.getImageData(0,0,1,1).data[3]/255};return[...(root?.querySelectorAll(".cr-tool-btn[data-tool-decision]")??[])].map(button=>{const style=getComputedStyle(button);return{decision:button.dataset.toolDecision,className:button.className,height:style.height,minWidth:style.minWidth,fontSize:style.fontSize,padding:style.padding,color:style.color,backgroundColor:style.backgroundColor,backgroundAlpha:alphaOf(style.backgroundColor)}})})()')
|
||||
}
|
||||
async function clickApproval(toolId, decision, twice = false) {
|
||||
const selector = toolSelector(toolId) + ' .cr-tool-btn[data-tool-decision="' + decision + '"]'
|
||||
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(".msg--assistant .msg__status")', '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 emit(fileApprovalRun, { type: 'text', delta: 'approval separator' })
|
||||
const unrelatedTool = toolCall('approval-unrelated-tool', 'mcp_call_tool', 'running', { server_name: 'fixture', tool_name: 'unrelated' }, { output: '' })
|
||||
await emitTool(fileApprovalRun, 'tool_call', unrelatedTool)
|
||||
await emit(fileApprovalRun, interactionEvent(fileApprovalRun, 'approval-unrelated-request', 'approval-unrelated-tool'))
|
||||
await waitInteraction(fileApprovalRun, 'approval-unrelated-request', 'pending')
|
||||
await waitFor('document.querySelector(' + JSON.stringify(toolSelector('approval-file-tool') + ' .cr-tool-btn[data-tool-decision="allow"]') + ')', 'file approval action rendered')
|
||||
await waitFor('document.querySelector(' + JSON.stringify(approvalSelector('approval-unrelated-request')) + ')', 'unrelated approval remains generic')
|
||||
const filePending = await approvalState('approval-file-tool', 'approval-file-request')
|
||||
assert.equal(filePending.toolStatus, 'pending')
|
||||
assert.deepEqual(filePending.inlineButtons.map(button => button.decision).sort(), ['allow', 'reject'])
|
||||
assert.equal(filePending.genericCount, 1, 'only the unrelated request uses a generic confirmation card')
|
||||
assert.equal(filePending.genericForInteraction, false, 'the inline file request has no duplicate generic confirmation')
|
||||
const fileButtonStyles = await approvalButtonStyles('approval-file-tool')
|
||||
for (const button of fileButtonStyles) {
|
||||
assert.equal(button.height, '20px')
|
||||
assert.equal(button.minWidth, '52px')
|
||||
assert.equal(button.fontSize, '12px')
|
||||
assert.equal(button.padding, '0px 10px')
|
||||
}
|
||||
const fileAcceptStyle = fileButtonStyles.find(button => button.decision === 'allow')
|
||||
const fileRejectStyle = fileButtonStyles.find(button => button.decision === 'reject')
|
||||
assert.ok(fileAcceptStyle.backgroundAlpha > 0)
|
||||
assert.ok(fileRejectStyle.backgroundAlpha > 0)
|
||||
assert.notEqual(fileAcceptStyle.backgroundColor, fileRejectStyle.backgroundColor)
|
||||
assert.notEqual(fileAcceptStyle.color, fileRejectStyle.color)
|
||||
const unrelatedPending = await approvalState('approval-unrelated-tool', 'approval-unrelated-request')
|
||||
assert.equal(unrelatedPending.genericForInteraction, true)
|
||||
await screenshot('conversation-file-approval-2026-10-15.png', toolSelector('approval-file-tool'))
|
||||
|
||||
await read('window.__YCODE_CONVERSATION_STATE_TEST__.queueResponse("failure")')
|
||||
await clickApproval('approval-file-tool', 'allow')
|
||||
await waitFor('document.querySelector(".msg--assistant .msg__status--error[role=\\"alert\\"]")', 'failed file approval response surfaced')
|
||||
const fileRetry = await approvalState('approval-file-tool', 'approval-file-request')
|
||||
assert.ok(fileRetry.inlineButtons.some(button => button.decision === 'allow' && !button.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-tool', 'allow', true)
|
||||
await waitFor('(()=>{const root=document.querySelector(' + JSON.stringify(toolSelector('approval-file-tool')) + '),buttons=root?[...root.querySelectorAll(".cr-tool-btn[data-tool-decision]")]:[];return buttons.length>0&&buttons.every(button=>button.disabled)})()', 'responding disables all file approval buttons')
|
||||
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(toolSelector('approval-file-tool') + ' .cr-tool-btn[data-tool-decision]') + ')', 'accepted file approval actions are removed')
|
||||
assert.ok(await read('document.querySelector(' + JSON.stringify(approvalSelector('approval-unrelated-request')) + ')'), 'unrelated generic approval remains visible')
|
||||
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', false)
|
||||
} else {
|
||||
await waitTool(toolId, 'pending', true)
|
||||
}
|
||||
const commandPending = await toolState(toolId)
|
||||
assert.equal(commandPending.expanded, scenario.arrival === 'early' ? 'true' : 'false')
|
||||
await waitFor('document.querySelector(' + JSON.stringify(toolSelector(toolId) + ' .cr-tool-btn[data-tool-decision="' + scenario.decision + '"]') + ')', 'command approval decision rendered')
|
||||
if (scenario.arrival === 'early') commandButtonStyles = await approvalButtonStyles(toolId)
|
||||
if (scenario.arrival === 'early') await screenshot('conversation-command-approval-2026-10-15.png', toolSelector(toolId))
|
||||
assert.ok(!await read('document.querySelector(' + JSON.stringify(approvalSelector(interactionId)) + ')'), 'inline command request has no generic duplicate')
|
||||
if (scenario.arrival === 'delayed') {
|
||||
await clickTool(toolId)
|
||||
await waitTool(toolId, 'pending', true)
|
||||
}
|
||||
await clickApproval(toolId, scenario.decision)
|
||||
await waitInteraction(runId, interactionId, scenario.status)
|
||||
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, '20px')
|
||||
assert.equal(button.minWidth, '52px')
|
||||
assert.equal(button.fontSize, '12px')
|
||||
assert.equal(button.padding, '0px 10px')
|
||||
}
|
||||
const commandRunStyle = commandButtonStyles.find(button => button.decision === 'allow')
|
||||
const commandSkipStyle = commandButtonStyles.find(button => button.decision === 'skip')
|
||||
const commandRejectStyle = commandButtonStyles.find(button => button.decision === 'reject')
|
||||
assert.ok(commandRunStyle.backgroundAlpha > 0)
|
||||
assert.ok(commandRejectStyle.backgroundAlpha > 0)
|
||||
assert.notEqual(commandRunStyle.backgroundColor, commandRejectStyle.backgroundColor)
|
||||
assert.ok(commandSkipStyle.className.includes('cr-tool-btn--default'))
|
||||
assert.ok(commandSkipStyle.backgroundAlpha > 0)
|
||||
assert.notEqual(commandSkipStyle.backgroundColor, commandRunStyle.backgroundColor)
|
||||
assert.notEqual(commandSkipStyle.backgroundColor, commandRejectStyle.backgroundColor)
|
||||
assert.notEqual(commandSkipStyle.color, commandRunStyle.color)
|
||||
assert.notEqual(commandSkipStyle.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, inlineDecisions: filePending.inlineButtons.map(button => button.decision), genericCount: filePending.genericCount },
|
||||
fileButtonStyles, commandButtonStyles,
|
||||
fileRetryOptionIds: fileAcceptedResponses.map(item => item.response.optionId), deferredDoubleClickRequestCount: fileDeferredResponses.length,
|
||||
unrelatedApprovalRemainsGeneric: unrelatedPending.genericForInteraction, 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)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user