From ec08109a09bfda6435d93030b5f2f60ec8538dc4 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sat, 19 Sep 2026 18:11:39 +0800 Subject: [PATCH] fix: keep menus and notifications usable above modals 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. --- UI-DESIGN.md | 6 + docs/features/document-processing/progress.md | 29 +++- .../features/document-processing/ui-design.md | 2 +- docs/quality/recurring-defects.md | 19 +++ src/renderer/src/AnchoredMenu.tsx | 7 +- src/renderer/src/App.tsx | 7 +- src/renderer/src/FloatingPortal.test.tsx | 62 +++++++++ src/renderer/src/FloatingPortal.tsx | 77 +++++++++++ src/renderer/src/anchored-menu.css | 1 - src/renderer/src/floating-portal.css | 23 ++++ src/renderer/src/styles.css | 1 - src/renderer/src/test-setup.ts | 5 + tests/overlay-layering.electron.test.ts | 38 ++++++ tests/support/overlay-layering-driver.mjs | 124 ++++++++++++++++++ tests/support/overlay-layering-regression.tsx | 59 +++++++++ 15 files changed, 450 insertions(+), 10 deletions(-) create mode 100644 src/renderer/src/FloatingPortal.test.tsx create mode 100644 src/renderer/src/FloatingPortal.tsx create mode 100644 src/renderer/src/floating-portal.css create mode 100644 tests/overlay-layering.electron.test.ts create mode 100644 tests/support/overlay-layering-driver.mjs create mode 100644 tests/support/overlay-layering-regression.tsx diff --git a/UI-DESIGN.md b/UI-DESIGN.md index fd6729ce..d4ef9e5a 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -400,6 +400,9 @@ 顶栏,不能保留一条未被遮罩的窗口控制色块。关闭操作由 Modal 自身提供。 - 整窗固定遮罩通过 `createPortal(..., document.body)` 挂载,避免页面层叠上下文影响; 保留 React Context、关闭与焦点管理,表单样式不得依赖已移出的页面祖先选择器。 +- 共享锚定菜单 `AnchoredMenu` 和应用通知共用 `FloatingPortal` 的非模态 `popover="manual"`,由浏览器 top layer + 绘制,避免被 Modal 或滚动容器遮挡。菜单挂在触发按钮所属 Modal 内,通知跟随当前活动 + Modal,无 Modal 时挂在 `body`;不能只提高 `z-index` 或解除背景的 `inert`。 - 原生浏览器 `WebContentsView` 不受网页 `z-index` 控制。应用级 Modal 打开时统一释放 浏览器 viewport;非模态对话框、菜单、局部确认或应用通知与浏览器区域相交时也释放。 浮层关闭后只恢复当前活动实例的 viewport,不关闭、重建或刷新网页。接线与测试见 @@ -447,6 +450,9 @@ - 异步操作成功、无需立即处理的信息,以及不属于某个字段的异步失败,使用应用级 `success`、`info` 或 `error` 通知。 - 成功和信息通知默认在约 4.5 秒后自动消失;错误通知保持可见,直到用户关闭或同一去重键的更新替换它。 +- 通知出现不抢焦点;Modal 打开时,通知关闭按钮纳入当前 Modal 的 Tab 循环。聚焦通知时 + Escape 返回原控件或当前 Modal 的可用控件,不关闭 Modal;关闭通知后同样恢复焦点。 + 打开、关闭或切换 Modal 不重建通知,不重置自动消失计时。 - 同一语义和文案的重复通知应去重。通知正文必须有长度上限,不包含凭据、私人内容或未脱敏的提供商响应。 - 字段校验、破坏性确认、操作进度、阻塞整个页面的状态,以及需要就地重试或修正的错误保留在相关控件附近。 - 就地错误必须与对应字段或操作建立程序化关联;全局错误使用 `alert` 和 assertive 实时区域,成功与信息使用 `status` 和 polite 实时区域。 diff --git a/docs/features/document-processing/progress.md b/docs/features/document-processing/progress.md index 1bae2130..963d3614 100644 --- a/docs/features/document-processing/progress.md +++ b/docs/features/document-processing/progress.md @@ -2,7 +2,7 @@ ## 状态 -当前状态以 [2026-09-19 实现与验证](#2026-09-19-实现与验证)及[附件操作布局复验](#2026-09-19-附件操作布局复验)为准。 +当前状态以 [2026-09-19 实现与验证](#2026-09-19-实现与验证)、[附件操作布局复验](#2026-09-19-附件操作布局复验)及[全局浮层复验](#2026-09-19-全局浮层复验)为准。 以下至“菜单核对检查”的内容是实施前历史记录,保留原日期与原调用数。 2026-09-18:完成规划文档和独立 HTTP 探索脚本。FR-H1 至 FR-H12 均未完成生产实现, @@ -303,3 +303,30 @@ Worker 整体回收),是具体实现对比,保留原文。本次新增内 `git diff --check` 通过。deai-writing 扫描文档处理目录仍为既有 2 个阻断候选,按前述具体 实现对比理由保留;新增内容无阻断项,复核项保留必要的能力边界和菜单状态限制。关联文档 新增段落经人工审校;本轮没有产品代码变更,未运行产品测试或实机菜单验收。 + +## 2026-09-19 全局浮层复验 + +修复前源码在 Electron 中复现:“测试解析”的 `AnchoredMenu` 层级为 129,低于设置 +遮罩的 130;通知层级为 75,且仍在被设为 `inert` 的应用壳层内。两者的鼠标命中均失败。 +共享修复及后续约束见[复发缺陷规则](../../quality/recurring-defects.md#modal-内的菜单与通知被遮挡或隔离)。 + +- `FloatingPortal.test.tsx` 覆盖 Modal 切换时保持同一通知宿主和内容、焦点循环及菜单归属。 +- `overlay-layering.electron.test.ts` 使用生产组件与受控 IPC,验证浅/深主题下的 + 1280×800、960×720、720×640、640×420;包含通知在 Modal 前/后出现、点击关闭、 + Tab/Shift+Tab/Escape、解析场景菜单与二级结果、应用中心、本机推理监控和原生 `dialog`。 +- 完整 App 使用当前 Main、Preload、Renderer 的独立构建及空白 profile,从侧栏设置入口 + 修改最大页数为 99,确认生产读取值及成功通知;原生鼠标点击“测试解析”菜单后,生产 IPC + 解析本地 TXT,结果展示 70 字符,Escape 逐层关闭并恢复焦点。仅文件选择器返回合成文件; + 未替换解析、保存或通知。模型及 OCR 服务调用均为 0。 +- 已通过图片读取工具逐张审阅 fixture 的 16 张 `light/dark-宽度-menu/notification.png`, + 以及完整 App 的保存通知、测试菜单和解析结果三张 PNG;文案、主题、菜单与通知层级正常。 + +临时证据位于批准目录下的 `overlay-baseline-0919`、`overlay-layering-0919` 和 +`overlay-full-app-0919-r2`。完整 App 首轮因驱动选择器错误失败,修正后通过;仍记录到 +4 条既有 data-font CSP 警告。本轮实际 UI 环境为 Windows x64、Electron 43.2.0, +未验证 macOS、Linux 或辅助技术的实际语音播报。 +本轮临时 profile、基线缓存和独立构建已清理,保留脚本、脱敏报告、源码/产物哈希及截图。 + +最终 `npm test`:403 个文件通过、9 个跳过,4,793 项通过、67 项跳过,耗时 909.69 秒。 +`npm run typecheck`、`npm run lint` 及隔离目录的 Electron Vite 生产构建通过; +相关文档的 48 个相对文件链接有效,`git diff --check` 通过。 diff --git a/docs/features/document-processing/ui-design.md b/docs/features/document-processing/ui-design.md index 72e3bbe2..73ea2de8 100644 --- a/docs/features/document-processing/ui-design.md +++ b/docs/features/document-processing/ui-design.md @@ -16,7 +16,7 @@ | 知识库 | `App.tsx` 已提供 `PageShell variant="master-detail"`,`KnowledgeWorkspace.tsx` 内含库导航与 DocumentsView;需在文档区域新增解析详情及返回列表,不再嵌套页面壳层或新建知识库级页签 | | 解析预览 | 新增共享内容视图,分别嵌入设置诊断 Modal、聊天附件 Modal 和知识库文档详情;共享数据和交互,外层容器负责标题、范围、关闭与滚动 | | 页签与选择 | `WorkspacePrimitives.tsx` 的 `PageTabs variant="segmented"` 承载正文、图片、详情;来源及过滤模式使用 `SegmentedControl`,不能用它替换页签语义 | -| 浮层 | 沿用 `createPortal(..., document.body)`、`activateModalFocus`、`trapTabFocus`;当前没有可直接调用的通用 Modal 组件,不虚构组件 API | +| 浮层 | Modal 沿用 body Portal、`activateModalFocus` 与 `trapTabFocus`;测试及附件菜单复用 `AnchoredMenu`,由共享 `FloatingPortal` 挂入所属 Modal 并在 top layer 绘制。通知遵循根 UI 规范 | | 图文内容 | 复用 `MarkdownRenderer.tsx` 的排版、表格滚动和静态 HTML 处理边界;补齐受管图片与 OCR HTML 表格适配,现有 Markdown 渲染器并未直接支持这些资源 | | 状态与反馈 | `ScopeBadge`、`EmptyState`、共享按钮、`toggle-row` Switch、应用 `onNotify`;图片多选用 Checkbox,失败重试就地显示 | diff --git a/docs/quality/recurring-defects.md b/docs/quality/recurring-defects.md index 1ebc3254..58861cc0 100644 --- a/docs/quality/recurring-defects.md +++ b/docs/quality/recurring-defects.md @@ -44,6 +44,25 @@ **历史**:先前浏览器多实例修正只覆盖了终端关闭确认;2026-09-13 用户在知识库实例 Modal 再次复现,同一检查确认其他应用级 Modal 均缺少处理。 +## Modal 内的菜单与通知被遮挡或隔离 + +**症状**:设置中的“测试解析”菜单落在遮罩后面;设置、应用中心等全局 Modal 打开时, +应用通知被遮挡,关闭按钮也无法点击或聚焦。 + +**原因**:菜单挂在 `body`,却使用低于 Modal 的层级;通知留在被 Modal 设为 `inert` +的应用壳层内,同时层级也低于 Modal。单独增大 `z-index` 无法解除模态隔离。 + +**规则**:菜单与通知共用 `FloatingPortal`。稳定的非模态 Popover 宿主挂入所属/当前 +Modal,兼顾 top layer 绘制、辅助技术的模态归属及焦点循环;没有 Modal 时才挂在 +`body`。保留背景隔离,不在页面各自加层级常量,不因移动通知重置其计时。 + +**防复发**:`FloatingPortal.test.tsx` 覆盖宿主移动和内容不重挂载; +`overlay-layering.electron.test.ts` 使用真实组件、Portal、鼠标命中及原生键盘,覆盖 +解析菜单/二级结果、设置、应用中心、本机推理监控和原生 `dialog`,以及浅深主题和短窗口。 + +**历史**:2026-09-19 同次检查确认解析测试菜单和应用级通知两处挂载机制错误,后者影响 +多个全局 Modal;统一宿主后补完整 App 的真实保存通知与 TXT 解析复验。 + ## Flex 滚动内容压缩页签和分段控件 **症状**:知识库来源选择被长参数表单压成窄缝,标签和按钮文字被裁掉。 diff --git a/src/renderer/src/AnchoredMenu.tsx b/src/renderer/src/AnchoredMenu.tsx index daea802e..7cbd2fa7 100644 --- a/src/renderer/src/AnchoredMenu.tsx +++ b/src/renderer/src/AnchoredMenu.tsx @@ -1,5 +1,6 @@ import { useEffect, useEffectEvent, useLayoutEffect, useRef, type ReactNode, type RefObject } from 'react' -import { createPortal, flushSync } from 'react-dom' +import { flushSync } from 'react-dom' +import { FloatingPortal } from './FloatingPortal' import './anchored-menu.css' export function AnchoredMenu({ anchorRef, id, label, onClose, children }: { @@ -87,6 +88,6 @@ export function AnchoredMenu({ anchorRef, id, label, onClose, children }: { if (document.activeElement === document.body || menu.contains(document.activeElement)) anchor?.focus() } }, [anchorRef]) - return createPortal(, document.body) + return } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index efb82769..377b4949 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -252,6 +252,7 @@ import { type KeepAliveCacheEntry, } from "./keep-alive-cache"; import { activateModalFocus, trapTabFocus } from "./dialog-focus"; +import { FloatingPortal } from "./FloatingPortal"; import { displayErrorMessage, displayNetworkAwareErrorMessage, @@ -486,7 +487,7 @@ function AppNotificationItem({ ); } -function AppNotificationViewport({ +export function AppNotificationViewport({ notifications, dispatch, }: { @@ -499,7 +500,7 @@ function AppNotificationViewport({ return null; } return ( -
@@ -510,7 +511,7 @@ function AppNotificationViewport({ notification={notification} /> ))} -
+
); } diff --git a/src/renderer/src/FloatingPortal.test.tsx b/src/renderer/src/FloatingPortal.test.tsx new file mode 100644 index 00000000..30e4e950 --- /dev/null +++ b/src/renderer/src/FloatingPortal.test.tsx @@ -0,0 +1,62 @@ +import { useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, it } from 'vitest' +import { FloatingPortal } from './FloatingPortal' +import { activateModalFocus, trapTabFocus } from './dialog-focus' + +afterEach(cleanup) + +function Modal({ label, onClose }: { label: string; onClose: () => void }): React.JSX.Element { + const close = useRef(null) + useEffect(() => activateModalFocus(() => close.current), []) + return createPortal(
trapTabFocus(event, event.currentTarget)}> + +
, document.body) +} + +it('moves one live notification host into successive modals without remounting its content', async () => { + let mounts = 0 + function Notice(): React.JSX.Element { + useEffect(() => { mounts++ }, []) + return
Saved
+ } + function Fixture(): React.JSX.Element { + const [modal, setModal] = useState(false) + return
+ + {modal && setModal(false)} />} +
+ } + render() + const host = screen.getByRole('status').parentElement! + expect(host.parentElement).toBe(document.body) + fireEvent.click(screen.getByText('Open')) + await waitFor(() => expect(host.parentElement).toBe(screen.getByRole('dialog'))) + expect(host).not.toHaveAttribute('inert') + expect(document.querySelector('.app-shell')!.inert).toBe(true) + expect(mounts).toBe(1) + screen.getByText('Dismiss').focus() + fireEvent.keyDown(screen.getByText('Dismiss'), { key: 'Tab' }) + expect(screen.getByText('Close Settings')).toHaveFocus() + fireEvent.click(screen.getByText('Close Settings')) + await waitFor(() => expect(host.parentElement).toBe(document.body)) + expect(mounts).toBe(1) + expect(document.querySelector('.app-shell')!.inert).toBe(false) +}) + +it('keeps an anchored floating menu inside its owning modal', async () => { + function Fixture(): React.JSX.Element { + const anchor = useRef(null) + const [open, setOpen] = useState(false) + return
+ + {open &&
Scenarios
} +
+ } + render() + fireEvent.click(screen.getByText('Test')) + await waitFor(() => expect(screen.getByRole('dialog')).toContainElement(screen.getByRole('menu'))) + expect(screen.getByRole('menu').parentElement).toHaveProperty('popover', 'manual') +}) diff --git a/src/renderer/src/FloatingPortal.tsx b/src/renderer/src/FloatingPortal.tsx new file mode 100644 index 00000000..ac7d5a32 --- /dev/null +++ b/src/renderer/src/FloatingPortal.tsx @@ -0,0 +1,77 @@ +import { useLayoutEffect, useState, type ReactNode, type RefObject } from 'react' +import { createPortal } from 'react-dom' +import { trapTabFocus } from './dialog-focus' +import './floating-portal.css' + +// Top-layer painting does not escape modal inertness: the host must also belong +// to the active dialog. Keep the host stable so moving it preserves toast timers. +export function FloatingPortal({ children, anchorRef }: { + children: ReactNode + anchorRef?: RefObject +}): React.JSX.Element { + const [host] = useState(() => { + const element = document.createElement('div') + element.className = 'floating-portal' + element.popover = 'manual' + return element + }) + useLayoutEffect(() => { + let focused: HTMLElement | null = null + let returnFocus: HTMLElement | null = null + const sync = (): void => { + const modals = Array.from(document.querySelectorAll('[aria-modal="true"], dialog:modal')) + const owner = anchorRef?.current?.closest('[aria-modal="true"], dialog:modal') ?? + (anchorRef ? null : modals.reverse().find(element => + !element.closest('[inert], [hidden], [aria-hidden="true"]') && getComputedStyle(element).display !== 'none')) + const target = owner ?? document.body + const active = host.contains(document.activeElement) ? document.activeElement as HTMLElement : null + if (host.parentElement !== target) { + target.append(host) + host.removeAttribute('inert') + } + if (!host.matches(':popover-open')) host.showPopover() + if (active) active.focus({ preventScroll: true }) + if (focused && !focused.isConnected && document.activeElement === document.body) { + restoreFocus() + } + } + const restoreFocus = (): void => { + const modal = host.closest('[aria-modal="true"], dialog:modal') + const target = returnFocus?.isConnected && !returnFocus.closest('[inert], [hidden], :disabled') && + (!modal || modal.contains(returnFocus)) ? returnFocus : modal?.querySelector('button:not(:disabled), [tabindex="0"]') + target?.focus({ preventScroll: true }) + focused = null + } + const onFocus = (event: FocusEvent): void => { + focused = event.target instanceof HTMLElement ? event.target : null + if (event.relatedTarget instanceof HTMLElement && !host.contains(event.relatedTarget)) { + returnFocus = event.relatedTarget + } + } + const onKeyDown = (event: KeyboardEvent): void => { + if (event.defaultPrevented || anchorRef) return + const modal = host.closest('[aria-modal="true"], dialog:modal') + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + restoreFocus() + } else { + trapTabFocus(event, modal) + } + } + sync() + const observer = new MutationObserver(sync) + observer.observe(document.body, { childList: true, subtree: true, attributes: true, + attributeFilter: ['inert', 'hidden', 'aria-hidden', 'aria-modal', 'open'] }) + host.addEventListener('focusin', onFocus) + host.addEventListener('keydown', onKeyDown) + return () => { + observer.disconnect() + host.removeEventListener('focusin', onFocus) + host.removeEventListener('keydown', onKeyDown) + if (host.contains(document.activeElement)) restoreFocus() + host.remove() + } + }, [anchorRef, host]) + return createPortal(children, host) +} diff --git a/src/renderer/src/anchored-menu.css b/src/renderer/src/anchored-menu.css index 60fe9b90..5b9e7b6d 100644 --- a/src/renderer/src/anchored-menu.css +++ b/src/renderer/src/anchored-menu.css @@ -1,6 +1,5 @@ .anchored-menu { position: fixed; - z-index: calc(var(--z-dialog) - 1); box-sizing: border-box; overflow-y: auto; padding: var(--space-2); diff --git a/src/renderer/src/floating-portal.css b/src/renderer/src/floating-portal.css new file mode 100644 index 00000000..393c9d7b --- /dev/null +++ b/src/renderer/src/floating-portal.css @@ -0,0 +1,23 @@ +.floating-portal { + position: fixed; + inset: 0; + width: auto; + height: auto; + margin: 0; + padding: 0; + border: 0; + overflow: visible; + background: transparent; + color: inherit; + pointer-events: none; +} + +.floating-portal::backdrop { + background: transparent; + pointer-events: none; +} + +.floating-portal > * { + pointer-events: auto; + -webkit-app-region: no-drag; +} diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 382829e1..7411f3c4 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -12398,7 +12398,6 @@ details.settings-section > :not(summary) + :not(summary) { .app-notification-viewport { position: fixed; - z-index: 75; top: 54px; right: var(--space-4); display: grid; diff --git a/src/renderer/src/test-setup.ts b/src/renderer/src/test-setup.ts index b62a29d3..54ee5644 100644 --- a/src/renderer/src/test-setup.ts +++ b/src/renderer/src/test-setup.ts @@ -7,6 +7,11 @@ if (typeof Element !== 'undefined') { Element.prototype.scrollTo = vi.fn() } +// jsdom does not implement top-layer rendering; Electron tests cover it. +if (typeof HTMLElement !== 'undefined') { + HTMLElement.prototype.showPopover = vi.fn() +} + if (typeof ResizeObserver === 'undefined') { globalThis.ResizeObserver = class { observe = vi.fn() diff --git a/tests/overlay-layering.electron.test.ts b/tests/overlay-layering.electron.test.ts new file mode 100644 index 00000000..dd82e8f0 --- /dev/null +++ b/tests/overlay-layering.electron.test.ts @@ -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', + '
')) + }) + } }], 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) diff --git a/tests/support/overlay-layering-driver.mjs b/tests/support/overlay-layering-driver.mjs new file mode 100644 index 00000000..35ef77ee --- /dev/null +++ b/tests/support/overlay-layering-driver.mjs @@ -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) }) diff --git a/tests/support/overlay-layering-regression.tsx b/tests/support/overlay-layering-regression.tsx new file mode 100644 index 00000000..2aefbfd7 --- /dev/null +++ b/tests/support/overlay-layering-regression.tsx @@ -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>([]) + const native = useRef(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
+
+ + +
+ { + if ('dismiss' in action) setNotifications(items => items.filter(item => item.id !== action.dismiss)) + }} /> + {modal === 'settings' && setModal('')} + onSaved={() => {}} onUpdateProject={async () => { throw new Error('Unused fixture API') }} projects={[]} onClearLocalData={async () => {}} />} + {modal === 'applications' && setModal('')} + onOpen={() => {}} onUpdate={async () => true} onRetry={() => {}} />} + {modal === 'inference' && setModal('')} />} + +
+} + +createRoot(document.getElementById('root')!).render()