fix(B01): retain native card state and restore mode and confirmation styles

This commit is contained in:
Codex committed 2026-10-21 13:41:18 +09:00
1 parent ddd677bae3
commit 1ed71b63b5
14 files changed
+407 -37

No files matched your search

@@ -0,0 +1,99 @@
/** F01 acceptance reproduction extended through the real reducer and MessageBubble.
* No Electron, model, SDK, or external service; DOM behavior, not visual parity.
*/
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import type { ChatMessage, ChatEvent, MessagePart } from '../src/shared/types'
const { JSDOM } = createRequire(process.cwd() + '/package.json')(process.env.YCODE_TEST_DOM_MODULE || 'jsdom')
const dom = new JSDOM('<!doctype html><html><body><main id="root"></main></body></html>', { pretendToBeVisual: true, url: 'http://localhost/' })
for (const key of ['window', 'document', 'Node', 'Element', 'HTMLElement', 'MouseEvent', 'KeyboardEvent', 'Event']) Object.defineProperty(globalThis, key, { value: dom.window[key], configurable: true })
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true })
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
const { act } = require('react') as typeof import('react')
const { createRoot } = require('react-dom/client') as typeof import('react-dom/client')
const MessageBubble = require('../src/renderer/src/components/MessageBubble').default as typeof import('../src/renderer/src/components/MessageBubble').default
const { applyAssistantEvent } = require('../src/shared/message-parts') as typeof import('../src/shared/message-parts')
const { completedFoldPartIdentity } = require('../src/renderer/src/workbuddy/conversation/completed-fold') as typeof import('../src/renderer/src/workbuddy/conversation/completed-fold')
const root = createRoot(document.getElementById('root')!)
const plan = '.cb-plan-header-left'
const task = '.cb-task-result-item-title'
const outer = '.cr-collapse--completed > .cr-collapse__header'
const nodes = (selector: string) => [...document.querySelectorAll(selector)]
const get = (selector: string, index = 0) => { const node = nodes(selector)[index]; assert.ok(node, `${selector}[${index}]`); return node }
const states = () => ({ plan: nodes(plan).map(node => node.getAttribute('aria-expanded')), task: nodes(task).map(node => node.getAttribute('aria-expanded')) })
async function render(message: ChatMessage) { await act(async () => root.render(<MessageBubble message={message} />)) }
async function click(selector: string, index = 0) { await act(async () => get(selector, index).dispatchEvent(new MouseEvent('click', { bubbles: true }))) }
type EventInput = ChatEvent extends infer E ? E extends ChatEvent ? Omit<E, 'runId'> : never : never
const event = (value: EventInput) => ({ runId: 'native-identity', ...value }) as ChatEvent
const planPart = (id: string): Extract<MessagePart, { type: 'plan' }> => ({ type: 'plan', plan: { id, title: `Plan ${id}`, steps: [] } })
const taskPart = (id: string): Extract<MessagePart, { type: 'task' }> => ({ type: 'task', task: { id, title: `Task ${id}`, status: 'success', summary: `Result ${id}` } })
async function main() {
// All native business IDs, including non-disclosure image/artifact parts, are stable.
const stableParts: MessagePart[] = [planPart('same'), taskPart('same'), { type: 'artifact', artifact: { id: 'same', name: 'File', path: '/file' } }, { type: 'image', id: 'same', url: '/image' }, { type: 'reasoning', id: 'same', text: 'Thought' }, { type: 'tool', toolId: 'same' }]
const identities = stableParts.map(part => completedFoldPartIdentity(part, 1))
assert.equal(new Set(identities).size, stableParts.length, 'part types cannot share identity')
stableParts.forEach((part, i) => assert.equal(completedFoldPartIdentity(part, 99), identities[i], `${part.type} ignores position`))
assert.notEqual(completedFoldPartIdentity(planPart('1'), 1), completedFoldPartIdentity({ type: 'plan', plan: {} }, 1), 'business IDs cannot collide with fallback indices')
assert.equal(completedFoldPartIdentity({ type: 'future', id: 'native' }, 1), completedFoldPartIdentity({ type: 'future', id: 'native' }, 2), 'unknown top-level IDs remain supported')
let message: ChatMessage = { id: 'native-one', role: 'assistant', createdAt: 1, content: '', status: 'streaming', parts: [] }
// Exact acceptance event sequence: two text chunks separated by reasoning.
const streamingEvents: EventInput[] = [
{ type: 'text', delta: 'Draft one' },
{ type: 'part', part: { type: 'reasoning', id: 'r', text: 'Thought', finished: true } },
{ type: 'text', delta: 'Draft two' },
{ type: 'part', part: planPart('p') },
{ type: 'part', part: taskPart('x') }
]
for (const value of streamingEvents) message = applyAssistantEvent(message, event(value))
assert.deepEqual(message.parts?.map(part => part.type), ['text', 'reasoning', 'text', 'plan', 'task'])
await render(message)
assert.deepEqual(states(), { plan: ['true'], task: ['true'] })
await click(plan); await click(task)
const collapsed = { plan: ['false'], task: ['false'] }
assert.deepEqual(states(), collapsed)
message = applyAssistantEvent(message, event({ type: 'done', content: 'Corrected final prose', tools: [], turnDurationMs: 1000 }))
assert.deepEqual(message.parts?.map(part => part.type), ['text', 'reasoning', 'plan', 'task'], 'real done reducer coalesces text')
await render(message)
assert.deepEqual(states(), collapsed, 'terminal correction preserves choices')
for (let repeat = 0; repeat < 2; repeat++) {
await click(outer); assert.deepEqual(states(), collapsed, 'outer expansion preserves choices')
await click(outer); assert.deepEqual(states(), collapsed, 'outer collapse preserves choices')
}
// Insert new same-kind cards before originals, then reorder. New cards must not inherit state.
const prefix = message.parts!.filter(part => part.type !== 'plan' && part.type !== 'task')
message = { ...message, parts: [...prefix, planPart('p2'), taskPart('x2'), planPart('p'), taskPart('x')] }
await render(message)
assert.deepEqual(states(), { plan: ['true', 'false'], task: ['true', 'false'] }, 'insertions retain original identity only')
await click(plan, 1) // explicitly open original; keep task closed
await click(plan, 0) // close new plan; leave new task open
message = { ...message, parts: [taskPart('x'), planPart('p'), ...prefix, taskPart('x2'), planPart('p2')] }
await render(message)
assert.deepEqual(states(), { plan: ['true', 'false'], task: ['false', 'true'] }, 'reorder retains independent choices')
await click(task, 1)
await click(outer); await click(outer)
assert.deepEqual(states(), { plan: ['true', 'false'], task: ['false', 'false'] }, 'reordered choices survive outer reparenting')
await render({ ...message, id: 'native-two' })
assert.deepEqual(states(), { plan: ['true', 'true'], task: ['true', 'true'] }, 'same part IDs in another message are isolated')
assert.equal(get(outer).getAttribute('aria-expanded'), 'false')
// Legacy persisted payloads may predate required ID fields. Keep their positional fallback,
// without promising identity across arbitrary insertions when no business identity exists.
const legacyParts = [planPart(''), taskPart('')].map(part => {
const copy = structuredClone(part) as any
delete copy[copy.type].id
return copy
}) as MessagePart[]
const legacy = { ...message, id: 'legacy', parts: [...prefix, ...legacyParts] }
await render(legacy)
await click(plan); await click(task)
await render(structuredClone(legacy))
await click(outer); await click(outer)
assert.deepEqual(states(), collapsed, 'no-ID old history retains choice at the same position')
assert.notEqual(completedFoldPartIdentity(legacyParts[0], 1), completedFoldPartIdentity(legacyParts[0], 2), 'no-ID fallback remains positional')
await render({ ...legacy, id: 'legacy-other' })
assert.deepEqual(states(), { plan: ['true'], task: ['true'] }, 'legacy fallback remains message-isolated')
await act(async () => root.unmount()); dom.window.close()
console.log('completed-fold-native-identity: real reducer done correction, native IDs, insertion/reorder, repeated outer toggles, message isolation and legacy fallback passed')
}
main().then(() => process.exit(0)).catch(error => { console.error(error); dom.window.close(); process.exit(1) })
+4 -1
View File
@@ -24,11 +24,14 @@ for (const file of ['scripts/build-runtime-regressions.mjs', '.tmp/claude-approv
// Optional DOM tests use separately installed jsdom, not Electron/CLI or an upstream model.
if (process.env.YCODE_TEST_DOM_MODULE) {
for (const file of ['task-popconfirm-regression', 'question-interaction-regression', 'question-identity-regression', 'completed-fold-nested-regression', 'permission-intercept-dom-regression']) {
for (const file of ['task-popconfirm-regression', 'question-interaction-regression', 'question-identity-regression', 'completed-fold-nested-regression', 'completed-fold-native-identity-regression', 'permission-intercept-dom-regression']) {
const outfile = `.tmp/alignment-tests/${file}.cjs`
await build({ entryPoints: [`scripts/${file}.tsx`], outfile, bundle: true, platform: 'node', format: 'cjs', jsx: 'automatic', loader: { '.css': 'empty' }, logLevel: 'warning' })
const result = spawnSync(process.execPath, [outfile], { stdio: 'inherit', timeout: 120000 })
if (result.error) throw result.error
if (result.status !== 0) process.exit(result.status ?? 1)
}
const modeChip = spawnSync(process.execPath, ['scripts/test-mode-chip.mjs'], { stdio: 'inherit', timeout: 120000 })
if (modeChip.error) throw modeChip.error
if (modeChip.status !== 0) process.exit(modeChip.status ?? 1)
}
@@ -0,0 +1,62 @@
/** Real Chromium fixture, isolated callbacks only: no IPC, sessions, models, or original app. */
import React from 'react'
import { createRoot } from 'react-dom/client'
import { flushSync } from 'react-dom'
import '../src/renderer/src/workbuddy/typography/tokens.css'
import '../src/renderer/src/styles/tokens.css'
import '../src/renderer/src/ui/tokens.css'
import '../src/renderer/src/styles/app.css'
import '../src/renderer/src/styles/sidebar.css'
import '../src/renderer/src/workbuddy/conversation/source-styles.css'
import '../src/renderer/src/workbuddy/conversation/source-adapters.css'
import '../src/renderer/src/components/workspace-source.css'
import '../src/renderer/src/components/source-select/source-select-recovered.css'
import '../src/renderer/src/components/source-select/source-select.css'
import { TaskPopconfirm } from '../src/renderer/src/components/TaskPopconfirm'
const root = createRoot(document.getElementById('root')!)
const anchor = document.getElementById('anchor')!
const results: unknown[] = []
const pause = () => new Promise(resolve => setTimeout(resolve, 200))
function check(actual: unknown, expected: unknown, label: string) {
results.push({label, actual, expected})
if (JSON.stringify(actual) !== JSON.stringify(expected)) throw Error(label + ': ' + JSON.stringify(actual))
}
const button = (name: string) => document.querySelector<HTMLButtonElement>('.task-popconfirm__' + name)!
async function run() {
for (const theme of ['light', 'dark']) for (const kind of ['archive', 'delete'] as const) for (const running of (kind === 'delete' ? [false, true] : [false])) {
document.documentElement.dataset.theme = theme
let calls = 0, closes = 0, resolve!: () => void, reject!: (cause: Error) => void
const close = () => { closes++; flushSync(() => root.render(null)) }
flushSync(() => root.render(<TaskPopconfirm kind={kind} running={running} anchor={anchor} onClose={close} onConfirm={() => { calls++; return new Promise<void>((yes, no) => { resolve = yes; reject = no }) }} />))
await pause()
const label = `${theme}/${kind}/${running}`
const dark = theme === 'dark'
const confirm = button('confirm'), cancel = button('cancel')
for (const node of [confirm, cancel]) {
const s = getComputedStyle(node)
check([s.minWidth, s.paddingLeft, s.paddingRight, s.height, s.borderRadius, s.fontSize], ['66px', '19px', '19px', '28px', '6px', '12px'], label + ' geometry')
}
const primary = getComputedStyle(confirm), secondary = getComputedStyle(cancel)
check([primary.backgroundColor, primary.color], kind === 'delete' ? [dark ? 'rgb(175, 25, 26)' : 'rgb(232, 92, 92)', 'rgb(255, 255, 255)'] : dark ? ['rgba(255, 255, 255, 0.9)', 'rgba(0, 0, 0, 0.9)'] : ['rgba(0, 0, 0, 0.9)', 'rgb(255, 255, 255)'], label + ' primary/danger colors')
check([secondary.backgroundColor, secondary.borderTopWidth, secondary.borderTopStyle, secondary.borderTopColor], [dark ? 'rgb(38, 38, 38)' : 'rgb(255, 255, 255)', '1px', 'solid', dark ? 'rgb(61, 61, 61)' : 'rgb(222, 222, 222)'], label + ' secondary colors/border')
const popup = document.querySelector<HTMLElement>('.task-popconfirm')!
check([getComputedStyle(popup).width, getComputedStyle(popup).borderRadius, getComputedStyle(document.querySelector('.task-popconfirm__title')!).fontWeight], ['240px', '16px', '600'], label + ' shell')
check(getComputedStyle(popup).backgroundColor, dark ? 'rgb(36, 36, 36)' : 'rgb(255, 255, 255)', label + ' shell background')
check(document.activeElement === confirm, true, label + ' initial focus')
if (running) check(confirm.textContent, '停止并删除', label + ' running delete semantic')
confirm.click(); confirm.click(); await pause()
check([calls, confirm.disabled, cancel.disabled, confirm.textContent], [1, true, true, '处理中…'], label + ' busy prevents duplicates')
document.dispatchEvent(new KeyboardEvent('keydown', {key:'Escape', bubbles:true})); cancel.click()
check(closes, 0, label + ' busy cannot dismiss')
if (kind === 'delete') check(getComputedStyle(confirm).backgroundColor, dark ? 'rgb(128, 56, 56)' : 'rgb(245, 185, 185)', label + ' danger busy background')
reject(new Error('fixture failure')); await pause()
check(document.querySelector('[role="alert"]')?.textContent, 'fixture failure', label + ' failure retained')
check(confirm.disabled, false, label + ' retry enabled')
confirm.click(); await pause(); resolve(); await pause()
check([calls, closes, !!document.querySelector('.task-popconfirm')], [2, 1, false], label + ' retry success')
}
}
run().then(() => finish(true), error => finish(false, String(error)))
function finish(pass: boolean, error?: string) {
const output = document.createElement('pre'); output.id = 'result'; output.textContent = JSON.stringify({pass, error, results}); document.body.append(output)
}
+17 -1
View File
@@ -41,7 +41,7 @@ async function main() {
check(document.querySelector('[role="dialog"]')?.getAttribute('aria-labelledby'), 'accessible title')
check(document.activeElement === document.querySelector('.task-popconfirm__confirm'), 'confirmation auto-focuses')
check(taskConfirmationCopy('delete', true).confirm === '停止并删除', 'running delete copy')
check(document.querySelector('.task-popconfirm__compatibility')?.textContent?.includes('YCode 暂无此设置入口'), 'archive destination mismatch explicit')
check(!document.querySelector('.task-popconfirm__compatibility'), 'archive preserves selected original copy without extra explanation')
const confirm = document.querySelector('.task-popconfirm__confirm')!
await act(async () => { click(confirm); click(confirm) })
check(calls === 1, 'duplicate confirmation guarded synchronously')
@@ -56,6 +56,22 @@ async function main() {
await act(async () => resolve!())
check(closes === 1 && !document.querySelector('.task-popconfirm'), 'success closes')
await render('delete')
await act(async () => root.render(null))
await act(async () => root.render(<TaskPopconfirm kind="delete" running anchor={anchor} onConfirm={submit} onClose={close} />)); await settle()
const runningConfirm = document.querySelector<HTMLButtonElement>('.task-popconfirm__confirm')!
const runningCancel = document.querySelector<HTMLButtonElement>('.task-popconfirm__cancel')!
check(runningConfirm.textContent === '停止并删除', 'running delete uses stop-and-delete action')
const beforeRunning = calls, beforeRunningClose = closes
await act(async () => { click(runningConfirm); click(runningConfirm); click(runningCancel) })
check(calls === beforeRunning + 1 && closes === beforeRunningClose, 'running delete is single-flight and cancellation locked')
check(runningConfirm.disabled && runningCancel.disabled && runningConfirm.textContent === '处理中…', 'running delete busy buttons remain readable and disabled')
await act(async () => reject!(new Error('stop failed')))
check(document.querySelector('[role="alert"]')?.textContent === 'stop failed', 'stop-and-delete failure retained')
check(!runningConfirm.disabled && runningConfirm.textContent === '停止并删除', 'stop-and-delete failure restores action')
await act(async () => click(runningConfirm))
await act(async () => resolve!())
check(calls === beforeRunning + 2 && closes === beforeRunningClose + 1, 'stop-and-delete retry succeeds once')
await render('delete')
let underlyingClicks = 0
other.addEventListener('click', () => underlyingClicks++)
await act(async () => {
+77
View File
@@ -0,0 +1,77 @@
/** Own-YCode isolated component fixture. Run on a permitted Electron desktop:
* node scripts/test-mode-chip-native.mjs
* No original app, model, SDK, user data, or production session access.
*/
import fs from 'node:fs'
import path from 'node:path'
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
const require = createRequire(import.meta.url), module = require('electron')
const base = path.resolve('.tmp')
fs.mkdirSync(base, { recursive: true })
const file = 'src/renderer/src/workbuddy/composer/ModeChip.tsx'
async function prepareFixture(output) {
const src = relative => JSON.stringify(path.resolve(relative).replaceAll('\\', '/'))
fs.writeFileSync(path.join(output, 'fixture.tsx'), `
import React,{useState} from 'react'; import {createRoot} from 'react-dom/client';
import {ModeChip} from ${src(file)};
import ${src('src/renderer/src/workbuddy/typography/tokens.css')};
import ${src('src/renderer/src/styles/tokens.css')};
import ${src('src/renderer/src/ui/tokens.css')};
import ${src('src/renderer/src/styles/app.css')};
import ${src('src/renderer/src/workbuddy/conversation/source-styles.css')};
const q=(window as any).qa={clears:0};
function Fixture(){const [mode,setMode]=useState<any>('plan'),[disabled,setDisabled]=useState(false);Object.assign(q,{setMode,setDisabled});return <div className="chat-pane cr-theme" style={{padding:40}}><div style={{display:"flex",alignItems:"center",gap:8}}><button id="before">Before</button><ModeChip mode={mode} disabled={disabled} onClear={()=>{q.clears++;setMode('craft')}}/><button id="after">After</button></div></div>};createRoot(document.getElementById('root')!).render(<Fixture/>);`)
await require('esbuild').build({ entryPoints: [path.join(output, 'fixture.tsx')], bundle: true, platform: 'browser', jsx: 'automatic', outfile: path.join(output, 'fixture.js'), logLevel: 'warning' })
fs.writeFileSync(path.join(output, 'index.html'), '<html data-theme="light"><head><meta charset="utf-8"><link rel="stylesheet" href="fixture.css"></head><body><div id="root"></div><script src="fixture.js"></script></body></html>')
}
if (!process.versions.electron) {
const output = fs.mkdtempSync(path.join(base, 'mode-chip-native-')), env = { ...process.env }
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
if (process.argv.includes('--build-only')) {
await prepareFixture(output); console.log(JSON.stringify({ output, fixtureBuilt: true, nativeExecuted: false }))
} else {
const child = spawn(module, [path.resolve('scripts/test-mode-chip-native.mjs'), output], { env, windowsHide: true, stdio: 'inherit' })
const timeout = setTimeout(() => child.kill(), 60000)
child.on('error', error => { clearTimeout(timeout); console.error(error); process.exitCode = 1 })
child.on('exit', code => { clearTimeout(timeout); process.exitCode = code ?? 1; console.log(JSON.stringify({ output, exitCode: code })) })
}
} else {
const { app, BrowserWindow } = module, output = path.resolve(process.argv.at(-1))
assert.ok(path.dirname(output) === base && path.basename(output).startsWith('mode-chip-native-'))
fs.mkdirSync(path.join(output, 'data')); app.setPath('userData', path.join(output, 'data')); app.disableHardwareAcceleration()
let win; const checks = [], samples = [], errors = []
try {
await prepareFixture(output)
await app.whenReady(); win = new BrowserWindow({ width: 800, height: 300, show: false, webPreferences: { contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } })
win.webContents.on('console-message', event => { if (event.level === 'error' || event.level >= 3) errors.push(event.message) })
await win.loadFile(path.join(output, 'index.html')); win.showInactive()
const read = code => win.webContents.executeJavaScript(code), pause = () => new Promise(resolve => setTimeout(resolve, 80))
const check = (label, actual, expected = true) => { assert.deepEqual(actual, expected, label); checks.push(label) }
const key = async keyCode => { win.webContents.sendInputEvent({ type: 'keyDown', keyCode }); win.webContents.sendInputEvent({ type: 'keyUp', keyCode }); await pause() }
const sample = () => read(`(()=>{const c=document.querySelector('.yc-mode-chip'),i=c.querySelector('.yc-mode-chip__mode-icon'),b=c.querySelector('button'),s=getComputedStyle(c),r=e=>{const x=e.getBoundingClientRect();return{x:x.x,y:x.y,width:x.width,height:x.height}};return{chip:r(c),icon:r(i),button:b?r(b):null,background:s.backgroundColor,radius:s.borderRadius,color:s.color,expectedColor:s.getPropertyValue('--cr-text-secondary').trim(),font:s.fontSize,weight:s.fontWeight,line:s.lineHeight,padding:s.paddingLeft,iconOpacity:getComputedStyle(i).opacity,clearOpacity:b?getComputedStyle(b).opacity:null}})()`)
await pause()
for (const theme of ['light', 'dark']) for (const mode of ['plan', 'ask']) {
await read(`document.documentElement.dataset.theme='${theme}'; document.querySelector('.chat-pane').dataset.crTheme='${theme}'; qa.setMode('${mode}');qa.setDisabled(false);document.querySelector('#before').focus()`)
win.webContents.sendInputEvent({ type: 'mouseMove', x: 1, y: 1 }); await pause()
const normal = await sample(); samples.push({ theme, mode, state: 'normal', ...normal })
check('transparent', normal.background, 'rgba(0, 0, 0, 0)'); check('pill', parseFloat(normal.radius) >= normal.chip.height / 2)
check('height', normal.chip.height, 32); check('font', normal.font, '13px'); check('weight', normal.weight, '500'); check('line', normal.line, '20px'); check('padding', normal.padding, '8px')
check('same slot', normal.icon, normal.button); check('icon width', normal.icon.width, 16); check('default icon', normal.iconOpacity, '1'); check('default close', normal.clearOpacity, '0')
check('secondary color', await read(`(()=>{const c=document.querySelector('.yc-mode-chip'),p=document.createElement('span');p.style.color='var(--cr-text-secondary)';c.append(p);const ok=getComputedStyle(p).color===getComputedStyle(c).color;p.remove();return ok})()`))
win.webContents.sendInputEvent({ type: 'mouseMove', x: Math.round(normal.chip.x + normal.chip.width / 2), y: Math.round(normal.chip.y + normal.chip.height / 2) }); await pause()
const hover = await sample(); samples.push({ theme, mode, state: 'hover', ...hover }); check('hover icon hidden', hover.iconOpacity, '0'); check('hover X', hover.clearOpacity, '1'); check('no hover layout shift', hover.chip, normal.chip)
win.webContents.sendInputEvent({ type: 'mouseMove', x: 1, y: 1 }); await read(`document.querySelector('.yc-mode-chip').focus()`); await pause()
const focus = await sample(); check('focus icon hidden', focus.iconOpacity, '0'); check('focus X', focus.clearOpacity, '1'); check('no focus layout shift', focus.chip, normal.chip)
await fs.promises.writeFile(path.join(output, `${theme}-${mode}-focus.png`), (await win.webContents.capturePage()).toPNG())
await key('Tab'); check('Tab reaches native clear', await read(`document.activeElement.matches('.yc-mode-chip__clear')`))
const before = await read('qa.clears'); await key(mode === 'plan' ? 'Enter' : 'Space'); check('native keyboard exit', await read('qa.clears'), before + 1); check('craft hidden', await read(`!document.querySelector('.yc-mode-chip')`))
await read(`qa.setMode('${mode}');qa.setDisabled(true)`); await pause(); check('disabled has no clear', await read(`!document.querySelector('.yc-mode-chip button')`)); check('disabled not tabbable', await read(`document.querySelector('.yc-mode-chip').tabIndex`), -1)
}
win.setSize(320, 300); await read(`qa.setMode('ask');qa.setDisabled(false)`); await pause(); const narrow = await sample(); check('narrow slot preserved', narrow.icon, narrow.button); check('narrow no overflow', narrow.chip.x + narrow.chip.width <= 320)
await fs.promises.writeFile(path.join(output, 'narrow.png'), (await win.webContents.capturePage()).toPNG()); check('no renderer errors', errors, [])
fs.writeFileSync(path.join(output, 'report.json'), JSON.stringify({ checks, samples, errors, sourceSha256: createHash('sha256').update(fs.readFileSync(file)).digest('hex'), scope: 'Own-YCode isolated component browser test, not full app/store integration or original-app pixel acceptance' }, null, 2)); app.exit(0)
} catch (error) { fs.writeFileSync(path.join(output, 'failure.txt'), String(error.stack || error)); console.error(error); app.exit(1) }
}
+47
View File
@@ -0,0 +1,47 @@
/** Isolated DOM regression; not a browser layout/pixel or original-app acceptance test. */
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { createRequire } from 'node:module'
import { build } from 'esbuild'
const require = createRequire(import.meta.url)
const { JSDOM } = require(process.env.YCODE_TEST_DOM_MODULE || process.env.YCODE_JSDOM_PATH || 'jsdom')
const output = fs.mkdtempSync(path.resolve('.tmp/mode-chip-dom-'))
await build({ entryPoints: ['src/renderer/src/workbuddy/composer/ModeChip.tsx'], bundle: true, platform: 'node', format: 'cjs', jsx: 'automatic', packages: 'external', loader: { '.css': 'empty' }, outfile: path.join(output, 'component.cjs'), logLevel: 'error' })
const dom = new JSDOM('<html><head></head><body><div id="root"></div></body></html>', { url: 'http://localhost' })
Object.assign(globalThis, { window: dom.window, document: dom.window.document, HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true })
const React = require('react'), { createRoot } = require('react-dom/client')
const { ModeChip } = require(path.join(output, 'component.cjs'))
const css = fs.readFileSync('src/renderer/src/workbuddy/composer/mode-chip.css', 'utf8')
const style = document.createElement('style'); style.textContent = css; document.head.append(style)
const root = createRoot(document.getElementById('root')); let clears = 0, checks = 0
const check = (actual, expected, message) => { assert.deepEqual(actual, expected, message); checks++ }
const render = async (mode, disabled = false) => React.act(async () => root.render(React.createElement(ModeChip, { mode, disabled, onClear: () => { clears++ } })))
for (const mode of ['plan', 'ask']) {
await render(mode)
const chip = document.querySelector('.yc-mode-chip'), slot = chip.querySelector('.yc-mode-chip__icon-slot'), button = slot.querySelector('button'), icon = slot.querySelector('.yc-mode-chip__mode-icon')
check(chip.textContent, mode === 'plan' ? '计划' : '仅问答', 'mode label has no extra X text')
check(chip.getAttribute('role'), 'group', 'chip remains a group, not a menu')
check(button.type, 'button', 'clear cannot submit composer')
check(button.getAttribute('aria-label'), `退出${chip.textContent}模式`, 'accessible exit name')
check(icon.parentElement === button.parentElement, true, 'icon and close share one slot')
check(dom.window.getComputedStyle(slot).display, 'grid', 'shared grid slot')
check(dom.window.getComputedStyle(button).gridArea, '1 / 1', 'button occupies original icon cell')
check(dom.window.getComputedStyle(icon).gridArea, '1 / 1', 'mode occupies same original cell')
check(dom.window.getComputedStyle(button).position, 'static', 'no floating absolute close')
check(dom.window.getComputedStyle(chip).backgroundColor, 'rgba(0, 0, 0, 0)', 'transparent background')
await React.act(async () => button.focus())
check(document.activeElement === button, true, 'native button receives keyboard focus')
const before = clears
await React.act(async () => button.click())
check(clears, before + 1, 'activation calls existing host clear exactly once')
await render(mode, true)
check(document.querySelector('button'), null, 'disabled mode exposes no clear action')
check(document.querySelector('.yc-mode-chip').getAttribute('aria-disabled'), 'true', 'disabled state is announced')
check(document.querySelector('.yc-mode-chip').tabIndex, -1, 'disabled mode not in keyboard tab order')
}
await render('craft'); check(document.querySelector('.yc-mode-chip'), null, 'craft stays hidden')
// CSS contracts complement DOM checks; test-mode-chip-native.mjs is an unexecuted native hover/focus/layout probe.
for (const contract of ['border-radius:var(--yc-radius-full)', 'color:var(--cr-text-secondary)', ':hover .yc-mode-chip__mode-icon', ':focus-within .yc-mode-chip__mode-icon', ':hover .yc-mode-chip__clear', ':focus-within .yc-mode-chip__clear']) check(css.includes(contract), true, contract)
await React.act(async () => root.unmount()); dom.window.close()
console.log(JSON.stringify({ checks, passed: true, scope: 'Isolated React DOM and CSS contracts; no native keyboard/layout/pixel acceptance' }))
+25
View File
@@ -0,0 +1,25 @@
/** Build and run real-browser computed-style checks; no jsdom style substitute.
* CHROMIUM_PATH=/path/to/chrome node scripts/test-task-popconfirm-browser.mjs
* --build-only creates a portable HTML fixture for an available browser.
* Chromium sandbox remains enabled. Runtime failure is NOT a passing test.
*/
import { build } from 'esbuild'
import { mkdtemp, writeFile, readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { spawnSync } from 'node:child_process'
const dir = await mkdtemp(resolve('.tmp/task-popconfirm-browser-'))
await build({ entryPoints: ['scripts/task-popconfirm-browser-fixture.tsx'], bundle: true, platform: 'browser', format: 'iife', jsx: 'automatic', outfile: dir + '/fixture.js', loader: {'.svg':'dataurl', '.woff2':'dataurl', '.woff':'dataurl'}, logLevel: 'warning' })
const js = await readFile(dir + '/fixture.js', 'utf8'), css = await readFile(dir + '/fixture.css', 'utf8')
await writeFile(dir + '/index.html', `<!doctype html><html><head><meta charset="utf-8"><style>${css}</style></head><body><button id="anchor" style="position:absolute;left:30px;top:100px">Task</button><main id="root"></main><script>${js.replaceAll('</script', '<\\/script')}</script></body></html>`)
console.log('Browser fixture: ' + dir + '/index.html')
if (!process.argv.includes('--build-only')) {
const proc = spawnSync(process.env.CHROMIUM_PATH || 'chromium', ['--headless', '--disable-gpu', '--no-first-run', '--user-data-dir=' + dir + '/profile', '--virtual-time-budget=20000', '--dump-dom', pathToFileURL(dir + '/index.html').href], { encoding:'utf8', timeout:60000, maxBuffer:20*1024*1024 })
await writeFile(dir + '/browser.log', (proc.stderr || '') + '\n' + (proc.error || ''))
const match = proc.stdout?.match(/<pre id="result">([\s\S]*?)<\/pre>/)
if (proc.status !== 0 || !match) throw Error(`Browser did not finish; status=${proc.status}; see ${dir}/browser.log`)
const report = JSON.parse(match[1].replaceAll('&quot;', '"').replaceAll('&lt;', '<').replaceAll('&gt;', '>').replaceAll('&amp;', '&'))
await writeFile(dir + '/report.json', JSON.stringify(report, null, 2))
if (!report.pass) throw Error(report.error)
console.log(`Task confirmation real Chromium: ${report.results.length} assertions passed; ${dir}/report.json`)
}
+25 -2
View File
@@ -66,6 +66,27 @@ if (!process.versions.electron) {
const capture = await main.webContents.debugger.sendCommand('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false })
await fs.writeFile(path.join(directory, name + '.png'), Buffer.from(capture.data, 'base64'))
}
// Assert styles in the real body portal, not just variant class names.
async function checkConfirmationStyles(kind) {
const previous = await read('document.documentElement.dataset.theme || "light"')
const setTheme = async theme => {
await read(`(()=>{localStorage.setItem('ycode:theme',${JSON.stringify(theme)});window.dispatchEvent(new StorageEvent('storage',{key:'ycode:theme',newValue:${JSON.stringify(theme)}}))})()`)
await wait(`document.documentElement.dataset.theme === ${JSON.stringify(theme)}`, 'application theme synchronization')
await pause(400)
}
for (const theme of ['light', 'dark']) {
await setTheme(theme)
const values = await read(`(()=>{const style=name=>{const s=getComputedStyle(document.querySelector('.task-popconfirm__'+name));return {background:s.backgroundColor,color:s.color,border:[s.borderTopWidth,s.borderTopStyle,s.borderTopColor],geometry:[s.minWidth,s.paddingLeft,s.paddingRight,s.height,s.borderRadius,s.fontSize]}};return {cancel:style('cancel'),confirm:style('confirm'),titleWeight:getComputedStyle(document.querySelector('.task-popconfirm__title')).fontWeight}})()`)
const dark = theme === 'dark'
check(kind+' '+theme+' secondary background/border', [values.cancel.background, values.cancel.border], [dark ? 'rgb(38, 38, 38)' : 'rgb(255, 255, 255)', ['1px', 'solid', dark ? 'rgb(61, 61, 61)' : 'rgb(222, 222, 222)']])
check(kind+' '+theme+' primary/danger background', values.confirm.background, kind === 'delete' ? (dark ? 'rgb(175, 25, 26)' : 'rgb(232, 92, 92)') : (dark ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.9)'))
check(kind+' '+theme+' confirmation foreground', values.confirm.color, kind === 'archive' && dark ? 'rgba(0, 0, 0, 0.9)' : 'rgb(255, 255, 255)')
for (const action of ['confirm', 'cancel']) check(kind+' '+theme+' '+action+' geometry', values[action].geometry, ['66px', '19px', '19px', '28px', '6px', '12px'])
check(kind+' '+theme+' title weight', values.titleWeight, '600')
await shot(kind+'-confirm-'+theme)
}
await setTheme(previous)
}
// 真实用户路径:先悬停行让操作按钮显形,再点击按钮。
const openMenu = `(()=>{const row=document.querySelector('[data-session-id]');if(!row)return null;row.classList.add('sidebar__item--menu-open');const button=row.querySelector('.agent-card-more-button');if(!button)return null;button.click();return true})()`
const menuSource = `(()=>{const menu=document.querySelector('.conversation-context-menu');if(!menu)return null;const style=getComputedStyle(menu);const rect=menu.getBoundingClientRect();return {rect:[rect.x,rect.y,rect.width,rect.height],background:style.backgroundColor,borderRadius:style.borderRadius,padding:style.padding,items:[...menu.querySelectorAll('.cb-context-menu-item')].map(button=>{const box=button.getBoundingClientRect();const icon=button.querySelector('.cb-context-menu-item-icon svg');const label=button.querySelector('.cb-context-menu-item-label');const itemStyle=getComputedStyle(button);return {label:label?label.textContent:'',width:box.width,height:box.height,fontSize:itemStyle.fontSize,lineHeight:itemStyle.lineHeight,borderRadius:itemStyle.borderRadius,color:itemStyle.color,icon:icon?[icon.getBoundingClientRect().width,icon.getBoundingClientRect().height]:null}}),dividers:[...menu.querySelectorAll('.cb-context-menu-divider')].map(divider=>{const box=divider.getBoundingClientRect();const dividerStyle=getComputedStyle(divider);return {width:box.width,height:box.height,margin:dividerStyle.margin}})}})()`
@@ -167,9 +188,10 @@ if (!process.versions.electron) {
const archiveConfirm = await read(`(()=>{const dialog=document.querySelector('.task-popconfirm');const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};const style=getComputedStyle(dialog);return {box:box(dialog),title:dialog.querySelector('.task-popconfirm__title')?.textContent,desc:dialog.querySelector('.task-popconfirm__description')?.textContent,cancel:dialog.querySelector('.task-popconfirm__cancel')?.textContent,confirm:dialog.querySelector('.task-popconfirm__confirm')?.textContent,padding:style.padding,borderRadius:style.borderRadius}})()`)
check('archive opens a confirmation before changing the task', archiveConfirm.title, '归档任务')
check('archive restores the documented original destination', archiveConfirm.desc, '确认将该任务归档吗?归档后可在“系统设置-数据管理”中查看。')
check('archive explains the unavailable destination and working fallback', await read(`document.querySelector('.task-popconfirm__compatibility').textContent.includes('侧栏“已归档”')`), true)
check('archive does not append unrequested compatibility copy', await read(`!!document.querySelector('.task-popconfirm__compatibility')`), false)
check('archive confirmation actions use the recovered labels', [archiveConfirm.cancel, archiveConfirm.confirm], ['取消', '确认归档'])
check('archive confirmation uses the plan-defined 240px popconfirm', [Math.round(archiveConfirm.box[2]), archiveConfirm.padding, archiveConfirm.borderRadius], [240, '16px', '12px'])
check('archive confirmation uses the plan-defined 240px popconfirm', [Math.round(archiveConfirm.box[2]), archiveConfirm.padding, archiveConfirm.borderRadius], [240, '16px', '16px'])
await checkConfirmationStyles('archive')
await shot('archive-confirm')
await read(`document.querySelector('.task-popconfirm__cancel').click()`)
await wait(`!document.querySelector('.task-popconfirm')`, 'archive cancellation closes')
@@ -195,6 +217,7 @@ if (!process.versions.electron) {
check('delete uses the idle confirmation description', deleteConfirm.desc, '删除后,这些任务将从你的所有设备中移除,且无法恢复。')
check('delete confirmation actions use the recovered idle labels', [deleteConfirm.cancel, deleteConfirm.confirm], ['取消', '确认'])
check('idle delete is not marked as running', deleteConfirm.running, null)
await checkConfirmationStyles('delete')
await shot('delete-confirm')
await read(`document.querySelector('.task-popconfirm__confirm').click()`)
await wait(`window.ycode.session.listArchived().then(list=>!list.some(item=>item.id===${JSON.stringify(remainingId)}))`, 'delete completes after confirmation')