fix: restore consistent controls, usable overlays and narrow layouts

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.
This commit is contained in:
mesalogo
2026-09-27 21:57:35 +08:00
parent 11facf0392
commit 2713a81bd9
49 changed files with 1868 additions and 223 deletions
+63 -1
View File
@@ -1,9 +1,11 @@
// @vitest-environment node
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { copyFile, mkdtemp, rm } from 'node:fs/promises'
import { copyFile, mkdtemp, rm, readFile, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { build } from 'esbuild'
import { createServer } from 'vite'
import react from '@vitejs/plugin-react'
import { expect, it } from 'vitest'
@@ -36,3 +38,63 @@ it('keeps menus and notifications interactive above real Electron modals', async
} finally { clearTimeout(timeout) }
} finally { await server.close(); await rm(directory, { recursive: true, force: true }) }
}, 120000)
it.each([
['opens the MCP editor from App settings with native browser isolation and real persistence', 'mcp-overlay-main.ts'],
['keeps nested previews, sandbox keyboard, project modals and native overlays usable', 'overlay-paths-main.ts']
])('%s', async (_name, entry) => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-mcp-overlay-'))
const server = await createServer({ configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
plugins: [react({ include: /\/(src|tests)\/.*\.[jt]sx?$/ }), { name: 'mcp-app', configureServer(server) {
server.middlewares.use('/mcp.html', async (_request, response) => {
response.setHeader('Content-Type', 'text/html')
response.end(await server.transformIndexHtml('/mcp.html',
'<html><body><div id="root"></div><script type="module" src="/tests/support/magic-notes-capture-renderer.tsx"></script></body></html>'))
})
} }], server: { host: '127.0.0.1', port: 0, watch: null },
optimizeDeps: { entries: ['tests/support/magic-notes-capture-renderer.tsx'], include: ['fabric', 'quill', 'quill/blots/block', 'quill-delta', 'jspdf', 'pdfjs-dist', 'react', 'react-dom/client', 'react-i18next'] } })
try {
const driver = join(directory, 'main.mjs')
await build({ entryPoints: [`tests/support/${entry}`], outfile: driver,
bundle: true, platform: 'node', format: 'esm', external: ['electron'], plugins: [
{ name: 'isolated-external-services', setup(build) {
build.onResolve({ filter: /channels\/channel-env$/ }, () => ({ path: 'channels', namespace: 'mcp' }))
build.onResolve({ filter: /agent\/create-runtime$/ }, () => ({ path: 'runtime', namespace: 'mcp' }))
build.onLoad({ filter: /.*/, namespace: 'mcp' }, ({ path }) => ({ contents: path === 'channels'
? 'export const startEnvironmentChannels=()=>[];'
: 'export const createDefaultModelRuntime=()=>{globalThis.overlayModelAttempts=(globalThis.overlayModelAttempts??0)+1;throw new Error("Model calls forbidden")}; export const createModelProfileRuntime=createDefaultModelRuntime;' }))
} },
{ name: 'external-packages', setup(build) {
build.onResolve({ filter: /^[^./]/ }, async args => {
if (args.pluginData?.resolved || /^[A-Za-z]:/.test(args.path) || args.path.startsWith('node:') || args.path === 'electron') return
const result = await build.resolve(args.path, { resolveDir: resolve('.'), kind: 'import-statement', pluginData: { resolved: true } })
return result.errors.length ? { errors: result.errors } : { path: result.external ? result.path : pathToFileURL(result.path).href, external: true }
})
} }
] })
await build({ entryPoints: ['src/preload/index.ts'], outfile: join(directory, 'preload.cjs'), bundle: true, platform: 'node', format: 'cjs', external: ['electron'] })
await build({ entryPoints: ['src/main/execution-stats-worker.ts'], outfile: join(directory, 'out/main/execution-stats-worker.js'), bundle: true, platform: 'node', format: 'cjs' })
await server.listen()
const env: NodeJS.ProcessEnv = { ...process.env, GB_MCP_DIRECTORY: directory, GB_MCP_URL: server.resolvedUrls!.local[0] + 'mcp.html' }
delete env.ELECTRON_RUN_AS_NODE
const bootstrap = join(directory, 'bootstrap.cjs')
await writeFile(bootstrap, `import(${JSON.stringify(pathToFileURL(driver).href)}).catch(error => { console.error(error); require('electron').app.exit(1) })`)
const child = spawn(createRequire(import.meta.url)('electron'), [bootstrap], { env, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', data => { output += data })
child.stderr.on('data', data => { output += data })
const timeout = setTimeout(() => child.kill(), 90000)
try {
expect(await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) }), output).toBe(0)
} finally { clearTimeout(timeout) }
const result = JSON.parse(await readFile(join(directory, 'result.json'), 'utf8'))
process.stdout.write(`${entry} Electron evidence: ${JSON.stringify(result)}\n`)
expect(result).toMatchObject({ modelAttempts: 0 })
if (entry === 'mcp-overlay-main.ts') {
expect(result.persistedAfterReload).toBe(true)
expect(result.scenarios).toHaveLength(4)
} else {
expect(result).toMatchObject({ image: true, iframe: true, project: true, tooltip: true, citation: true })
}
} finally { await server.close(); await rm(directory, { recursive: true, force: true }) }
}, 180000)
+48
View File
@@ -0,0 +1,48 @@
// @vitest-environment node
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { copyFile, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { createServer } from 'vite'
import react from '@vitejs/plugin-react'
import { expect, it } from 'vitest'
it('preserves shared control states in real Chromium across themes and containers', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-shared-controls-'))
const server = await createServer({
configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
plugins: [react(), { name: 'shared-controls', configureServer(server) {
server.middlewares.use('/controls.html', async (_request, response) => {
response.setHeader('Content-Type', 'text/html')
response.end(await server.transformIndexHtml('/controls.html',
'<html><body><div id="root"></div><script type="module" src="/tests/support/shared-controls-regression.tsx"></script></body></html>'))
})
} }],
optimizeDeps: { entries: ['tests/support/shared-controls-regression.tsx'] },
server: { host: '127.0.0.1', port: 0, watch: null }
})
try {
await server.listen()
const driver = join(directory, 'driver.mjs')
await copyFile(resolve('tests/support/shared-controls-driver.mjs'), driver)
const env: NodeJS.ProcessEnv = { ...process.env, GB_CONTROLS_DIRECTORY: directory,
GB_CONTROLS_URL: server.resolvedUrls!.local[0] + 'controls.html' }
delete env.ELECTRON_RUN_AS_NODE
const child = spawn(createRequire(import.meta.url)('electron'), [driver], { env, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', data => { output += data })
child.stderr.on('data', data => { output += data })
const timeout = setTimeout(() => child.kill(), 90000)
try {
expect(await new Promise((resolve, reject) => {
child.once('exit', resolve)
child.once('error', reject)
}), output).toBe(0)
process.stdout.write(output)
} finally { clearTimeout(timeout) }
} finally {
await server.close()
await rm(directory, { recursive: true, force: true })
}
}, 120000)
+228
View File
@@ -0,0 +1,228 @@
import { app, BrowserWindow, safeStorage, WebContentsView } from 'electron'
import assert from 'node:assert/strict'
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { createServer } from 'node:http'
import { registerIpcHandlers } from '../../src/main/ipc'
import { AssistantDatabase } from '../../src/main/assistant/assistant-database'
import { ApplicationSettingsStore } from '../../src/main/application-settings-store'
import { CapabilityService } from '../../src/main/capabilities/capability-service'
import { BrowserService } from '../../src/main/browser/browser-service'
import { RuntimeSettingsStore } from '../../src/main/runtime-settings-store'
const directory = process.env.GB_MCP_DIRECTORY!
app.setPath('userData', join(directory, 'profile'))
app.commandLine.appendSwitch('force-device-scale-factor', '1')
// Other desktop windows must not suspend the frame-based input synchronization.
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion')
app.whenReady().then(async () => {
console.log('MCP overlay: Electron ready')
const win = new BrowserWindow({ show: true, width: 1280, height: 900, useContentSize: true,
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false,
backgroundThrottling: false, preload: join(directory, 'preload.cjs') } })
const database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
database.initialize(directory)
const applicationSettings = new ApplicationSettingsStore(join(directory, 'application.json'))
await applicationSettings.update({ checkUpdatesOnStartup: false })
const cipher = { isAvailable: () => safeStorage.isEncryptionAvailable(),
encrypt: (value: string) => safeStorage.encryptString(value), decrypt: (value: Buffer) => safeStorage.decryptString(value) }
const capabilities = new CapabilityService(join(directory, 'capabilities.json'),
join(directory, 'builtin-skills'), join(directory, 'imported-skills'), cipher)
const browser = new BrowserService({ parentWindow: win })
const settings = new RuntimeSettingsStore(join(directory, 'runtime.json'), cipher, {})
// Only unrelated startup dependencies are substituted; MCP, browser, preload and IPC are production.
const dispose = registerIpcHandlers(win,
{ capability: 'text', getStatus: async () => ({ available: true, name: 'Overlay test', capability: 'text' }) } as never,
'CommandOrControl+Shift+Space', settings,
capabilities, { clear() {}, cancelImport() {}, getDraft: () => [] } as never,
{ snapshot: () => ({ libraries: [], sources: [], documents: [], entities: [], relations: [], evidence: [], tasks: [] }),
database: { externalStore: { listBindings: () => [] } }, external: { listInstances: () => [] } } as never,
database, { clear() {} } as never, {} as never, async () => {}, undefined, browser,
undefined, undefined, applicationSettings, undefined, undefined, undefined, undefined, undefined,
undefined, undefined, undefined, undefined, undefined, { getPending: async () => undefined } as never)
const errors: string[] = []
win.webContents.on('console-message', event => { if (event.level === 'error') errors.push(event.message) })
const js = <T = unknown>(code: string): Promise<T> => win.webContents.executeJavaScript(code)
const settle = (): Promise<unknown> => js('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
const wait = async (code: string): Promise<void> => {
for (let i = 0; i < 600; i++) {
if (await js(code)) return
await new Promise(resolve => setTimeout(resolve, 25))
}
throw new Error(`Timeout: ${code}\nFocus: ${await js('document.activeElement?.tagName + " " + document.activeElement?.className')}\n${await js('document.body.innerText')}\n${errors.join('\n')}`)
}
const click = async (selector: string): Promise<void> => {
win.focus(); win.webContents.focus()
await wait(`!!document.querySelector(${JSON.stringify(selector)})`)
await js(`document.querySelector(${JSON.stringify(selector)}).scrollIntoView({block:'nearest', inline:'nearest'})`)
await settle()
const point = await js<{ x: number; y: number; hit: boolean }>(`(() => {
const e = document.querySelector(${JSON.stringify(selector)}); e.scrollIntoView({block:'nearest'});
const r = e.getBoundingClientRect(), x = Math.round(r.x+r.width/2), y = Math.round(r.y+r.height/2);
return {x,y,hit:e.contains(document.elementFromPoint(x,y))}; })()`)
assert(point.hit, `Occluded: ${selector} ${JSON.stringify(point)} ${await js(`document.elementFromPoint(${point.x},${point.y})?.className`)}`)
win.focus(); win.webContents.focus()
win.webContents.sendInputEvent({ type: 'mouseDown', x: point.x, y: point.y, button: 'left', clickCount: 1 })
win.webContents.sendInputEvent({ type: 'mouseUp', x: point.x, y: point.y, button: 'left', clickCount: 1 })
await settle()
}
const key = async (keyCode: string, modifiers: Electron.KeyboardInputEvent['modifiers'] = []): Promise<void> => {
win.focus(); win.webContents.focus()
win.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers })
win.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers })
await settle()
}
const evidence: Record<string, unknown> = {}
const pageServer = createServer((_request, response) => {
response.setHeader('Content-Type', 'text/html')
response.end('<html><body style="background:#f80"><h1>MCP overlay browser fixture</h1><input id="browser-input"></body></html>')
})
await new Promise<void>(resolve => pageServer.listen(0, '127.0.0.1', resolve))
const pageAddress = pageServer.address() as { port: number }
const nativeView = (): WebContentsView | undefined => win.contentView.children.find(view => view instanceof WebContentsView) as WebContentsView | undefined
const waitNative = async (visible: boolean): Promise<void> => {
for (let i = 0; i < 200; i++) {
if (nativeView()?.getVisible() === visible) return
await new Promise(resolve => setTimeout(resolve, 25))
}
assert.equal(nativeView()?.getVisible(), visible, 'Native browser visibility ' + JSON.stringify(await js(`({
overlays:[...document.querySelectorAll('[aria-modal=true],[role=dialog],[role=menu],.app-notification')].map(e=>[e.className,e.getBoundingClientRect().toJSON()]),
viewport:document.querySelector('.assistant-sidebar__browser-viewport')?.getBoundingClientRect().toJSON(),
workbar:document.querySelector('.assistant-sidebar-toggle')?.outerHTML,
inert:[...document.querySelectorAll('[inert]')].map(e=>e.className || e.id)})`)))
}
try {
console.log('MCP overlay: loading App')
await win.loadURL(process.env.GB_MCP_URL!)
console.log('MCP overlay: App loaded')
await wait('!!document.querySelector(".sidebar")')
console.log('MCP overlay: App mounted')
const add = '#mcp-settings-panel-custom .settings-content-actions button'
const scenarios: unknown[] = []
for (const withBrowser of [false, true]) {
if (withBrowser) {
await click('.assistant-sidebar-toggle[aria-expanded=false]')
await wait('!!document.querySelector(".assistant-sidebar-toggle[aria-expanded=true]")')
await js('document.querySelector(".workbar-shell__tab[aria-selected=true]").focus()')
await key('Right')
await key('Right')
await wait('document.querySelector(".workbar-shell__tab[aria-selected=true]").textContent.startsWith("Browser")')
await waitNative(true)
await wait('!document.querySelector(".assistant-sidebar__browser-address input").disabled')
await click('.assistant-sidebar__browser-address input')
await key('A', ['control'])
await win.webContents.insertText(`http://127.0.0.1:${pageAddress.port}/`)
await wait(`document.querySelector('.assistant-sidebar__browser-address input').value === 'http://127.0.0.1:${pageAddress.port}/' &&
!document.querySelector('.assistant-sidebar__browser-go').disabled`)
await click('.assistant-sidebar__browser-go')
for (let i = 0; i < 200; i++) {
if (await nativeView()!.webContents.executeJavaScript('document.querySelector("h1")?.textContent === "MCP overlay browser fixture"')) break
await new Promise(resolve => setTimeout(resolve, 25))
}
assert.equal(await nativeView()!.webContents.executeJavaScript('document.querySelector("h1")?.textContent'), 'MCP overlay browser fixture',
'Browser page ' + nativeView()!.webContents.getURL() + ' ' + await nativeView()!.webContents.executeJavaScript('document.body?.innerText'))
await nativeView()!.webContents.executeJavaScript('window.overlayDocumentToken = "preserve-page"')
}
for (const theme of ['light', 'dark']) {
console.log('MCP overlay scenario:', { withBrowser, theme })
const width = theme === 'light' || withBrowser ? 1280 : 960
const height = theme === 'light' ? 900 : 420
await js(`document.documentElement.dataset.theme = '${theme}'`)
await click('button[aria-label="Settings"]')
await click('#settings-tab-capabilities')
await click('#capabilities-settings-tab-mcp')
await click('#mcp-settings-tab-custom')
win.setContentSize(width, height)
await wait(`innerWidth === ${width} && innerHeight === ${height}`)
await settle()
if (withBrowser) await waitNative(false)
const browserId = nativeView()?.webContents.id
await click(add)
await wait('document.activeElement === document.querySelector(".mcp-editor input")')
if (withBrowser) await waitNative(false)
assert(await js(`document.querySelector('.settings-backdrop').inert && document.querySelector('.app-shell').inert &&
!document.querySelector('.mcp-editor').closest('[inert]')`), 'Only the child modal may be interactive')
await click('.mcp-editor input')
await win.webContents.insertText('Keyboard draft')
await wait('document.querySelector(".mcp-editor input").value === "Keyboard draft"')
// Programmatic background focus must also be rejected by Chromium inert handling.
await js('document.querySelector("#settings-tab-appearance").focus()')
assert(await js('!!document.activeElement.closest(".mcp-editor")'))
await click('.mcp-editor__header button')
await wait(`!document.querySelector('.mcp-editor') && document.activeElement === document.querySelector('${add}')`)
for (const close of ['escape', 'cancel', 'backdrop']) {
await click(add)
await wait('document.activeElement === document.querySelector(".mcp-editor input")')
await key('Tab', ['shift'])
assert(await js('document.activeElement.matches(".mcp-editor__header button")'))
await key('Tab', ['shift'])
assert(await js('document.activeElement.matches(".mcp-editor__actions .primary-button")'))
await key('Tab')
assert(await js('document.activeElement.matches(".mcp-editor__header button")'))
if (close === 'escape') await key('Escape')
else if (close === 'cancel') await click('.mcp-editor__actions .secondary-button')
else {
win.webContents.sendInputEvent({ type: 'mouseDown', x: 2, y: 2, button: 'left', clickCount: 1 })
win.webContents.sendInputEvent({ type: 'mouseUp', x: 2, y: 2, button: 'left', clickCount: 1 })
}
await wait(`!document.querySelector('.mcp-editor') && document.activeElement === document.querySelector('${add}')`)
assert(await js('!!document.querySelector(".settings-panel") && !document.querySelector(".settings-backdrop").inert && document.querySelector(".app-shell").inert'))
if (withBrowser) await waitNative(false)
}
await click(add)
await click('.mcp-editor input')
const name = `Overlay ${withBrowser}-${theme}`
await win.webContents.insertText(name)
await click('.mcp-editor input[aria-label]')
await win.webContents.insertText('overlay-test-command')
// Disable the test configuration so no MCP process is launched by runtime refresh.
await click('.mcp-editor input[role="switch"]')
await click('.mcp-editor__actions .primary-button')
await wait(`!document.querySelector('.mcp-editor') && document.activeElement === document.querySelector('${add}')`)
const saved = (await capabilities.getSnapshot()).mcpServers.find(server => server.name === name)!
assert(saved && !saved.enabled && saved.transport === 'stdio' && saved.command === 'overlay-test-command', 'Production IPC must persist the form')
const edit = '#mcp-settings-panel-custom button[aria-label="Edit ' + name + '"]'
await click(edit)
await wait(`document.querySelector('.mcp-editor input')?.value === '${name}'`)
await click('.mcp-editor input')
await key('Escape')
await wait(`!document.querySelector('.mcp-editor') && document.activeElement === document.querySelector('${edit}')`)
win.setContentSize(1280, 900)
await wait('innerWidth === 1280 && innerHeight === 900')
await settle()
await key('Escape')
await wait('!document.querySelector(".settings-panel") && !document.querySelector(".app-shell").inert')
assert(await js('document.activeElement.matches("button[aria-label=Settings]")'), 'Settings must restore its own trigger')
if (withBrowser) {
await waitNative(true)
assert.equal(nativeView()!.webContents.id, browserId, 'Modal must not recreate browser')
assert.equal(nativeView()!.webContents.getURL(), `http://127.0.0.1:${pageAddress.port}/`)
assert.equal(await nativeView()!.webContents.executeJavaScript('window.overlayDocumentToken'), 'preserve-page', 'Modal must not reload browser')
}
scenarios.push({ withBrowser, theme, width, height, hitTesting: true, focusCycle: true, parentIsolation: true,
closeMethods: ['X', 'Escape', 'Cancel', 'backdrop', 'save'], savedId: saved.id, edit: true,
...(withBrowser ? { nativeHiddenDuringModals: true, nativeRestoredWithoutReload: true } : {}) })
}
win.setContentSize(1280, 900)
await wait('innerWidth === 1280 && innerHeight === 900')
await settle()
}
const reloaded = new CapabilityService(join(directory, 'capabilities.json'),
join(directory, 'builtin-skills'), join(directory, 'imported-skills'), cipher)
assert.equal((await reloaded.getSnapshot()).mcpServers.length, 4)
evidence.scenarios = scenarios
evidence.persistedAfterReload = true
evidence.modelAttempts = (globalThis as typeof globalThis & { overlayModelAttempts?: number }).overlayModelAttempts ?? 0
await writeFile(join(directory, 'result.json'), JSON.stringify(evidence))
assert.equal(evidence.modelAttempts, 0)
assert.deepEqual(errors, [])
} finally {
win.destroy()
await dispose()
await browser.dispose()
pageServer.close()
database.close()
}
app.exit(0)
}).catch(error => { console.error(error); app.exit(1) })
+247
View File
@@ -0,0 +1,247 @@
import { app, BrowserWindow, ipcMain, nativeImage, safeStorage, screen, WebContentsView } from 'electron'
import assert from 'node:assert/strict'
import { randomUUID } from 'node:crypto'
import { createRequire } from 'node:module'
import { createServer } from 'node:http'
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { registerIpcHandlers } from '../../src/main/ipc'
import { AssistantDatabase } from '../../src/main/assistant/assistant-database'
import { ApplicationSettingsStore } from '../../src/main/application-settings-store'
import { BrowserService } from '../../src/main/browser/browser-service'
import { CapabilityService } from '../../src/main/capabilities/capability-service'
import { defaultRuntimeSettings } from '../../src/shared/contracts'
import { ipcChannels } from '../../src/shared/ipc-channels'
const directory = process.env.GB_MCP_DIRECTORY!
app.setPath('userData', join(directory, 'profile'))
app.commandLine.appendSwitch('force-device-scale-factor', '1')
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion')
app.whenReady().then(async () => {
const win = new BrowserWindow({ show: true, frame: false, width: 1280, height: 800, useContentSize: true,
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false,
backgroundThrottling: false, preload: join(directory, 'preload.cjs') } })
const database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
database.initialize(directory)
const project = database.listProjects()[0]!
const resultId = randomUUID()
const html = '<html><body><a href="#end">Jump to end</a>' + '<p>Long preview text</p>'.repeat(80) +
'<h2 id="end">End</h2><script>window.parent.previewUnsafe=true</script><img src="https://example.invalid/leak"></body></html>'
database.saveLocalConversations([{ header: { id: randomUUID(), projectId: project.id, title: 'Overlay paths', updatedAt: Date.now() },
messages: [{ id: randomUUID(), role: 'assistant', state: 'complete', createdAt: Date.now(), content: '```html\n' + html + '\n```',
attachments: [{ id: randomUUID(), kind: 'text', name: 'review.pdf', size: 20, preview: 'Saved result', resultId }],
sourceReferences: [{ libraryId: randomUUID(), libraryName: 'Review', documentName: 'Review source', sourceName: 'Fixture',
snippet: 'Saved citation', rank: 1, external: { kind: 'external', provider: 'fastgpt', instanceId: randomUUID(), remoteKnowledgeBaseId: 'review' } }]
}, { id: randomUUID(), role: 'assistant', state: 'complete', createdAt: Date.now(),
content: '```html\n<html><body><h1>Plain keyboard preview</h1>' + '<p>Long plain content</p>'.repeat(80) + '</body></html>\n```' }] }])
const settings = { ...defaultRuntimeSettings, workspacePath: directory, modelProfiles: [], embeddingConnections: [] }
const applicationSettings = new ApplicationSettingsStore(join(directory, 'application.json'))
await applicationSettings.update({ checkUpdatesOnStartup: false })
const browser = new BrowserService({ parentWindow: win })
const cipher = { isAvailable: () => safeStorage.isEncryptionAvailable(),
encrypt: (value: string) => safeStorage.encryptString(value), decrypt: (value: Buffer) => safeStorage.decryptString(value) }
const capabilities = new CapabilityService(join(directory, 'capabilities.json'), join(directory, 'builtin-skills'), join(directory, 'imported-skills'), cipher)
const dispose = registerIpcHandlers(win,
{ capability: 'text', getStatus: async () => ({ available: true, name: 'Overlay fixture', capability: 'text' }) } as never,
'CommandOrControl+Shift+Space', { getResolvedSettings: async () => settings, getPublicSettings: async () => settings } as never,
capabilities, { clear() {}, cancelImport() {}, getDraft: () => [] } as never,
{ snapshot: () => ({ libraries: [], sources: [], documents: [], entities: [], relations: [], evidence: [], tasks: [] }),
database: { externalStore: { listBindings: () => [] } }, external: { listInstances: () => [] } } as never,
database, { clear() {} } as never, {} as never, async () => {}, undefined, browser,
undefined, undefined, applicationSettings, undefined, undefined, undefined, undefined, undefined,
undefined, undefined, undefined, undefined, undefined, { getPending: async () => undefined } as never)
// Parsing payloads are fixtures; App, preload, window keyboard IPC and browser services are production.
const redImage = nativeImage.createFromBitmap(Buffer.from([0, 0, 255, 255]), { width: 1, height: 1 }).resize({ width: 200, height: 100 }).toDataURL()
ipcMain.removeHandler(ipcChannels.documentParsingResult)
ipcMain.removeHandler(ipcChannels.documentParsingImage)
ipcMain.handle(ipcChannels.documentParsingResult, () => ({ id: resultId, fileName: 'review.pdf', sourceFormat: '.pdf',
sections: [{ locator: 'Page 1', content: '![Review](asset:image)' }],
images: [{ id: 'image', pageNumber: 1, width: 200, height: 100, size: 100, mimeType: 'image/png' }],
missingImages: [], warnings: [], completeness: 'complete', parsedAt: new Date().toISOString(), durationMs: 1, settings: {} }))
ipcMain.handle(ipcChannels.documentParsingImage, () => redImage)
const js = <T = unknown>(code: string): Promise<T> => win.webContents.executeJavaScript(code)
const settle = (): Promise<unknown> => js('new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))')
const wait = async (code: string): Promise<void> => {
for (let i = 0; i < 400; i++) {
if (await js(code)) return
await new Promise(r => setTimeout(r, 25))
}
throw new Error(`Timeout: ${code}\nFocus: ${await js('document.activeElement?.outerHTML')}\n${await js('document.body.innerText')}`)
}
const click = async (selector: string): Promise<void> => {
await wait(`!!document.querySelector(${JSON.stringify(selector)})`)
await js(`document.querySelector(${JSON.stringify(selector)}).scrollIntoView({block:'nearest'})`)
await settle()
const p = await js<{ x: number; y: number; hit: boolean }>(`(()=>{const e=document.querySelector(${JSON.stringify(selector)}),r=e.getBoundingClientRect(),x=Math.round(r.x+r.width/2),y=Math.round(r.y+r.height/2);return {x,y,hit:e.contains(document.elementFromPoint(x,y))}})()`)
assert(p.hit, `Occluded ${selector}`)
win.focus()
win.webContents.sendInputEvent({ type: 'mouseDown', x: p.x, y: p.y, button: 'left', clickCount: 1 })
win.webContents.sendInputEvent({ type: 'mouseUp', x: p.x, y: p.y, button: 'left', clickCount: 1 })
await settle()
}
const key = async (keyCode: string, modifiers: Electron.KeyboardInputEvent['modifiers'] = []): Promise<void> => {
if (!win.isFocused()) win.focus()
// Do not refocus WebContents here: it would reset a focused child frame.
win.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers })
win.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers })
await settle()
}
const evidence: Record<string, unknown> = {}
const artifacts = process.env.GB_OVERLAY_PATHS_ARTIFACTS
if (artifacts) await mkdir(artifacts, { recursive: true })
const screenshot = async (name: string): Promise<void> => {
if (artifacts) await writeFile(join(artifacts, name + '.png'), (await win.webContents.capturePage()).toPNG())
}
const server = createServer((_q, r) => { r.setHeader('Content-Type', 'text/html'); r.end('<h1>Native overlay page</h1>') })
await new Promise<void>(r => server.listen(0, '127.0.0.1', r))
const origin = `http://127.0.0.1:${(server.address() as { port: number }).port}/`
const native = (): WebContentsView | undefined => win.contentView.children.find(v => v instanceof WebContentsView) as WebContentsView | undefined
const waitNative = async (visible: boolean): Promise<void> => {
for (let i = 0; i < 400; i++) {
if (native()?.getVisible() === visible) return
await new Promise(r => setTimeout(r, 25))
}
assert.equal(native()?.getVisible(), visible)
}
try {
await win.loadURL(process.env.GB_MCP_URL!)
await wait('!!document.querySelector(".message-html-preview")')
for (const theme of ['light', 'dark']) {
await js(`document.documentElement.dataset.theme='${theme}'`)
await click('[aria-label="查看解析结果:review.pdf"]')
await click('.document-result-image button')
await wait('!!document.querySelector(".image-viewer-dialog")')
assert(await js(`getComputedStyle(document.querySelector('.image-viewer-backdrop')).zIndex === getComputedStyle(document.querySelector('.document-parsing-diagnostic-backdrop')).zIndex && document.querySelector('.document-parsing-diagnostic-backdrop').inert`))
const imageRect = await js<{ x: number; y: number }>(`(()=>{const r=document.querySelector('.image-viewer-dialog img').getBoundingClientRect();return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)}})()`)
const pixel = (await win.webContents.capturePage({ ...imageRect, width: 1, height: 1 })).toBitmap()
assert(pixel[2]! > 240 && pixel[1]! < 10 && pixel[0]! < 10, 'Image pixels must paint above the inert parent')
await screenshot(theme + '-nested-image')
await key('Escape')
await wait('!document.querySelector(".image-viewer-dialog") && !document.querySelector(".document-parsing-diagnostic-backdrop").inert')
await wait('document.activeElement.matches(".document-result-image button")')
await key('Escape')
await wait('!document.querySelector(".document-parsing-diagnostic")')
}
evidence.image = true
await click('.message-html-preview__actions button:last-child')
await wait('!!document.querySelector(".html-preview-viewer")')
assert(await js(`document.querySelector('.html-preview-viewer iframe').getAttribute('sandbox') === '' && document.querySelector('.html-preview-viewer iframe').srcdoc.includes("script-src 'none'")`))
await key('Tab')
await wait('document.activeElement.matches(".html-preview-viewer iframe")')
assert(await js(`!document.querySelector('.html-preview-viewer iframe').srcdoc.includes('<script>') && !document.querySelector('.html-preview-viewer iframe').srcdoc.includes('https://example.invalid/leak')`))
for (let i = 0; i < 6 && !await js('document.activeElement.matches(".html-preview-viewer__header button")'); i++) {
await key('Tab')
}
await wait('document.activeElement.matches(".html-preview-viewer__header button")')
await key('Tab', ['shift'])
await wait('document.activeElement.matches(".html-preview-viewer iframe")')
for (let i = 0; i < 6 && !await js('document.activeElement.matches(".html-preview-viewer__header button")'); i++) {
await key('Tab', ['shift'])
}
await wait('document.activeElement.matches(".html-preview-viewer__header button")')
await key('Tab')
await wait('document.activeElement.matches(".html-preview-viewer iframe")')
await key('Escape')
await wait('!document.querySelector(".html-preview-viewer")')
assert(await js('document.activeElement.matches(".message-html-preview__actions button:last-child")'))
assert(await js('window.previewUnsafe !== true'))
await click('.message-html-preview:has(iframe[srcdoc*="Plain keyboard preview"]) .message-html-preview__actions button:last-child')
await wait('!!document.querySelector(".html-preview-viewer")')
await key('Tab')
await wait('document.activeElement.matches(".html-preview-viewer iframe")')
await key('PageDown')
await key('Escape')
await wait('!document.querySelector(".html-preview-viewer")')
evidence.iframe = true
win.setContentSize(760, 700)
await wait('document.querySelector(".sidebar").inert')
await click('.sidebar-toggle')
await click('.project-switcher .icon-button')
await wait('!!document.querySelector(".project-create-card") && document.querySelector(".app-shell").inert')
await key('Escape')
await wait('!document.querySelector(".project-create-card")')
assert(await js('!document.querySelector(".sidebar").inert && document.activeElement.matches(".project-switcher .icon-button")'))
await key('Escape')
await wait('document.querySelector(".sidebar").inert')
evidence.project = true
win.setContentSize(1280, 800)
await click('.sidebar-toggle')
await click('.message-citations summary')
await click('.message-citations__actions button')
await wait('!!document.querySelector(".knowledge-citation-dialog")')
assert.equal(await js('getComputedStyle(document.querySelector(".knowledge-citation-dialog")).webkitAppRegion'), 'no-drag')
if (process.platform === 'win32') {
const koffi = createRequire(join(process.cwd(), 'package.json'))('koffi') as typeof import('koffi')
const send = koffi.load('user32.dll').func('intptr_t __stdcall SendMessageW(void *hWnd, uint32_t Msg, uintptr_t wParam, intptr_t lParam)')
const hwnd = koffi.decode(win.getNativeWindowHandle(), 'void *')
await js('document.querySelector(".knowledge-citation-dialog").style.webkitAppRegion="initial"')
const bounds = win.getBounds()
const hit = (x: number): number => {
const point = screen.dipToScreenPoint({ x: bounds.x + x, y: bounds.y + 12 })
return Number(send(hwnd, 0x84, 0, (point.y << 16) | (point.x & 0xffff)))
}
let captionX: number | undefined
for (let attempt = 0; attempt < 40 && captionX === undefined; attempt++) {
for (let x = 20; x < 1200; x += 20) if (hit(x) === 2) { captionX = x; break }
if (captionX === undefined) await new Promise(r => setTimeout(r, 25))
}
assert.notEqual(captionX, undefined, 'Control must exercise an actual native drag region')
await js('document.querySelector(".knowledge-citation-dialog").style.removeProperty("-webkit-app-region")')
for (let attempt = 0; attempt < 40 && hit(captionX!) !== 1; attempt++) await new Promise(r => setTimeout(r, 25))
assert.equal(hit(captionX!), 1, 'Citation overlay must receive HTCLIENT, not HTCAPTION')
evidence.nativeHit = 'HTCLIENT; negative control HTCAPTION'
}
await key('Escape')
evidence.citation = true
await click('.assistant-sidebar-toggle[aria-expanded=false]')
await js('document.querySelector(".workbar-shell__tab[aria-selected=true]").focus()')
await key('Right'); await key('Right')
await wait('document.querySelector(".workbar-shell__tab[aria-selected=true]").textContent.startsWith("Browser")')
await waitNative(true)
await wait('!document.querySelector(".assistant-sidebar__browser-address input").disabled')
await click('.assistant-sidebar__browser-address input')
await key('A', ['control'])
await win.webContents.insertText(origin)
await click('.assistant-sidebar__browser-go')
for (let i = 0; i < 400 && native()!.webContents.getURL() !== origin; i++) await new Promise(r => setTimeout(r, 25))
await native()!.webContents.executeJavaScript('window.overlayToken="same-document"')
const nativeId = native()!.webContents.id
await js(`[...document.querySelectorAll('.primary-nav button')].find(e=>e.textContent.includes('Run history')).click()`)
await wait('!!document.querySelector(".activity-panel .inline-help")')
for (const width of [1600, 1000]) {
win.setContentSize(width, 800)
await wait(`innerWidth === ${width}`)
if (width === 1000) {
await wait('document.querySelector(".assistant-sidebar-toggle").getAttribute("aria-expanded") === "false"')
await click('.assistant-sidebar-toggle')
await waitNative(true)
}
await js('document.querySelector(".assistant-sidebar__resize-handle").focus()')
await key(width === 1000 ? 'End' : 'Home')
await wait(`(()=>{const e=document.querySelector('.assistant-sidebar');return Math.abs(e.getBoundingClientRect().width-parseFloat(e.style.getPropertyValue('--assistant-sidebar-width')))<=2})()`)
await click('.activity-panel .inline-help')
await wait('!!document.querySelector(".inline-help__content")')
const overlaps = await js<boolean>(`(()=>{const a=document.querySelector('.inline-help__content').getBoundingClientRect(),b=document.querySelector('.assistant-sidebar__browser-viewport').getBoundingClientRect();return a.right>b.left&&a.left<b.right&&a.top<b.bottom&&a.bottom>b.top})()`)
assert.equal(overlaps, width === 1000, 'Real help/viewport layout ' + JSON.stringify(await js(`['.inline-help__content','.assistant-sidebar__browser-viewport','.assistant-sidebar__resize-handle'].map(s=>{const e=document.querySelector(s);return [s,e.getBoundingClientRect().toJSON(),e.getAttribute('aria-valuenow')]})`)))
await waitNative(!overlaps)
await screenshot(width + '-browser-help')
await key('Escape')
await waitNative(true)
assert.equal(native()!.webContents.id, nativeId)
assert.equal(await native()!.webContents.executeJavaScript('window.overlayToken'), 'same-document')
}
evidence.tooltip = true
evidence.modelAttempts = (globalThis as typeof globalThis & { overlayModelAttempts?: number }).overlayModelAttempts ?? 0
assert.equal(evidence.modelAttempts, 0)
await writeFile(join(directory, 'result.json'), JSON.stringify(evidence))
if (artifacts) await writeFile(join(artifacts, 'result.json'), JSON.stringify(evidence, null, 2))
} finally {
await dispose()
await browser.dispose()
win.destroy()
server.close()
database.close()
}
app.exit(0)
}).catch(error => { console.error(error); app.exit(1) })
+212
View File
@@ -0,0 +1,212 @@
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)
}
})
@@ -0,0 +1,73 @@
import { useState } from 'react'
import { createRoot } from 'react-dom/client'
import { createPortal } from 'react-dom'
import { Trash2 } from 'lucide-react'
import { RuntimeCustomizationSection } from '../../src/renderer/src/RuntimeCustomizationSection'
import { DestructiveConfirmActions, PageTabs, SegmentedControl } from '../../src/renderer/src/WorkspacePrimitives'
import { UiLocaleProvider } from '../../src/renderer/src/i18n/UiLocaleProvider'
import { applyAppearanceTheme } from '../../src/renderer/src/theme'
import type { DesktopApi } from '../../src/shared/contracts'
import '../../src/renderer/src/styles.css'
Object.defineProperty(window, 'goodbuddy', { value: {
runtimeCustomization: {
getSettings: async () => ({ opencode: {}, continue: { defaultPresetId: 'preset', presets: [{
id: 'preset', name: 'Fixture preset',
rules: [{ id: 'rule', name: 'Fixture rule', content: 'Rule content', enabled: true }],
prompts: [{ id: 'prompt', name: 'Fixture prompt', description: 'Prompt description', prompt: 'Prompt content' }]
}] } }),
getNativeSnapshot: async () => ({ provider: 'continue', available: true, inventoryStatus: 'available',
agents: [], tools: [], commands: [], lsp: [], formatters: [], mcpServers: [], skills: [], rules: [], prompts: [], resources: [],
context: { strategy: 'goodbuddy-summary', manualCompact: true, detail: 'Fixture' } })
}
} as unknown as DesktopApi })
function Controls({ name }: { name: string }): React.JSX.Element {
const [tab, setTab] = useState('first')
const [confirming, setConfirming] = useState<string>()
return <section data-controls={name} style={{ padding: 12 }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{['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'].map(className =>
<button type="button" key={className} className={className} data-button={className}>{className.includes('icon-button') ? 'X' : className}</button>)}
</div>
<div className="field">
<label>Text<input data-input="text" defaultValue="Fixture" /></label>
<label>Choice<select data-input="select" defaultValue="one"><option value="one">One</option></select></label>
<label>Content<textarea data-input="textarea" defaultValue="Fixture" /></label>
<label className="toggle-row"><input data-input="switch" role="switch" type="checkbox" defaultChecked /><span>Switch</span></label>
<label className="check-field"><input data-input="radio" type="radio" defaultChecked /><span>Radio</span></label>
<label className="check-field"><input data-input="checkbox" type="checkbox" defaultChecked /><span>Checkbox</span></label>
</div>
<label className="assistant-sidebar__browser-address">Address<input data-input="address" defaultValue="example.test" /></label>
<label className="custom-task-dialog__field">Dialog field<input data-input="dialog" defaultValue="Fixture" /></label>
<PageTabs ariaLabel={`${name} tabs`} idPrefix={name} value={tab} onChange={setTab}
tabs={[{ id: 'first', label: 'First' }, { id: 'second', label: 'Second' }]} />
<PageTabs ariaLabel={`${name} segmented tabs`} idPrefix={`${name}-segmented`} value={tab} onChange={setTab}
variant="segmented" tabs={[{ id: 'first', label: 'First' }, { id: 'second', label: 'Second' }]} />
<SegmentedControl ariaLabel={`${name} segments`} value="first" onChange={() => {}}
options={[{ value: 'first', label: 'First' }, { value: 'second', label: 'Second' }]} />
{['conversation-actions', 'workspace-files__menu'].map(menu => <div key={menu} className={menu} style={{ position: 'static' }}>
<button type="button" aria-label="Delete icon" className="danger-ghost icon-button" data-menu-ghost><Trash2 size={14} aria-hidden="true" /></button>
<DestructiveConfirmActions confirming={confirming === menu} confirmLabel="Delete fixture" triggerLabel="Delete"
onRequestConfirm={() => setConfirming(menu)} onCancel={() => setConfirming(undefined)} onConfirm={() => {}} />
</div>)}
</section>
}
function Fixture(): React.JSX.Element {
return <>
<div className="app-shell" style={{ display: 'block', overflow: 'auto' }}>
<button id="theme-light" onClick={() => applyAppearanceTheme('light')}>Light</button>
<button id="theme-dark" onClick={() => applyAppearanceTheme('dark')}>Dark</button>
<div className="settings-panel" style={{ display: 'block', height: 'auto', maxHeight: 'none', width: '100%' }}>
<Controls name="settings" />
<RuntimeCustomizationSection provider="continue" />
</div>
</div>
{createPortal(<div id="portal-controls" style={{ position: 'fixed', inset: 0, overflow: 'auto', display: 'none', background: 'var(--surface-raised)' }}>
<Controls name="portal" />
</div>, document.body)}
</>
}
createRoot(document.getElementById('root')!).render(<UiLocaleProvider initialPreference="zh-CN"><Fixture /></UiLocaleProvider>)
@@ -0,0 +1,153 @@
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 { setTimeout } from 'node:timers/promises'
import { setTimeout as startTimeout } from 'node:timers'
const directory = process.env.GB_LAYOUT_DIRECTORY
const artifacts = process.env.GB_LAYOUT_ARTIFACTS
app.setPath('userData', join(directory, 'profile'))
app.commandLine.appendSwitch('force-device-scale-factor', '1')
// Keep test frames advancing during native window resize/focus transitions.
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion')
app.whenReady().then(async () => {
const win = new BrowserWindow({ show: true, width: 1000, height: 720, useContentSize: true,
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } })
const errors = [], observations = []
let pending = 'startup'
startTimeout(async () => {
await writeFile(join(directory, 'result.json'), JSON.stringify({ passed: false, pending, observations, errors }))
app.exit(1)
}, 80000)
win.webContents.on('console-message', event => { if (event.level === 'error') errors.push(event.message) })
const js = code => { pending = code; return win.webContents.executeJavaScript(code) }
const wait = async code => {
for (let i = 0; i < 400; i++) { if (await js(code)) return; await setTimeout(25) }
throw new Error(`Timeout: ${code}\n${errors.join('\n')}`)
}
const settle = () => js('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
const point = selector => js(`(() => {
const e=document.querySelector(${JSON.stringify(selector)});if(!e)throw new Error('Missing control: '+${JSON.stringify(selector)});const r=e.getBoundingClientRect();
return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2),hit:e.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2))};
})()`)
const click = async selector => {
win.focus(); win.webContents.focus()
await wait('document.hasFocus()')
await settle()
const p = await point(selector)
win.webContents.sendInputEvent({ type: 'mouseMove', x: 0, y: 0 })
win.webContents.sendInputEvent({ type: 'mouseMove', x: p.x, y: p.y })
await wait(`(() => { const e=document.querySelector(${JSON.stringify(selector)}),r=e.getBoundingClientRect();return e.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2)) })()`)
win.webContents.sendInputEvent({ type: 'mouseDown', x: p.x, y: p.y, button: 'left', clickCount: 1 })
win.webContents.sendInputEvent({ type: 'mouseUp', x: p.x, y: p.y, button: 'left', clickCount: 1 })
await settle()
}
const key = async keyCode => {
win.focus(); win.webContents.focus()
await wait('document.hasFocus()')
win.webContents.sendInputEvent({ type: 'keyDown', keyCode })
win.webContents.sendInputEvent({ type: 'keyUp', keyCode })
await settle()
}
const screenshot = async name => {
if (artifacts) {
await writeFile(join(artifacts, name + '.png'), (await win.webContents.capturePage()).toPNG())
}
}
const addVisible = async () => {
assert(await js(`(() => { const e=document.querySelector('.workbar-shell__add'),r=e.getBoundingClientRect(),s=e.closest('.assistant-sidebar').getBoundingClientRect();
return !e.closest('.workbar-shell__tab-scroll') && r.left>=s.left && r.right<=s.right && e.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2)); })()`), 'Add must remain visible and hit-testable outside the scroll viewport')
}
try {
if (artifacts) await mkdir(artifacts, { recursive: true })
for (const locale of ['zh-CN', 'en-US']) {
for (const theme of ['light', 'dark']) {
win.setContentSize(1000, 720)
await win.loadURL(`${process.env.GB_LAYOUT_URL}?locale=${locale}&theme=${theme}`)
await wait('!!document.querySelector(".workbar-shell__scroll-button")')
await js('document.fonts.ready')
await settle()
const scroll = "document.querySelector('.workbar-shell__tab-scroll')"
for (const many of [false, true]) {
if (many) { await click('#many-tabs'); await wait('document.querySelectorAll("[role=tab]").length===16') }
await addVisible()
await click('.workbar-shell__add')
await wait('!!document.querySelector(".workbar-shell__catalog") && document.activeElement.matches("[data-workbar-app-choice]")')
await key('Escape')
await wait('!document.querySelector(".workbar-shell__catalog") && document.activeElement.matches(".workbar-shell__add")')
const active = await js('document.querySelector("[role=tab][aria-selected=true]").id')
await click('.workbar-shell__scroll-button:last-child')
await wait(`${scroll}.scrollLeft > 0`)
assert.equal(await js('document.querySelector("[role=tab][aria-selected=true]").id'), active)
await addVisible()
await js('document.querySelector("[role=tab][aria-selected=true]").focus()')
await key('End')
await wait('document.activeElement === document.querySelector(".workbar-shell__tab-item:last-child [role=tab][aria-selected=true]")')
assert(await js(`(() => {const r=document.activeElement.parentElement.getBoundingClientRect(),s=${scroll}.getBoundingClientRect();return r.left>=s.left-1 && r.right<=s.right+1})()`), 'End must reveal the last tab')
await addVisible()
await screenshot(`${locale}-${theme}-workbar-${many ? 'many' : 'default'}`)
await key('Home')
await wait(`${scroll}.scrollLeft === 0 && document.activeElement === document.querySelector('.workbar-shell__tab-item:first-child [role=tab][aria-selected=true]')`)
if (!many) {
await js("document.querySelector('.assistant-sidebar').style.width='900px';document.querySelector('.assistant-sidebar').style.flexBasis='900px'")
await wait('!document.querySelector(".workbar-shell__scroll-button")')
await addVisible()
await js("document.querySelector('.assistant-sidebar').style.width='300px';document.querySelector('.assistant-sidebar').style.flexBasis='300px'")
await wait('!!document.querySelector(".workbar-shell__scroll-button")')
}
}
await click('.workbar-shell__add')
await wait('!!document.querySelector(".workbar-shell__catalog")')
await click('.workbar-shell__catalog-choice:has(.workbar-shell__app-icon--browser)')
await wait('document.querySelectorAll("[role=tab]").length===17 && !document.querySelector(".workbar-shell__catalog")')
await addVisible()
observations.push({ locale, theme, workbar: 'default and 16 long tabs; native add/catalog/create, arrows, Home/End' })
await win.loadURL(`${process.env.GB_LAYOUT_URL}?surface=settings&locale=${locale}&theme=${theme}`)
await wait('!!document.querySelector(".model-connection-detail select")')
await js('document.fonts.ready')
for (const [width, height, panelWidth] of [[680, 560], [720, 560], [721, 560], [740, 560], [960, 560], [1180, 760], [1180, 560, 740]]) {
win.setContentSize(width, height)
await wait(`innerWidth === ${width} && innerHeight === ${height}`)
await settle()
await js(`document.querySelector('.settings-panel').style.width=${JSON.stringify(panelWidth ? `${panelWidth}px` : '')}`)
await js("document.querySelector('.model-connection-detail select').scrollIntoView({block:'center'})")
const result = await js(`(() => {
const e=document.querySelector('.model-connection-detail select'),s=getComputedStyle(e),r=e.getBoundingClientRect();
const c=document.createElement('canvas').getContext('2d');c.font=s.font;
const manager=document.querySelector('.model-connection-manager'),list=manager.querySelector('.model-connection-list').getBoundingClientRect(),detail=manager.querySelector('.model-connection-detail').getBoundingClientRect();
return {containerWidth:manager.clientWidth,stacked:detail.top>=list.bottom-1,selectWidth:e.clientWidth,
textWidth:c.measureText(e.selectedOptions[0].textContent).width,available:e.clientWidth-parseFloat(s.paddingLeft)-parseFloat(s.paddingRight)-20,
overflow:['.settings-panel__content','.model-connection-manager','.model-connection-detail'].filter(sel=>{const n=document.querySelector(sel);return n.scrollWidth>n.clientWidth+1}),
hit:e.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2)),value:e.value};
})()`)
assert.equal(result.stacked, result.containerWidth <= 640, JSON.stringify(result))
assert.deepEqual(result.overflow, [], JSON.stringify(result))
assert(result.textWidth <= result.available, 'Protocol truncated: ' + JSON.stringify(result))
assert(result.hit, 'Protocol is occluded')
await js("document.querySelector('.model-connection-detail select').closest('.field').previousElementSibling.querySelector('input').focus()")
await key('Tab')
await wait('document.activeElement.matches(".model-connection-detail select")')
await key('Home'); await key('Down')
await wait('document.querySelector(".model-connection-detail select").value!=="openai-chat-completions"')
await key('Home')
await wait('document.querySelector(".model-connection-detail select").value==="openai-chat-completions"')
observations.push({ locale, theme, width, height, panelWidth, ...result })
await screenshot(`${locale}-${theme}-settings-${width}${panelWidth ? '-narrow-container' : ''}`)
}
}
}
assert.deepEqual(errors, [])
const result = { passed: true, observations, errors, modelCalls: 0 }
await writeFile(join(directory, 'result.json'), JSON.stringify(result))
if (artifacts) await writeFile(join(artifacts, 'result.json'), JSON.stringify(result, null, 2))
app.exit(0)
} catch (error) {
const result = JSON.stringify({ passed: false, error: String(error.stack), observations, errors })
await writeFile(join(directory, 'result.json'), result)
if (artifacts) { await screenshot('failure'); await writeFile(join(artifacts, 'result.json'), result) }
app.exit(1)
}
})
+71
View File
@@ -0,0 +1,71 @@
import { useState } from 'react'
import { createRoot } from 'react-dom/client'
import { SettingsPanel } from '../../src/renderer/src/SettingsPanel'
import { DEFAULT_WORKBAR_INSTANCES, WorkbarShell } from '../../src/renderer/src/WorkbarShell'
import { UiLocaleProvider } from '../../src/renderer/src/i18n/UiLocaleProvider'
import { changeUiLocale } from '../../src/renderer/src/i18n'
import { applyAppearanceTheme } from '../../src/renderer/src/theme'
import { installBundledUiFonts } from '../../src/renderer/src/fonts'
import { defaultRuntimeSettings, type DesktopApi } from '../../src/shared/contracts'
import type { WorkbarTabInstance } from '../../src/shared/workbar-contracts'
import '../../src/renderer/src/styles.css'
const query = new URLSearchParams(location.search)
const locale = query.get('locale') === 'en-US' ? 'en-US' : 'zh-CN'
const longName = locale === 'en-US'
? 'Organization shared model connection for document processing and analysis'
: '\u7ec4\u7ec7\u5185\u90e8\u6587\u6863\u5904\u7406\u4e0e\u5206\u6790\u4e13\u7528\u5171\u4eab\u6a21\u578b\u8fde\u63a5'
const profileId = '10000000-0000-4000-8000-000000000001'
Object.defineProperty(window, 'goodbuddy', { value: {
settings: {
getRuntime: async () => ({ ...defaultRuntimeSettings, modelProfiles: [{
id: profileId, name: longName, baseUrl: 'http://fixture.invalid/v1', modelName: 'fixture-model',
protocol: 'openai-chat-completions', authentication: 'none', imageGenerationQuality: 'auto',
apiKeyConfigured: false, credentialSource: 'none'
}], defaultModelProfileId: profileId, embeddingConnections: [],
opencodeModelSource: { kind: 'platform' }, continueModelSource: { kind: 'platform' },
deepseekHarnessModelSource: { kind: 'platform' } }),
detectAgentRuntimes: async () => ({})
},
runtimeCustomization: {
getSettings: async () => ({ opencode: {}, continue: { presets: [] } }),
getNativeSnapshot: async () => ({ provider: 'opencode', available: false, inventoryStatus: 'unavailable',
agents: [], tools: [], commands: [], lsp: [], formatters: [], mcpServers: [], skills: [], rules: [], prompts: [], resources: [],
context: { strategy: 'goodbuddy-summary', manualCompact: false, detail: 'Fixture' } })
}
} as unknown as DesktopApi })
function WorkbarFixture(): React.JSX.Element {
const [instances, setInstances] = useState<readonly WorkbarTabInstance[]>(DEFAULT_WORKBAR_INSTANCES.map((item, index) => ({
...item, title: locale === 'en-US' ? ['Task center', 'Workspace', 'Browser', 'Results'][index]! : item.title
})))
const [active, setActive] = useState<string | null>(instances[0]!.id)
return <>
<button id="many-tabs" onClick={() => setInstances(current => [...current, ...Array.from({ length: 12 }, (_, index) => ({
id: `extra-${index}`, appId: 'browser' as const, title: `${longName} ${index + 1}`
}))])}>More tabs</button>
<aside className="assistant-sidebar assistant-sidebar--open" style={{ width: 300, flexBasis: 300, height: 480 }}>
<WorkbarShell instances={instances} activeInstanceId={active} onActiveInstanceChange={setActive}
onCreateInstance={({ appId }) => {
const id = `created-${instances.length}`
setInstances(current => [...current, { id, appId, title: `${appId} ${instances.length}` }])
setActive(id)
}}
onCloseInstance={instance => setInstances(current => current.filter(item => item.id !== instance.id))}
onResolveTerminalTarget={() => ({ type: 'local' })} renderPanel={instance => <p>{instance.title}</p>} />
</aside>
</>
}
function Fixture(): React.JSX.Element {
return <UiLocaleProvider initialPreference={locale}>
{query.get('surface') === 'settings'
? <SettingsPanel open initialCategory="model" projects={[]} onClose={() => {}} onSaved={() => {}}
onUpdateProject={async () => { throw new Error('Unused fixture API') }} onClearLocalData={async () => {}} />
: <WorkbarFixture />}
</UiLocaleProvider>
}
installBundledUiFonts()
applyAppearanceTheme(query.get('theme') === 'dark' ? 'dark' : 'light')
void changeUiLocale(locale).then(() => createRoot(document.getElementById('root')!).render(<Fixture />))
@@ -0,0 +1,49 @@
// @vitest-environment node
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { copyFile, mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { createServer } from 'vite'
import react from '@vitejs/plugin-react'
import { expect, it } from 'vitest'
it('keeps workbar add reachable and model protocols readable at narrow container widths', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-responsive-controls-'))
const server = await createServer({
configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
plugins: [react(), { name: 'responsive-controls', configureServer(server) {
server.middlewares.use('/responsive.html', async (_request, response) => {
response.setHeader('Content-Type', 'text/html')
response.end(await server.transformIndexHtml('/responsive.html',
'<html><body><div id="root"></div><script type="module" src="/tests/support/workbar-settings-layout.tsx"></script></body></html>'))
})
} }],
optimizeDeps: { entries: ['tests/support/workbar-settings-layout.tsx'] },
server: { host: '127.0.0.1', port: 0, watch: null }
})
try {
await server.listen()
const driver = join(directory, 'driver.mjs')
await copyFile(resolve('tests/support/workbar-settings-layout-driver.mjs'), driver)
const env: NodeJS.ProcessEnv = { ...process.env, GB_LAYOUT_DIRECTORY: directory,
GB_LAYOUT_URL: server.resolvedUrls!.local[0] + 'responsive.html' }
delete env.ELECTRON_RUN_AS_NODE
const child = spawn(createRequire(import.meta.url)('electron'), [driver], { env, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', data => { output += data })
child.stderr.on('data', data => { output += data })
const timeout = setTimeout(() => child.kill(), 90000)
try {
const code = await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) })
const result = await readFile(join(directory, 'result.json'), 'utf8').catch(() => output)
expect(code, result).toBe(0)
const parsed = JSON.parse(result)
expect(parsed.passed).toBe(true)
process.stdout.write(`Responsive controls: ${parsed.observations.length} observations; Chinese/English, light/dark, native mouse and keyboard; model calls: 0\n`)
} finally { clearTimeout(timeout) }
} finally {
await server.close()
await rm(directory, { recursive: true, force: true })
}
}, 120000)