diff --git a/e2e/browser-panel-drag.spec.ts b/e2e/browser-panel-drag.spec.ts index e29ef0e1..6c6be41d 100644 --- a/e2e/browser-panel-drag.spec.ts +++ b/e2e/browser-panel-drag.spec.ts @@ -77,3 +77,29 @@ test('does not focus the address bar while dragging a browser panel', async () = await page.mouse.move(grab!.x + 180, grab!.y + 120, { steps: 20 }) await page.mouse.up() }) + +test('closes active and inactive new tabs with a single click on their close buttons', async () => { + const browser = await page.evaluate(() => window.__cateE2E!.createBrowser( + 'cate://newtab', + { x: 120, y: 120 }, + )) + const surface = page.locator(`[data-browser-surface="${browser.panelId}"]`) + await expect(surface).toHaveAttribute('data-browser-surface-visible', 'true') + const newTab = surface.getByRole('button', { name: 'New tab', exact: true }) + const closeTabs = surface.getByRole('button', { name: 'Close tab', exact: true }) + await newTab.click() + await newTab.click() + await expect(closeTabs).toHaveCount(3) + + // Close the active new tab, then the inactive one. Each click must remove + // exactly one tab, and the remaining start page must keep its empty address. + await expect(closeTabs.nth(2)).toBeEnabled() + await closeTabs.nth(2).click() + await expect(closeTabs).toHaveCount(2) + await expect(surface.locator('input').first()).toHaveValue('') + await closeTabs.first().hover() + await expect(closeTabs.first()).toBeEnabled() + await closeTabs.first().click() + await expect(closeTabs).toHaveCount(1) + await expect(surface.locator('input').first()).toHaveValue('') +}) diff --git a/src/main/ipc/fsWatch.test.ts b/src/main/ipc/fsWatch.test.ts index 82098c06..3b6e85ec 100644 --- a/src/main/ipc/fsWatch.test.ts +++ b/src/main/ipc/fsWatch.test.ts @@ -42,8 +42,8 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) /** * Wait until a FS_WATCH_EVENT matching `predicate` reaches the window. Calls - * `poke` between checks — chokidar drops events that fire before its initial - * scan finishes, so the change under test is re-applied until it's observed. + * `poke` between checks — native watcher setup is asynchronous, so the + * change under test is re-applied until it is observed. */ async function waitForWatchEvent( predicate: (event: { type: string; path: string }) => boolean, @@ -53,7 +53,7 @@ async function waitForWatchEvent( const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { await poke() - await sleep(100) + await sleep(300) const hit = sendToWindow.mock.calls.some( ([, channel, event]) => channel === FS_WATCH_EVENT && predicate(event as { type: string; path: string }), @@ -75,7 +75,7 @@ describe('fs watch events for nested paths', () => { }) afterEach(async () => { - await watchStop(fakeEvent, root) + await watchStop(fakeEvent, root, 'local') removeAllowedRoot(root, 'local') await fs.rm(root, { recursive: true, force: true }) }) @@ -99,14 +99,13 @@ describe('fs watch events for nested paths', () => { await watchStart(fakeEvent, root, 'local') - let rev = 0 const seen = await waitForWatchEvent( (event) => (event.type === 'update' || event.type === 'create') && event.path === nestedFile, - () => fs.writeFile(nestedFile, `v${++rev}`, 'utf8'), + () => fs.appendFile(nestedFile, 'next\n', 'utf8'), ) expect(seen).toBe(true) - }) + }, 10_000) test('does not report changes under an excluded folder', async () => { const excludedDir = path.join(root, 'node_modules', 'pkg') @@ -120,13 +119,11 @@ describe('fs watch events for nested paths', () => { // Poke both files; once the marker's event arrives the watcher is provably // live, so the absence of the excluded file's event is meaningful. - let rev = 0 const markerSeen = await waitForWatchEvent( (event) => event.path === markerFile, async () => { - rev++ - await fs.writeFile(excludedFile, `v${rev}`, 'utf8') - await fs.writeFile(markerFile, `v${rev}`, 'utf8') + await fs.appendFile(excludedFile, 'next\n', 'utf8') + await fs.appendFile(markerFile, 'next\n', 'utf8') }, ) expect(markerSeen).toBe(true) @@ -136,5 +133,5 @@ describe('fs watch events for nested paths', () => { channel === FS_WATCH_EVENT && (event as { path: string }).path === excludedFile, ) expect(excludedSeen).toBe(false) - }) + }, 10_000) }) diff --git a/src/renderer/panels/BrowserPanel.component.test.tsx b/src/renderer/panels/BrowserPanel.component.test.tsx index 912925db..2bcbc5ed 100644 --- a/src/renderer/panels/BrowserPanel.component.test.tsx +++ b/src/renderer/panels/BrowserPanel.component.test.tsx @@ -15,13 +15,13 @@ vi.mock('./UrlSuggestions', () => ({ UrlSuggestions: () => null })) vi.mock('./StartPage', () => ({ StartPage: () =>
Start page
})) vi.mock('./BrowserHistoryPage', () => ({ BrowserHistoryPage: () =>
})) vi.mock('./BrowserPasswordManagerPage', () => ({ BrowserPasswordManagerPage: () => null })) -vi.mock('./BrowserTabStrip', () => ({ BrowserTabStrip: () =>
})) vi.mock('./BrowserBookmarksSidebar', () => ({ BrowserBookmarksSidebar: () => null })) import BrowserPanel from './BrowserPanel' import { useAppStore } from '../stores/appStore' import { useBrowserStore } from '../stores/browserStore' import { useSettingsStore } from '../stores/settingsStore' +import { useActivePanelStore } from '../lib/activePanel' const browserControl = vi.fn(async () => ({ ok: true })) let downloadsChanged: ((payload: { @@ -49,6 +49,7 @@ beforeEach(() => { host = document.createElement('div') document.body.appendChild(host) root = createRoot(host) + useActivePanelStore.setState({ activePanelId: null }) useAppStore.setState({ updatePanelTitle: vi.fn(), updateBrowserActiveTabUrl: vi.fn(), updatePanelTabs: vi.fn(), }) @@ -79,6 +80,43 @@ function mount(tabs = [{ id: 'tab-1', url: 'https://example.test/', title: 'Exam } describe('BrowserPanel live webview', () => { + it.each(['ready', 'starting'])('closes new tabs and keeps the remaining start page when its guest is %s', (guestState) => { + mount([{ id: 'tab-1', url: 'cate://newtab', title: '' }]) + const firstGuest = host.querySelector('webview') as HTMLElement + const methods = installWebviewMethods(firstGuest) + methods.getURL.mockReturnValue('about:blank') + if (guestState === 'starting') { + methods.getURL.mockImplementation(() => { throw new Error('The WebView must be attached to the DOM and the dom-ready event emitted before this method can be called.') }) + } + act(() => (host.querySelector('button[aria-label="New tab"]') as HTMLButtonElement).click()) + + const closeButtons = host.querySelectorAll('button[aria-label="Close tab"]') + expect(closeButtons).toHaveLength(2) + expect(closeButtons[1].disabled).toBe(false) + act(() => closeButtons[1].click()) + + expect(host.querySelectorAll('button[aria-label="Close tab"]')).toHaveLength(1) + expect(host.querySelector('webview')).toBe(firstGuest) + expect(host.textContent).toContain('Start page') + expect((host.querySelector('input') as HTMLInputElement).value).toBe('') + expect(useAppStore.getState().updatePanelTabs).toHaveBeenLastCalledWith( + 'workspace-1', 'browser-1', [{ id: 'tab-1', url: 'cate://newtab', title: '' }], 'tab-1', + ) + }) + + it('keeps the start page when selecting an inactive new tab and closing it', () => { + mount([{ id: 'tab-1', url: 'cate://newtab', title: '' }]) + act(() => (host.querySelector('button[aria-label="New tab"]') as HTMLButtonElement).click()) + act(() => (host.querySelector('[title="New Tab · right-click to pin"]') as HTMLElement).click()) + expect(host.textContent).toContain('Start page') + expect((host.querySelector('input') as HTMLInputElement).value).toBe('') + + act(() => (host.querySelector('button[aria-label="Close tab"]') as HTMLButtonElement).click()) + expect(host.querySelectorAll('button[aria-label="Close tab"]')).toHaveLength(1) + expect(host.textContent).toContain('Start page') + expect((host.querySelector('input') as HTMLInputElement).value).toBe('') + }) + it('lets users inspect, replace, and reset an agent-set viewport from the menu', () => { mount() const controller = portalMocks.registerController.mock.calls[0][1] @@ -177,6 +215,25 @@ describe('BrowserPanel live webview', () => { focus.mockRestore() }) + it('does not move focus from new-tab chrome into the hidden guest', () => { + const frames = new Map() + let frameId = 0 + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + frames.set(++frameId, callback) + return frameId + }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((id) => { frames.delete(id) }) + useActivePanelStore.setState({ activePanelId: 'browser-1' }) + mount([{ id: 'tab-1', url: 'cate://newtab', title: '' }]) + const guestFocus = vi.spyOn(host.querySelector('webview') as HTMLElement, 'focus') + act(() => { + for (const callback of frames.values()) callback(0) + frames.clear() + }) + expect(document.activeElement).toBe(host.querySelector('input')) + expect(guestFocus).not.toHaveBeenCalled() + }) + it('shows download progress in a toolbar popover for its guest', () => { mount() const webview = host.querySelector('webview') as HTMLElement diff --git a/src/renderer/panels/BrowserPanel.tsx b/src/renderer/panels/BrowserPanel.tsx index 1dd8727e..ede3bd0c 100644 --- a/src/renderer/panels/BrowserPanel.tsx +++ b/src/renderer/panels/BrowserPanel.tsx @@ -433,7 +433,8 @@ export default function BrowserPanel({ if (!tab) return activeTabIdRef.current = id setActiveTabId(id) - const webview = webviewsByTabRef.current.get(id) + // The start page's hidden about:blank guest is only an automation host. + const webview = isStartPageUrl(tab.url) ? undefined : webviewsByTabRef.current.get(id) const url = webview?.getURL() || tab.url setCurrentUrl(url) setInputUrl(addressBarValue(url)) @@ -496,7 +497,7 @@ export default function BrowserPanel({ const neighbor = next[Math.min(idx, next.length - 1)] activeTabIdRef.current = neighbor.id setActiveTabId(neighbor.id) - const webview = webviewsByTabRef.current.get(neighbor.id) + const webview = isStartPageUrl(neighbor.url) ? undefined : webviewsByTabRef.current.get(neighbor.id) const url = webview?.getURL() || neighbor.url setCurrentUrl(url) setInputUrl(addressBarValue(url)) @@ -753,6 +754,9 @@ export default function BrowserPanel({ const webview = webviewRef.current if (!webview) return const frame = requestAnimationFrame(() => { + // Start pages keep an invisible guest for automation; user focus belongs + // to the address bar and tab controls, never that guest. + if (isStartPageUrl(currentUrlRef.current)) return // Canvas-node focus happens on mousedown, before the drag dead zone has // armed. Focusing a switches webContents and blurs the host // window, which cancels that pending drag. Keep focus on the title bar diff --git a/src/renderer/panels/BrowserTabStrip.test.tsx b/src/renderer/panels/BrowserTabStrip.test.tsx index 14dc78ba..2ded47a2 100644 --- a/src/renderer/panels/BrowserTabStrip.test.tsx +++ b/src/renderer/panels/BrowserTabStrip.test.tsx @@ -124,4 +124,41 @@ describe('BrowserTabStrip', () => { expect(setPointerCapture).not.toHaveBeenCalled() expect(onClose).toHaveBeenCalledWith('tab-1') }) + + it('closes a new tab on the first press after a drag without a compatibility click', () => { + const onClose = vi.fn() + act(() => root.render( + , + )) + const strip = host.querySelector('[aria-label="Browser tabs"]') as HTMLDivElement + Object.defineProperty(strip, 'scrollWidth', { configurable: true, value: 600 }) + Object.defineProperty(strip, 'clientWidth', { configurable: true, value: 300 }) + const pointer = (type: string, clientX: number): Event => Object.assign( + new MouseEvent(type, { bubbles: true, button: 0, clientX }), + { pointerId: 1 }, + ) + act(() => { + strip.dispatchEvent(pointer('pointerdown', 120)) + strip.dispatchEvent(pointer('pointermove', 70)) + strip.dispatchEvent(pointer('pointerup', 70)) + }) + + const icon = host.querySelectorAll('button[aria-label="Close tab"] svg')[1] + act(() => { + icon.dispatchEvent(pointer('pointerdown', 100)) + icon.dispatchEvent(pointer('pointerup', 100)) + icon.dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 })) + }) + expect(onClose).toHaveBeenCalledExactlyOnceWith('tab-2') + }) }) diff --git a/src/renderer/panels/BrowserTabStrip.tsx b/src/renderer/panels/BrowserTabStrip.tsx index 35d9bde8..8d4caf47 100644 --- a/src/renderer/panels/BrowserTabStrip.tsx +++ b/src/renderer/panels/BrowserTabStrip.tsx @@ -38,10 +38,10 @@ export function BrowserTabStrip({ tabs, activeTabId, onSelect, onClose, onNewTab const handlePointerDown = (event: ReactPointerEvent): void => { if (event.button !== 0) return - if (event.target instanceof Element && event.target.closest('button')) return // If a previous drag ended without Chromium emitting its compatibility // click, a new press is unambiguously a fresh interaction. suppressNextClickRef.current = false + if (event.target instanceof Element && event.target.closest('button')) return if (event.currentTarget.scrollWidth <= event.currentTarget.clientWidth) return dragRef.current = { pointerId: event.pointerId,