Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions e2e/browser-panel-drag.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('')
})
21 changes: 9 additions & 12 deletions src/main/ipc/fsWatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 }),
Expand All @@ -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 })
})
Expand All @@ -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')
Expand All @@ -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)
Expand All @@ -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)
})
59 changes: 58 additions & 1 deletion src/renderer/panels/BrowserPanel.component.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ vi.mock('./UrlSuggestions', () => ({ UrlSuggestions: () => null }))
vi.mock('./StartPage', () => ({ StartPage: () => <div>Start page</div> }))
vi.mock('./BrowserHistoryPage', () => ({ BrowserHistoryPage: () => <div data-testid="browser-history" /> }))
vi.mock('./BrowserPasswordManagerPage', () => ({ BrowserPasswordManagerPage: () => null }))
vi.mock('./BrowserTabStrip', () => ({ BrowserTabStrip: () => <div data-testid="browser-tabs" /> }))
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: {
Expand Down Expand Up @@ -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(),
})
Expand Down Expand Up @@ -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<HTMLButtonElement>('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]
Expand Down Expand Up @@ -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<number, FrameRequestCallback>()
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
Expand Down
8 changes: 6 additions & 2 deletions src/renderer/panels/BrowserPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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 <webview> switches webContents and blurs the host
// window, which cancels that pending drag. Keep focus on the title bar
Expand Down
37 changes: 37 additions & 0 deletions src/renderer/panels/BrowserTabStrip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<BrowserTabStrip
tabs={[
{ id: 'tab-1', url: 'cate://newtab', title: '' },
{ id: 'tab-2', url: 'cate://newtab', title: '' },
]}
activeTabId="tab-2"
onSelect={vi.fn()}
onClose={onClose}
onNewTab={vi.fn()}
onTogglePin={vi.fn()}
/>,
))
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')
})
})
2 changes: 1 addition & 1 deletion src/renderer/panels/BrowserTabStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ export function BrowserTabStrip({ tabs, activeTabId, onSelect, onClose, onNewTab

const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>): 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,
Expand Down
Loading