experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

test(transient-on-blur): coverage for IZUI focus-driven autoclose of transient windows

Asserts the production focus-handler invariants from commit 091e2a43:
- palette/quick-view auto-close when another non-utility window gains focus
- utility (chain popup) focusing does NOT close its transient parent
- overlay role is exempt from being auto-closed (IZUI coordinator owns it)

Test mechanics (the part the task description flagged as hard):
- HEADLESS Electron creates windows with show:false, so isVisible() is false
and the autoclose loop's `if (!other.isVisible()) continue;` short-circuits.
Solved with a new `__peek_test.showWindow(id)` main-process helper that
forces show().
- macOS panel-type windows don't fire reliable native focus events under
Playwright. Solved with `__peek_test.fireWindowFocus(id)` that
synchronously dispatches the BrowserWindow 'focus' event listener chain,
driving the production handler logic deterministically.
- A non-transient anchor window is opened in beforeAll so maybeHideApp()
doesn't collapse the app when a test's only visible windows are transients.

Fixture change: `evaluateMain<R, A>(fn, arg?)` now passes through the
second argument to `electronApp.evaluate`, matching Playwright's API.

+234 -2
+26
backend/electron/entry.ts
··· 140 140 forceSaveSession: () => saveSessionSnapshot('manual', { _forceForTest: true }), 141 141 /** Force-restore session snapshot (bypasses test-profile guard). */ 142 142 forceRestoreSession: () => restoreSessionSnapshot({ restoreSession: true }, undefined, true), 143 + /** 144 + * Force a BrowserWindow to be visible. In headless mode, the window-open 145 + * path passes `show: false` (because there is no display); this helper 146 + * lets tests exercise visibility-dependent code paths (e.g. the 147 + * focus-driven transient autoclose, which gates on `other.isVisible()`). 148 + */ 149 + showWindow: (id: number): boolean => { 150 + const win = BrowserWindow.fromId(id); 151 + if (!win || win.isDestroyed()) return false; 152 + win.show(); 153 + return true; 154 + }, 155 + /** 156 + * Synchronously dispatch the BrowserWindow 'focus' event listener chain 157 + * for the given window id. Use to drive focus-handler logic deterministically 158 + * in tests where headless Electron and Playwright's focus model don't 159 + * produce reliable native focus events (notably for type:'panel' NSPanels 160 + * on macOS — same root cause that the in-process autoclose path was 161 + * written to work around). 162 + */ 163 + fireWindowFocus: (id: number): boolean => { 164 + const win = BrowserWindow.fromId(id); 165 + if (!win || win.isDestroyed()) return false; 166 + win.emit('focus'); 167 + return true; 168 + }, 143 169 /** Returns the page-host URL-param bounds (the canonical webview screenBounds). */ 144 170 getHostUrlParamsByUrl: (urlSubstr: string) => { 145 171 let last: { x: number; y: number; width: number; height: number } | null = null;
+203
tests/desktop/transient-on-blur.spec.ts
··· 1 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 2 + import { Page } from '@playwright/test'; 3 + import { createPerDescribeApp } from '../helpers/test-app'; 4 + 5 + // IZUI policy: a window with role in TRANSIENT_ROLES (palette, quick-view, 6 + // overlay) must auto-hide when another non-utility window gains focus. 7 + // Driven from main-process `win.on('focus')` in backend/electron/ipc.ts — 8 + // macOS NSPanels don't fire reliable per-window blur on intra-app focus 9 + // shifts, so the focuser closes its transient siblings explicitly. 10 + // 11 + // Exceptions: 12 + // - 'utility' role focusing (chain popup) MUST NOT close its transient parent 13 + // - 'overlay' role being-focused-on is skipped (managed by IZUI coordinator) 14 + // 15 + // Test mechanics: HEADLESS Electron creates windows with `show: false`, and 16 + // macOS panel-type windows don't get reliable native focus events under 17 + // Playwright. Both signals are simulated through `__peek_test.showWindow` 18 + // and `__peek_test.fireWindowFocus`, which manipulate the BrowserWindow 19 + // state machine directly. This bypasses the unreliable native path while 20 + // still exercising the production focus-handler code (the for-loop that 21 + // iterates visible transient siblings and calls closeOrHideWindow). 22 + 23 + test.describe('Transient on blur auto-hide @desktop', () => { 24 + let app: DesktopApp; 25 + let bgWindow: Page; 26 + 27 + // Anchor: a non-transient visible window that prevents `maybeHideApp` from 28 + // hiding the entire app when a transient gets auto-closed and the visible 29 + // window count drops to zero. (`getVisibleWindowCount` excludes 30 + // TRANSIENT_ROLES + utility, so without an anchor every test would trigger 31 + // `app.hide()` on the first autoclose, marking the focuser non-visible.) 32 + let anchorId: number | null = null; 33 + 34 + test.beforeAll(async () => { 35 + ({ app, bgWindow } = await createPerDescribeApp('transient-on-blur')); 36 + 37 + const anchorResult = await bgWindow.evaluate(async () => { 38 + return await (window as any).app.window.open('about:blank', { 39 + role: 'workspace', 40 + width: 800, 41 + height: 600, 42 + key: 'transient-on-blur-anchor', 43 + }); 44 + }); 45 + if (!anchorResult.success) { 46 + throw new Error(`anchor window failed: ${JSON.stringify(anchorResult)}`); 47 + } 48 + anchorId = anchorResult.id as number; 49 + if (!app.evaluateMain) throw new Error('evaluateMain not available'); 50 + await app.evaluateMain<boolean, number>( 51 + (_ctx, wid) => (globalThis as any).__peek_test.showWindow(wid), 52 + anchorId, 53 + ); 54 + }); 55 + 56 + test.afterAll(async () => { 57 + if (anchorId != null && bgWindow) { 58 + try { 59 + await bgWindow.evaluate(async (wid: number) => { 60 + await (window as any).app.window.close(wid); 61 + }, anchorId); 62 + } catch { /* ignore */ } 63 + } 64 + if (app) await app.close(); 65 + }); 66 + 67 + /** 68 + * Open a panel-style window with the given role and force it visible 69 + * (HEADLESS Electron skips show()). Returns the window id. 70 + * 71 + * about:blank is used because modal/panel windows in this codebase await 72 + * loadURL — a non-resolving https:// URL would surface ERR_NAME_NOT_RESOLVED 73 + * as a window-open failure before the test could exercise the autoclose. 74 + */ 75 + async function openVisibleWindow(role: string, key: string): Promise<number> { 76 + const result = await bgWindow.evaluate(async ({ role, key }: { role: string; key: string }) => { 77 + return await (window as any).app.window.open('about:blank', { 78 + role, 79 + modal: true, 80 + type: 'panel', 81 + width: 400, 82 + height: 300, 83 + // Distinct key per call so the open path doesn't dedupe against 84 + // an existing about:blank window. 85 + key: `transient-on-blur-${key}`, 86 + }); 87 + }, { role, key }); 88 + if (!result.success) { 89 + throw new Error(`window.open failed for role=${role} key=${key}: ${JSON.stringify(result)}`); 90 + } 91 + const id = result.id as number; 92 + // Force show — HEADLESS Electron defaults to show:false. 93 + if (!app.evaluateMain) throw new Error('evaluateMain not available'); 94 + const shown = await app.evaluateMain<boolean, number>( 95 + (_ctx, wid) => (globalThis as any).__peek_test.showWindow(wid), 96 + id, 97 + ); 98 + if (!shown) throw new Error(`__peek_test.showWindow returned false for id=${id}`); 99 + return id; 100 + } 101 + 102 + /** 103 + * Synchronously dispatch the BrowserWindow 'focus' event for the given 104 + * window. The production autoclose loop runs inside the focus listener, 105 + * so this is what drives all assertions in the suite. 106 + */ 107 + async function fireFocus(windowId: number): Promise<void> { 108 + if (!app.evaluateMain) throw new Error('evaluateMain not available'); 109 + const fired = await app.evaluateMain<boolean, number>( 110 + (_ctx, wid) => (globalThis as any).__peek_test.fireWindowFocus(wid), 111 + windowId, 112 + ); 113 + if (!fired) throw new Error(`__peek_test.fireWindowFocus returned false for id=${windowId}`); 114 + } 115 + 116 + /** True if the window is currently in the list AND visible. */ 117 + async function isVisible(windowId: number): Promise<boolean> { 118 + return await bgWindow.evaluate(async (wid: number) => { 119 + const result = await (window as any).app.window.list({ includeInternal: true }); 120 + if (!result.success) return false; 121 + const w = result.windows.find((x: any) => x.id === wid); 122 + return w !== undefined && w.visible === true; 123 + }, windowId); 124 + } 125 + 126 + /** 127 + * Wait until the given window is no longer visible (closed/destroyed or 128 + * hidden via closeOrHideWindow). Polls the window list deterministically; 129 + * no fixed sleep. 130 + */ 131 + async function waitForHidden(windowId: number, timeout = 3000): Promise<void> { 132 + await bgWindow.waitForFunction( 133 + async (wid: number) => { 134 + const result = await (window as any).app.window.list({ includeInternal: true }); 135 + if (!result.success) return false; 136 + const w = result.windows.find((x: any) => x.id === wid); 137 + return w === undefined || w.visible !== true; 138 + }, 139 + windowId, 140 + { timeout } 141 + ); 142 + } 143 + 144 + /** Close a window, ignoring already-closed errors. */ 145 + async function closeWindow(windowId: number): Promise<void> { 146 + try { 147 + await bgWindow.evaluate(async (wid: number) => { 148 + await (window as any).app.window.close(wid); 149 + }, windowId); 150 + } catch { /* may already be closed */ } 151 + } 152 + 153 + test('palette closes when another palette opens', async () => { 154 + const a = await openVisibleWindow('palette', 'palette-a'); 155 + const b = await openVisibleWindow('palette', 'palette-b'); 156 + // Drive the production focus path on b. 157 + await fireFocus(b); 158 + await waitForHidden(a); 159 + expect(await isVisible(b)).toBe(true); 160 + await closeWindow(b); 161 + }); 162 + 163 + test('palette closes when a quick-view opens', async () => { 164 + const palette = await openVisibleWindow('palette', 'palette-then-qv'); 165 + const slide = await openVisibleWindow('quick-view', 'qv-after-palette'); 166 + await fireFocus(slide); 167 + await waitForHidden(palette); 168 + expect(await isVisible(slide)).toBe(true); 169 + await closeWindow(slide); 170 + }); 171 + 172 + test('quick-view closes when another quick-view opens', async () => { 173 + const a = await openVisibleWindow('quick-view', 'qv-a'); 174 + const b = await openVisibleWindow('quick-view', 'qv-b'); 175 + await fireFocus(b); 176 + await waitForHidden(a); 177 + expect(await isVisible(b)).toBe(true); 178 + await closeWindow(b); 179 + }); 180 + 181 + test('utility focusing does NOT close transient parent (chain popup invariant)', async () => { 182 + const palette = await openVisibleWindow('palette', 'palette-with-child'); 183 + const utility = await openVisibleWindow('utility', 'chain-popup-child'); 184 + // Drive focus to the utility — the production check skips the autoclose 185 + // loop entirely when the focuser's role is 'utility'. 186 + await fireFocus(utility); 187 + expect(await isVisible(palette)).toBe(true); 188 + expect(await isVisible(utility)).toBe(true); 189 + await closeWindow(utility); 190 + await closeWindow(palette); 191 + }); 192 + 193 + test('overlay is exempt from auto-close when a palette focuses', async () => { 194 + const overlay = await openVisibleWindow('overlay', 'overlay-protected'); 195 + const palette = await openVisibleWindow('palette', 'palette-after-overlay'); 196 + // Palette focuses → autoclose loop runs but skips overlays explicitly. 197 + await fireFocus(palette); 198 + expect(await isVisible(overlay)).toBe(true); 199 + expect(await isVisible(palette)).toBe(true); 200 + await closeWindow(palette); 201 + await closeWindow(overlay); 202 + }); 203 + });
+5 -2
tests/fixtures/desktop-app.ts
··· 51 51 getExtensionWindows(): Page[]; 52 52 53 53 /** Evaluate code in the main (Node) process (Electron only) */ 54 - evaluateMain?<R>(fn: (ctx: { app: Electron.App; require: NodeRequire }) => R): Promise<R>; 54 + evaluateMain?<R, A = void>( 55 + fn: (ctx: { app: Electron.App; require: NodeRequire }, arg: A) => R, 56 + arg?: A, 57 + ): Promise<R>; 55 58 56 59 /** Close the app */ 57 60 close(): Promise<void>; ··· 270 273 return waitForWindowHelper(() => electronApp.windows(), 'peek://test/'); 271 274 }, 272 275 273 - evaluateMain: (fn: any) => electronApp.evaluate(fn as any), 276 + evaluateMain: (fn: any, arg?: any) => electronApp.evaluate(fn as any, arg), 274 277 275 278 getExtensionWindows: () => { 276 279 return electronApp.windows().filter(w =>