Resolve 15 shared-control, overlay and responsive-layout issues plus the MCP editor being hidden behind settings. Restore theme contrast, danger and disabled states, visible keyboard focus, and consistent Continue fields. Keep the workbar add button reachable and model protocol values readable in narrow containers.
Make nested MCP and image dialogs usable above their parents with focus restoration, keep project Escape handling within the active modal, release native browser views for overlapping help, and restore keyboard access and Escape in sandboxed HTML previews without relaxing sandbox permissions. Add unit and real Electron regression coverage and update design and validation documentation.
Release note: 修复深浅主题下控件边框、危险操作、禁用状态和键盘焦点显示不一致的问题。MCP 编辑器与图片预览可在父弹窗上正常操作,HTML 全屏预览支持键盘进入和 Escape 返回;窄工作栏的添加按钮保持可用,模型连接协议文字完整可读。
Validation: the full suite recorded 5309 passed, 3 failed and 83 skipped. The ProjectSwitcher initial-focus regression was then fixed, and App, ProjectSwitcher and overlay Electron tests passed all 331 cases. The other two HeartbeatCenter failures were reproduced on isolated HEAD 11facf0 and remain pre-existing. Typecheck and lint passed after the fix; staged whitespace checks passed. The full suite was not rerun after the focused fix.
Limits: Electron evidence is from Windows development runs, with fixtures for unrelated services and native occlusion throttling disabled in some drivers. No production build, packaging, macOS/Linux, remote Agent host or external MCP connection validation; skipped tests and the documented scaling and native-select coverage gaps remain.
213 lines
12 KiB
JavaScript
213 lines
12 KiB
JavaScript
import { app, BrowserWindow } from 'electron'
|
|
import assert from 'node:assert/strict'
|
|
import { mkdir, writeFile } from 'node:fs/promises'
|
|
import { join } from 'node:path'
|
|
import process from 'node:process'
|
|
import console from 'node:console'
|
|
import { setTimeout } from 'node:timers/promises'
|
|
|
|
app.setPath('userData', join(process.env.GB_CONTROLS_DIRECTORY, 'profile'))
|
|
app.commandLine.appendSwitch('force-device-scale-factor', '1')
|
|
const artifacts = process.env.GB_SHARED_CONTROLS_ARTIFACTS
|
|
const observations = []
|
|
const errors = []
|
|
const contrast = (a, b) => {
|
|
const luminance = color => color.match(/[\d.]+/g).slice(0, 3).map(Number)
|
|
.map(v => v / 255).map(v => v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)
|
|
.reduce((sum, v, i) => sum + v * [0.2126, 0.7152, 0.0722][i], 0)
|
|
const values = [luminance(a), luminance(b)].sort((a, b) => a - b)
|
|
return (values[1] + 0.05) / (values[0] + 0.05)
|
|
}
|
|
|
|
app.whenReady().then(async () => {
|
|
const win = new BrowserWindow({ show: true, width: 1280, height: 800, useContentSize: true,
|
|
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } })
|
|
win.webContents.on('console-message', event => { if (event.level === 'error') errors.push(event.message) })
|
|
const js = code => win.webContents.executeJavaScript(code)
|
|
const wait = async code => {
|
|
for (let i = 0; i < 200; i++) {
|
|
if (await js(code)) return
|
|
await setTimeout(25)
|
|
}
|
|
throw new Error(`Timeout: ${code}`)
|
|
}
|
|
const settle = () => js(`Promise.all(document.getAnimations().filter(a => a.effect.getTiming().iterations !== Infinity)
|
|
.map(a => a.finished.catch(() => {}))).then(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))`)
|
|
const measure = async (selector, state) => {
|
|
await settle()
|
|
const style = await js(`(() => {
|
|
const e = document.querySelector(${JSON.stringify(selector)}), s = getComputedStyle(e), r = e.getBoundingClientRect();
|
|
const rgb = name => { const span = document.createElement('span'); span.style.color = s.getPropertyValue(name); e.parentElement.append(span);
|
|
const value = getComputedStyle(span).color; span.remove(); return value };
|
|
return { color:s.color, background:s.backgroundColor, border:s.borderTopColor, borderStyle:s.borderTopStyle,
|
|
radius:s.borderRadius, padding:s.padding, minHeight:s.minHeight, width:r.width, height:r.height,
|
|
font:s.fontFamily, opacity:s.opacity, cursor:s.cursor, outline:s.outlineStyle, outlineWidth:s.outlineWidth,
|
|
outlineColor:s.outlineColor, focusVisible:e.matches(':focus-visible'), disabled:e.matches(':disabled'),
|
|
tokens:Object.fromEntries(['--accent','--accent-solid','--accent-solid-hover','--text-on-accent','--text-secondary',
|
|
'--text-muted','--danger','--danger-solid','--danger-solid-hover','--danger-subtle','--border-control','--surface-raised']
|
|
.map(name=>[name,rgb(name)])) };
|
|
})()`)
|
|
observations.push({ theme: await js('document.documentElement.dataset.theme'), selector, state, ...style })
|
|
return style
|
|
}
|
|
const hover = async (selector, enabled) => {
|
|
const { root } = await win.webContents.debugger.sendCommand('DOM.getDocument')
|
|
const { nodeId } = await win.webContents.debugger.sendCommand('DOM.querySelector', { nodeId: root.nodeId, selector })
|
|
await win.webContents.debugger.sendCommand('CSS.forcePseudoState', { nodeId, forcedPseudoClasses: enabled ? ['hover'] : [] })
|
|
}
|
|
const disabled = (selector, value) => js(`document.querySelector(${JSON.stringify(selector)}).disabled = ${value}`)
|
|
const focus = async selector => {
|
|
await js(`(() => { const e = document.querySelector(${JSON.stringify(selector)}); e.scrollIntoView({block:'center'});
|
|
const start = document.createElement('button'); start.id='focus-start'; start.textContent='Focus start'; e.before(start); start.focus(); })()`)
|
|
win.focus(); win.webContents.focus()
|
|
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Tab' })
|
|
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Tab' })
|
|
await wait(`document.activeElement === document.querySelector(${JSON.stringify(selector)})`)
|
|
await js('document.getElementById("focus-start").remove()')
|
|
const s = await measure(selector, 'native-tab-focus')
|
|
assert.equal(s.focusVisible, true, selector)
|
|
assert.equal(s.outline, 'solid', selector)
|
|
assert.equal(s.outlineWidth, '2px', selector)
|
|
assert.equal(s.outlineColor, s.tokens['--accent'], selector)
|
|
}
|
|
const screenshot = async name => {
|
|
await settle()
|
|
if (artifacts) await writeFile(join(artifacts, `${name}.png`), (await win.webContents.capturePage()).toPNG())
|
|
}
|
|
try {
|
|
if (artifacts) await mkdir(artifacts, { recursive: true })
|
|
await win.loadURL(process.env.GB_CONTROLS_URL)
|
|
await wait('!!document.querySelector(".runtime-customization-item textarea")')
|
|
await js('document.fonts.ready')
|
|
win.webContents.debugger.attach('1.3')
|
|
await win.webContents.debugger.sendCommand('DOM.enable')
|
|
await win.webContents.debugger.sendCommand('CSS.enable')
|
|
for (const theme of ['light', 'dark']) {
|
|
await js(`document.getElementById('theme-${theme}').click()`)
|
|
for (const container of ['settings', 'portal']) {
|
|
await js(`document.getElementById('portal-controls').style.display = '${container === 'portal' ? 'block' : 'none'}'`)
|
|
const base = `[data-controls="${container}"]`
|
|
const classes = ['primary-button', 'secondary-button', 'danger-solid', 'danger-button', 'danger-button danger-button--quiet',
|
|
'danger-ghost', 'icon-button', 'danger-ghost icon-button', 'icon-button icon-button--active']
|
|
for (const className of classes) {
|
|
const selector = `${base} [data-button="${className}"]`
|
|
const normal = await measure(selector, 'default')
|
|
if (className === 'secondary-button') assert.equal(normal.border, normal.tokens['--border-control'])
|
|
if (className.includes('danger-ghost')) assert.equal(normal.color, normal.tokens['--danger'])
|
|
if (className === 'icon-button icon-button--active') assert.equal(normal.color, normal.tokens['--accent'])
|
|
await hover(selector, true)
|
|
const hovered = await measure(selector, 'hover')
|
|
if (className.includes('danger-ghost')) {
|
|
assert.equal(hovered.color, hovered.tokens['--danger'])
|
|
assert.equal(hovered.background, hovered.tokens['--danger-subtle'])
|
|
}
|
|
if (['primary-button', 'danger-solid'].includes(className)) {
|
|
for (const s of [normal, hovered]) assert(contrast(s.color, s.background) >= 4.5, `${theme} ${className} contrast`)
|
|
}
|
|
await hover(selector, false)
|
|
await disabled(selector, true)
|
|
const inactive = await measure(selector, 'disabled')
|
|
assert.equal(inactive.cursor, 'not-allowed', selector)
|
|
assert(Number(inactive.opacity) < 1, selector)
|
|
await hover(selector, true)
|
|
const inactiveHover = await measure(selector, 'disabled-hover')
|
|
assert.equal(inactiveHover.background, inactive.background, selector)
|
|
assert.equal(inactiveHover.color, inactive.color, selector)
|
|
await hover(selector, false)
|
|
await disabled(selector, false)
|
|
await focus(selector)
|
|
}
|
|
for (const kind of ['text', 'select', 'textarea', 'switch', 'radio', 'checkbox', 'address', 'dialog']) {
|
|
const selector = `${base} [data-input="${kind}"]`
|
|
const s = await measure(selector, 'default')
|
|
if (['text', 'select', 'textarea', 'address', 'dialog'].includes(kind)) {
|
|
assert.equal(s.border, s.tokens['--border-control'], selector)
|
|
assert(contrast(s.border, s.background) >= 3, selector)
|
|
} else {
|
|
assert.equal(s.height, kind === 'switch' ? 22 : 15, selector)
|
|
assert.equal(s.width, kind === 'switch' ? 38 : 15, selector)
|
|
}
|
|
await focus(selector)
|
|
}
|
|
for (const variant of ['default', 'segmented']) {
|
|
const tab = `${base} .page-tabs--${variant} .page-tabs__tab--active`
|
|
const selected = await measure(tab, 'active')
|
|
await hover(tab, true)
|
|
const selectedHover = await measure(tab, 'active-hover')
|
|
assert.equal(selectedHover.background, selected.background)
|
|
assert.equal(selectedHover.color, selected.color)
|
|
await hover(tab, false)
|
|
await focus(tab)
|
|
}
|
|
for (const suffix of ['--active', ':not(.segmented-control__option--active)']) {
|
|
const selector = `${base} .segmented-control__option${suffix}`
|
|
await disabled(selector, true)
|
|
const s = await measure(selector, 'disabled-segment')
|
|
assert.equal(s.cursor, 'not-allowed')
|
|
assert(Number(s.opacity) < 1)
|
|
await disabled(selector, false)
|
|
await focus(selector)
|
|
}
|
|
for (const menu of ['conversation-actions', 'workspace-files__menu']) {
|
|
const menuGhost = `${base} .${menu} [data-menu-ghost]`
|
|
await disabled(menuGhost, true)
|
|
await hover(menuGhost, true)
|
|
const disabledGhost = await measure(menuGhost, 'menu-disabled-danger-hover')
|
|
assert.equal(disabledGhost.color, disabledGhost.tokens['--text-muted'])
|
|
assert.equal(disabledGhost.background, 'rgba(0, 0, 0, 0)')
|
|
await hover(menuGhost, false)
|
|
await disabled(menuGhost, false)
|
|
const trigger = `${base} .${menu} .danger-button--quiet`
|
|
const triggerStyle = await measure(trigger, 'menu-danger-trigger')
|
|
assert.equal(triggerStyle.color, triggerStyle.tokens['--danger'])
|
|
await js(`document.querySelector(${JSON.stringify(trigger)}).click()`)
|
|
const confirm = `${base} .${menu} .danger-confirm .danger-button`
|
|
await wait(`!!document.querySelector(${JSON.stringify(confirm)})`)
|
|
const confirmStyle = await measure(confirm, 'menu-danger-confirm')
|
|
assert.equal(confirmStyle.color, confirmStyle.tokens['--text-on-accent'])
|
|
assert.equal(confirmStyle.background, confirmStyle.tokens['--danger-solid'])
|
|
await hover(confirm, true)
|
|
const confirmHover = await measure(confirm, 'menu-danger-confirm-hover')
|
|
assert.equal(confirmHover.background, confirmStyle.background)
|
|
assert.equal(confirmHover.color, confirmStyle.color)
|
|
await hover(confirm, false)
|
|
await js(`document.querySelector('${base} .${menu} .danger-confirm .secondary-button').click()`)
|
|
await wait(`!document.querySelector(${JSON.stringify(confirm)})`)
|
|
}
|
|
await screenshot(`${theme}-${container}`)
|
|
}
|
|
await js("document.getElementById('portal-controls').style.display='none'")
|
|
const controls = await js(`Array.from(document.querySelectorAll('.runtime-customization-item .field-control')).map((e,i)=>{e.dataset.actualControl=i;return '[data-actual-control="'+i+'"]'})`)
|
|
assert.equal(controls.length, 5)
|
|
for (const selector of controls) {
|
|
const s = await measure(selector, 'actual-continue-field')
|
|
assert.equal(s.borderStyle, 'solid')
|
|
assert.equal(s.border, s.tokens['--border-control'])
|
|
assert.equal(s.radius, '9px')
|
|
assert(s.height >= 38)
|
|
assert(s.font.includes('Inter Variable'))
|
|
await focus(selector)
|
|
}
|
|
const danger = '.runtime-customization-item .danger-ghost.icon-button'
|
|
await hover(danger, true)
|
|
const s = await measure(danger, 'actual-continue-danger-hover')
|
|
assert.equal(s.color, s.tokens['--danger'])
|
|
assert.equal(s.background, s.tokens['--danger-subtle'])
|
|
await hover(danger, false)
|
|
await js("document.querySelector('.runtime-preset-editor').scrollIntoView({block:'start'})")
|
|
await screenshot(`${theme}-continue`)
|
|
}
|
|
assert.deepEqual(errors, [])
|
|
if (artifacts) await writeFile(join(artifacts, 'computed.json'), JSON.stringify({ observations, errors, modelCalls: 0 }, null, 2))
|
|
console.log(`Shared controls: ${observations.length} computed observations; light/dark, settings/body Portal, native Tab, actual Continue; model calls: 0`)
|
|
app.exit(0)
|
|
} catch (error) {
|
|
console.error(error)
|
|
if (artifacts) {
|
|
await screenshot('failure')
|
|
await writeFile(join(artifacts, 'failure.json'), JSON.stringify({ observations, errors, failure: String(error.stack) }, null, 2))
|
|
}
|
|
app.exit(1)
|
|
}
|
|
})
|