fix: keep menus and notifications usable above modals
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Parsing test menus and application notifications could appear behind modal overlays or become unreachable while the background was inert. Share a stable top-layer portal that belongs to the owning or active modal, preserving notification timers, keyboard navigation and focus restoration. Add unit and Electron regression coverage for settings, nested parsing results, application center, inference monitoring and native dialogs, with matching UI guidance and validation evidence. Validation recorded in the preceding implementation: 4,793 tests passed and 67 skipped; typecheck, lint, isolated production build and Electron UI checks passed. Staged diff whitespace checks passed before this commit. Release note: Menus and notifications remain visible and usable while settings or other dialogs are open, including keyboard navigation and notification dismissal.
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
// @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('keeps menus and notifications interactive above real Electron modals', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-overlay-layering-'))
|
||||
const server = await createServer({ configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
|
||||
plugins: [react(), { name: 'overlay-layering', configureServer(server) {
|
||||
server.middlewares.use('/overlays.html', async (_request, response) => {
|
||||
response.setHeader('Content-Type', 'text/html')
|
||||
response.end(await server.transformIndexHtml('/overlays.html',
|
||||
'<html><body><div id="root"></div><script type="module" src="/tests/support/overlay-layering-regression.tsx"></script></body></html>'))
|
||||
})
|
||||
} }], server: { host: '127.0.0.1', port: 0, watch: null } })
|
||||
try {
|
||||
await server.listen()
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, GOODBUDDY_OVERLAY_URL: server.resolvedUrls!.local[0] + 'overlays.html',
|
||||
GOODBUDDY_OVERLAY_DIRECTORY: directory }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
const driver = join(directory, 'driver.mjs')
|
||||
await copyFile(resolve('tests/support/overlay-layering-driver.mjs'), driver)
|
||||
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) })
|
||||
expect(code, output).toBe(0)
|
||||
} finally { clearTimeout(timeout) }
|
||||
} finally { await server.close(); await rm(directory, { recursive: true, force: true }) }
|
||||
}, 120000)
|
||||
@@ -0,0 +1,124 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import assert from 'node:assert/strict'
|
||||
import { writeFile, mkdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import console from 'node:console'
|
||||
import { setTimeout } from 'node:timers'
|
||||
|
||||
const url = process.env.GOODBUDDY_OVERLAY_URL
|
||||
const directory = process.env.GOODBUDDY_OVERLAY_DIRECTORY
|
||||
app.setPath('userData', join(directory, 'profile'))
|
||||
app.commandLine.appendSwitch('force-device-scale-factor', '1')
|
||||
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 } })
|
||||
const errors = []
|
||||
win.webContents.on('console-message', event => { if (event.level === 'error') errors.push(event.message) })
|
||||
const js = code => win.webContents.executeJavaScript(code)
|
||||
const settle = () => js('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
|
||||
const wait = async code => {
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
if (await js(code)) return
|
||||
await new Promise(resolve => setTimeout(resolve, 25))
|
||||
}
|
||||
throw new Error('Timeout: ' + code + '\n' + errors.join('\n'))
|
||||
}
|
||||
const key = async (keyCode, modifiers = []) => {
|
||||
win.focus(); win.webContents.focus()
|
||||
win.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers })
|
||||
win.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers })
|
||||
await settle()
|
||||
}
|
||||
const click = async selector => {
|
||||
const point = await js(`(() => { const e = document.querySelector(${JSON.stringify(selector)}); e.scrollIntoView({block:'nearest'});
|
||||
const r = e.getBoundingClientRect(); const x = r.x + r.width/2, y = r.y + r.height/2;
|
||||
return { x: Math.round(x), y: Math.round(y), hit: e.contains(document.elementFromPoint(x, y)) }; })()`)
|
||||
assert(point.hit, 'Occluded: ' + selector)
|
||||
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 notify = async (tone = 'error') => {
|
||||
await js(`window.dispatchEvent(new CustomEvent('fixture-notify', { detail: ${JSON.stringify(tone)} }))`)
|
||||
await wait('!!document.querySelector(".app-notification")')
|
||||
await settle()
|
||||
}
|
||||
const artifacts = process.env.GOODBUDDY_OVERLAY_ARTIFACTS
|
||||
if (artifacts) await mkdir(artifacts, { recursive: true })
|
||||
const screenshot = async name => {
|
||||
if (artifacts) await writeFile(join(artifacts, name + '.png'), (await win.webContents.capturePage()).toPNG())
|
||||
}
|
||||
await win.loadURL(url)
|
||||
await wait('!!document.querySelector("#settings")')
|
||||
await js('document.fonts.ready')
|
||||
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await js(`window.dispatchEvent(new CustomEvent('fixture-theme', { detail: '${theme}' }))`)
|
||||
for (const [width, height] of [[1280, 800], [960, 720], [720, 640], [640, 420]]) {
|
||||
win.setContentSize(width, height); await settle()
|
||||
await notify()
|
||||
await click('#settings')
|
||||
await wait('!!document.querySelector("[aria-haspopup=menu][aria-expanded=false]")')
|
||||
assert(await js('document.querySelector(".settings-panel").contains(document.querySelector(".app-notification"))'))
|
||||
assert(await js('document.querySelector(".app-shell").inert'))
|
||||
const before = await js('document.activeElement.className')
|
||||
await notify('success')
|
||||
assert.equal(await js('document.activeElement.className'), before, 'Notification stole focus')
|
||||
await screenshot(`${theme}-${width}-notification`)
|
||||
await click('.app-notification--error button')
|
||||
await click('.app-notification--success button')
|
||||
assert(await js('!!document.activeElement.closest(".settings-panel")'), 'Dismissal lost modal focus')
|
||||
await click('[aria-haspopup=menu][aria-expanded=false]')
|
||||
await wait('!!document.querySelector("#document-test-menu")')
|
||||
assert(await js('document.querySelector(".settings-panel").contains(document.querySelector("#document-test-menu"))'))
|
||||
assert(await js('document.activeElement.matches("[role=menuitem]")'))
|
||||
await screenshot(`${theme}-${width}-menu`)
|
||||
await key('Escape')
|
||||
assert(await js('!document.querySelector("#document-test-menu") && !!document.querySelector(".settings-panel")'))
|
||||
assert(await js('document.activeElement.matches("[aria-haspopup=menu]")'))
|
||||
await key('Space')
|
||||
await wait('!!document.querySelector("#document-test-menu")')
|
||||
await click('#document-test-menu [role=menuitem]')
|
||||
await wait('!!document.querySelector(".document-parsing-diagnostic")')
|
||||
await notify()
|
||||
assert(await js('document.querySelector(".document-parsing-diagnostic").contains(document.querySelector(".app-notification"))'))
|
||||
assert(await js('document.querySelector(".settings-backdrop").inert'))
|
||||
await js('document.querySelector(".app-notification button").focus()')
|
||||
await key('Tab')
|
||||
assert(await js('document.activeElement.matches(".document-parsing-diagnostic header button")'))
|
||||
await key('Tab', ['shift'])
|
||||
assert(await js('document.activeElement.matches(".app-notification button")'))
|
||||
await key('Escape')
|
||||
assert(await js('!!document.querySelector(".document-parsing-diagnostic") && !document.activeElement.closest(".app-notification")'))
|
||||
await key('Escape')
|
||||
await wait('!document.querySelector(".document-parsing-diagnostic")')
|
||||
assert(await js('document.querySelector(".settings-panel").contains(document.querySelector(".app-notification"))'))
|
||||
await click('.app-notification button')
|
||||
await key('Escape')
|
||||
await wait('!document.querySelector(".settings-panel")')
|
||||
assert(await js('!document.querySelector(".app-shell").inert && document.activeElement.id === "settings"'))
|
||||
}
|
||||
}
|
||||
for (const id of ['applications', 'inference', 'native']) {
|
||||
await notify()
|
||||
await click('#' + id)
|
||||
await wait('!!document.querySelector("[aria-modal=true], dialog[open]")')
|
||||
await settle()
|
||||
assert(await js('!!document.querySelector(".app-notification").closest("[aria-modal=true], dialog[open]")'))
|
||||
await click('.app-notification button')
|
||||
if (id === 'native') await click('dialog[open] button')
|
||||
else await key('Escape')
|
||||
await wait('!document.querySelector("[aria-modal=true], dialog[open]")')
|
||||
}
|
||||
// A notification can outlive a modal; moving its host must not restart its timer.
|
||||
await notify('success')
|
||||
await click('#applications')
|
||||
await js('window.fixtureToast = document.querySelector(".app-notification")')
|
||||
await key('Escape')
|
||||
assert(await js('window.fixtureToast === document.querySelector(".app-notification") && document.querySelector(".floating-portal").parentElement === document.body'))
|
||||
await wait('!document.querySelector(".app-notification")')
|
||||
assert.deepEqual(errors, [])
|
||||
console.log('PASS: 8 theme/size scenarios; parsing menu and nested result; notification hit testing, native Tab/Shift+Tab/Escape; application center, inference, native dialog; persistent host and timeout')
|
||||
app.exit(0)
|
||||
}).catch(error => { console.error(error); app.exit(1) })
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { AppNotificationViewport } from '../../src/renderer/src/App'
|
||||
import { SettingsPanel } from '../../src/renderer/src/SettingsPanel'
|
||||
import { ApplicationCenter } from '../../src/renderer/src/ApplicationCenter'
|
||||
import LocalInferencePage from '../../src/renderer/src/LocalInferencePage'
|
||||
import { defaultRuntimeSettings } from '../../src/shared/contracts'
|
||||
import { UiLocaleProvider } from '../../src/renderer/src/i18n/UiLocaleProvider'
|
||||
import { applyAppearanceTheme } from '../../src/renderer/src/theme'
|
||||
import '../../src/renderer/src/styles.css'
|
||||
|
||||
const snapshot = {
|
||||
settings: { chatWorkflow: 'auto', knowledgeWorkflow: 'complete-index', localOcrModelId: 'pp-ocrv6-tiny', maximumPages: 100, pageTimeoutSeconds: 60 },
|
||||
status: { nativeParsingAvailable: true, conversionAvailable: false,
|
||||
localOcr: { id: 'pp-ocrv6-tiny', displayName: 'PP-OCRv6 Tiny', available: false, verified: false, runtime: 'onnxruntime-web-wasm', detail: 'Fixture: not installed' } },
|
||||
ocrModels: { rootDirectory: '', selectedDownloadSource: 'modelscope', catalog: [], installed: [], operations: [] }
|
||||
}
|
||||
Object.defineProperty(window, 'goodbuddy', { value: {
|
||||
settings: { getRuntime: async () => ({ ...defaultRuntimeSettings, modelProfiles: [], embeddingConnections: [],
|
||||
opencodeModelSource: { kind: 'platform' }, continueModelSource: { kind: 'platform' }, deepseekHarnessModelSource: { kind: 'platform' } }),
|
||||
detectAgentRuntimes: async () => ({}) },
|
||||
documentParsing: { getSnapshot: async () => snapshot, releaseResult: async () => undefined,
|
||||
test: async () => ({ fileName: 'fixture.txt', sourceFormat: 'TXT', method: 'native', pageCount: 1,
|
||||
ocrPageCount: 0, characterCount: 15, durationMs: 1, preview: 'Fixture result', warnings: [] }) },
|
||||
localInference: { getSnapshot: async () => ({ services: [], tasks: [] }) }
|
||||
} })
|
||||
|
||||
function Fixture(): React.JSX.Element {
|
||||
const [modal, setModal] = useState('')
|
||||
const [notifications, setNotifications] = useState<Array<{ id: string; message: string; tone: 'error' | 'success'; revision: number }>>([])
|
||||
const native = useRef<HTMLDialogElement>(null)
|
||||
useEffect(() => {
|
||||
const notify = (event: Event): void => {
|
||||
const tone = (event as CustomEvent).detail === 'success' ? 'success' : 'error'
|
||||
setNotifications(items => [...items, { id: crypto.randomUUID(), message: 'Overlay regression notification', tone, revision: 1 }])
|
||||
}
|
||||
const theme = (event: Event): void => applyAppearanceTheme((event as CustomEvent).detail)
|
||||
window.addEventListener('fixture-notify', notify)
|
||||
window.addEventListener('fixture-theme', theme)
|
||||
return () => { window.removeEventListener('fixture-notify', notify); window.removeEventListener('fixture-theme', theme) }
|
||||
}, [])
|
||||
return <div className="app-shell">
|
||||
<div><button id="settings" onClick={() => setModal('settings')}>Settings</button>
|
||||
<button id="applications" onClick={() => setModal('applications')}>Applications</button>
|
||||
<button id="inference" onClick={() => setModal('inference')}>Inference</button>
|
||||
<button id="native" onClick={() => native.current!.showModal()}>Native dialog</button></div>
|
||||
<AppNotificationViewport notifications={notifications} dispatch={action => {
|
||||
if ('dismiss' in action) setNotifications(items => items.filter(item => item.id !== action.dismiss))
|
||||
}} />
|
||||
{modal === 'settings' && <SettingsPanel open initialCategory="document-parsing" onClose={() => setModal('')}
|
||||
onSaved={() => {}} onUpdateProject={async () => { throw new Error('Unused fixture API') }} projects={[]} onClearLocalData={async () => {}} />}
|
||||
{modal === 'applications' && <ApplicationCenter pending={false} onClose={() => setModal('')}
|
||||
onOpen={() => {}} onUpdate={async () => true} onRetry={() => {}} />}
|
||||
{modal === 'inference' && <LocalInferencePage onClose={() => setModal('')} />}
|
||||
<dialog ref={native}><button onClick={() => native.current!.close()}>Close native</button></dialog>
|
||||
</div>
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<UiLocaleProvider initialPreference="zh-CN"><Fixture /></UiLocaleProvider>)
|
||||
Reference in New Issue
Block a user