Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions .github/workflows/desktop-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ on:
- '.github/workflows/desktop-e2e.yml'
- '.github/workflows/desktop-release.yml'
- 'apps/desktop/**'
- 'apps/sim/app/workspace/**/browser-session/**'
- 'apps/sim/app/_styles/**'
- 'apps/sim/lib/postcss/**'
- 'apps/sim/postcss.config.mjs'
Expand Down
212 changes: 212 additions & 0 deletions apps/desktop/e2e/browser-chrome.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { createServer, type Server } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { _electron as electron, expect, test } from '@playwright/test'
import type { SimDesktopApi } from '@sim/desktop-bridge'
import { build } from 'esbuild'
import postcss from 'postcss'
import loadPostcssConfig from 'postcss-load-config'

const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url))
const SIM_DIR = fileURLToPath(new URL('../../sim/', import.meta.url))
const FIXTURE = fileURLToPath(new URL('./fixtures/browser-chrome.tsx', import.meta.url))
const SCOPE = 'browser-chrome-fixture'

test('crowded tabs and renderer overlays work with a real native browser page', async () => {
const testInfo = test.info()
let server: Server | undefined
let userData: string | undefined
let app: Awaited<ReturnType<typeof electron.launch>> | undefined
try {
const config = await loadPostcssConfig({}, SIM_DIR)
const stylesheet = join(SIM_DIR, 'app/_styles/globals.css')
const css = await postcss(config.plugins).process(
`${readFileSync(stylesheet, 'utf8')}\n@source ${JSON.stringify(FIXTURE)};`,
{ from: stylesheet }
)
const hook = join(
SIM_DIR,
'app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.ts'
)
const bundle = await build({
stdin: {
contents: `import { mountBrowserChromeFixture } from ${JSON.stringify(FIXTURE)};
import { useBrowserPanelOcclusion } from ${JSON.stringify(hook)};
mountBrowserChromeFixture(useBrowserPanelOcclusion);`,
resolveDir: SIM_DIR,
loader: 'tsx',
},
bundle: true,
write: false,
outfile: testInfo.outputPath('fixture.js'),
external: ['node:async_hooks'],
banner: { js: 'var process={env:{NODE_ENV:"development"},browser:true};' },
format: 'iife',
platform: 'browser',
tsconfig: join(SIM_DIR, 'tsconfig.json'),
define: { 'process.env.NODE_ENV': '"development"' },
})
server = createServer((request, response) => {
const path = new URL(request.url ?? '/', 'http://localhost').pathname
if (path === '/fixture.js' || path === '/fixture.css') {
response.setHeader('Content-Type', path.endsWith('.js') ? 'text/javascript' : 'text/css')
response.end(
path.endsWith('.js')
? bundle.outputFiles.find((file) => file.path.endsWith('.js'))?.text
: css.css
)
return
}
if (path.startsWith('/api/')) {
response.setHeader('Content-Type', 'application/json')
response.end(
path === '/api/auth/get-session'
? JSON.stringify({ user: { id: 'fixture-user' }, session: { id: 'fixture-session' } })
: '{}'
)
return
}
response.writeHead(200, {
'Content-Type': 'text/html',
'Set-Cookie': 'better-auth.session_token=fixture; HttpOnly; SameSite=Lax; Path=/',
})
response.end(
path === '/page'
? '<!doctype html><html><body style="background:#192b40;color:white;font:24px system-ui;padding:25px"><h1>Browser fixture</h1><p>A live page behind the application chrome.</p><button>Page action</button></body></html>'
: '<!doctype html><html class="dark"><head><link rel="stylesheet" href="/fixture.css"></head><body style="margin:0;background:#191919;color:#eee"><div id="root"></div><script src="/fixture.js"></script></body></html>'
)
})
await new Promise<void>((resolve) => server?.listen(0, resolve))
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing fixture address')
userData = mkdtempSync(join(tmpdir(), 'sim-browser-chrome-e2e-'))
app = await electron.launch({
args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'],
cwd: DESKTOP_DIR,
env: {
...process.env,
SIM_DESKTOP_ORIGIN: `http://127.0.0.1:${address.port}`,
SIM_DESKTOP_USER_DATA: userData,
},
})
const shell = app
const page = await shell.firstWindow()
await shell.evaluate(({ app, BrowserWindow }) => {
const window = BrowserWindow.getAllWindows()[0]
window.setContentSize(1100, 750)
window.webContents.setBackgroundThrottling(false)
app.focus({ steal: true })
window.focus()
})
const nativeVisible = () =>
shell.evaluate(({ BrowserWindow, WebContentsView }) =>
BrowserWindow.getAllWindows()[0]
.contentView.children.find(
(view) => view instanceof WebContentsView && view.webContents.getURL().endsWith('/page')
)
?.getVisible()
)
await expect(page.locator('[data-tab-strip-item]')).toHaveCount(8)
await test.step('Eight tabs shrink to available width without early overflow', async () => {
const geometry = await page.locator('[data-tab-strip-item]').evaluateAll((tabs) =>
tabs.map((tab) => {
const bounds = tab.getBoundingClientRect()
return { width: bounds.width, right: bounds.right }
})
)
expect(geometry.every((tab) => tab.width >= 96 && tab.width < 160)).toBe(true)
expect(geometry.at(-1)?.right).toBeLessThan(1070)
await page.screenshot({ path: testInfo.outputPath('tabs.png') })
})
await test.step('Crowded tabs keep a readable minimum and scroll', async () => {
await page.locator('#many-tabs').click()
await expect(page.locator('[data-tab-strip-item]')).toHaveCount(18)
const overflow = await page
.locator('[data-tab-strip-item]')
.first()
.evaluate((tab) => ({
width: tab.getBoundingClientRect().width,
scrollWidth: tab.parentElement?.scrollWidth ?? 0,
clientWidth: tab.parentElement?.clientWidth ?? 0,
}))
expect(overflow.width).toBeGreaterThanOrEqual(96)
expect(overflow.scrollWidth).toBeGreaterThan(overflow.clientWidth)
await page.locator('#eight-tabs').click()
})
await test.step('An open menu recovers when no native page was available for its initial capture', async () => {
await page.locator('#menu-trigger').click()
await expect(page.getByRole('menu')).toBeVisible()
expect(
await page.evaluate(
(scope) =>
(
globalThis as typeof globalThis & { simDesktop: SimDesktopApi }
).simDesktop.browserAgent.capturePanelSnapshot(scope),
SCOPE
)
).toBeNull()
await expect(page.locator('#snapshot')).toHaveCount(0)
await page
.locator('#start-native')
.evaluate((button) => (button as HTMLButtonElement).click())
await expect.poll(nativeVisible).toBe(false)
await expect(page.locator('#snapshot')).toBeVisible()
await expect(page.getByRole('menu')).toBeVisible()
await page.keyboard.press('Escape')
await expect.poll(nativeVisible).toBe(true)
})
await test.step('Tooltip appears above the real native browser', async () => {
await page.locator('#reference').hover()
await expect(page.getByRole('tooltip')).toBeVisible()
await expect.poll(nativeVisible).toBe(false)
await expect(page.locator('#snapshot')).toBeVisible()
await page.screenshot({ path: testInfo.outputPath('tooltip.png') })
})
await test.step('Leaving the tooltip restores the native browser', async () => {
await page.mouse.move(200, 180)
await expect.poll(nativeVisible).toBe(true)
await expect(page.locator('#snapshot')).toHaveCount(0)
})
await test.step('An open menu remains above the page when its item tooltip disappears', async () => {
await page.locator('#menu-trigger').click()
await expect(page.getByRole('menu')).toBeVisible()
await expect.poll(nativeVisible).toBe(false)
await page.locator('#tooltip-item').hover()
await expect(page.getByRole('tooltip')).toBeVisible()
await page.locator('#plain-item').hover()
await expect(page.getByRole('tooltip')).toHaveCount(0)
await expect(page.getByRole('menu')).toBeVisible()
await expect.poll(nativeVisible).toBe(false)
await expect(page.locator('#snapshot')).toBeVisible()
})
await test.step('Resizing with an open menu refreshes the captured viewport', async () => {
await shell.evaluate(({ BrowserWindow }) =>
BrowserWindow.getAllWindows()[0].setContentSize(1000, 750)
)
await expect
.poll(() =>
page.locator('#snapshot').evaluate((image) => image.getBoundingClientRect().width)
)
.toBe(450)
await expect.poll(nativeVisible).toBe(false)
await page.screenshot({ path: testInfo.outputPath('menu.png') })
})
await test.step('Escape dismisses the menu and restores the native browser', async () => {
await page.keyboard.press('Escape')
await expect(page.getByRole('menu')).toHaveCount(0)
await expect.poll(nativeVisible).toBe(true)
await expect(page.locator('#snapshot')).toHaveCount(0)
})
} finally {
await app?.close()
if (server?.listening) {
const listener = server
await new Promise<void>((resolve, reject) =>
listener.close((error) => (error ? reject(error) : resolve()))
)
}
if (userData) rmSync(userData, { recursive: true, force: true })
}
})
168 changes: 168 additions & 0 deletions apps/desktop/e2e/fixtures/browser-chrome.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { useEffect, useRef, useState } from 'react'
import type { BrowserPanelSnapshot } from '@sim/browser-protocol'
import type { SimDesktopApi } from '@sim/desktop-bridge'
import {
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
TabStrip,
Tooltip,
} from '@sim/emcn'
import { File } from '@sim/emcn/icons'
import { getErrorMessage } from '@sim/utils/errors'
import { createPortal } from 'react-dom'
import { createRoot } from 'react-dom/client'

const SCOPE = 'browser-chrome-fixture'

interface BrowserChromeFixtureProps {
useOcclusion: (
scopeId: string,
activeTabId: string | null,
visible: boolean,
getHostRect: () => DOMRect | null
) => {
snapshot: BrowserPanelSnapshot | null
snapshotLayer: 'modal' | 'popover'
onSnapshotError: () => void
}
}

function reportPanelBounds(api: SimDesktopApi, host: HTMLDivElement | null) {
const bounds = host?.getBoundingClientRect()
if (!bounds) return
api.browserAgent.setPanelBounds(
{ x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height },
null,
SCOPE
)
}

function BrowserChromeFixture({ useOcclusion }: BrowserChromeFixtureProps) {
const host = useRef<HTMLDivElement>(null)
const [activeTabId, setActiveTabId] = useState<string | null>(null)
const [selected, setSelected] = useState('tab-0')
const [tabCount, setTabCount] = useState(8)
const [error, setError] = useState<string | null>(null)
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
const { snapshot, snapshotLayer, onSnapshotError } = useOcclusion(
SCOPE,
activeTabId,
true,
() => host.current?.getBoundingClientRect() ?? null
)

const startBrowser = async () => {
if (!api.browserAgent.openUrl) throw new Error('Native browser bridge is unavailable')
await api.browserAgent.activateScope(SCOPE)
const tabs = await api.browserAgent.openUrl(
`${location.origin.replace('127.0.0.1', 'localhost')}/page`,
SCOPE
)
setActiveTabId(tabs.activeTabId)
reportPanelBounds(api, host.current)
}

useEffect(() => {
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
const element = host.current
if (!element) return
const observer = new ResizeObserver(() => reportPanelBounds(api, element))
observer.observe(element)
const heartbeat = window.setInterval(() => reportPanelBounds(api, element), 1_000)
return () => {
window.clearInterval(heartbeat)
observer.disconnect()
api.browserAgent.setPanelBounds(null, null, SCOPE)
}
}, [api])

return (
<>
<div className='flex gap-2 pt-[32px]'>
<Button
id='start-native'
onClick={() => void startBrowser().catch((cause) => setError(getErrorMessage(cause)))}
>
Start browser
</Button>
<Button id='eight-tabs' onClick={() => setTabCount(8)}>
Eight tabs
</Button>
<Button id='many-tabs' onClick={() => setTabCount(18)}>
Many tabs
</Button>
</div>
{error && <p role='alert'>{error}</p>}
<TabStrip
tabs={Array.from({ length: tabCount }, (_, index) => ({
id: `tab-${index}`,
title: `Example resource ${index + 1} with a descriptive title`,
icon: <File className='size-[16px] shrink-0' />,
active: selected === `tab-${index}`,
}))}
variant='floating'
onSelect={setSelected}
onClose={() => undefined}
onNew={() => setTabCount((count) => count + 1)}
/>
<div className='absolute top-[260px] left-[340px] w-[190px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button id='reference'>Example reference</Button>
</Tooltip.Trigger>
<Tooltip.Content>
A descriptive reference tooltip that extends across the browser boundary.
</Tooltip.Content>
</Tooltip.Root>
</div>
<div className='absolute top-[370px] left-[420px]'>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button id='menu-trigger'>Open menu</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start' className='w-[280px]'>
<DropdownMenuItem id='plain-item'>Open example item</DropdownMenuItem>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<DropdownMenuItem id='tooltip-item'>Preview details</DropdownMenuItem>
</Tooltip.Trigger>
<Tooltip.Content>Additional details for this example menu item.</Tooltip.Content>
</Tooltip.Root>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div
ref={host}
id='browser-host'
className='absolute top-[160px] right-0 bottom-0 left-[550px]'
/>
{snapshot &&
createPortal(
<img
id='snapshot'
src={snapshot.dataUrl}
width={snapshot.viewportBounds?.width}
height={snapshot.viewportBounds?.height}
onError={onSnapshotError}
className={cn(
'pointer-events-none fixed top-[160px] left-[550px] max-w-none',
snapshotLayer === 'modal'
? 'z-[calc(var(--z-modal)-1)]'
: 'z-[calc(var(--z-dropdown)-1)]'
)}
/>,
document.body
)}
</>
)
}

/** The real Sim hook is supplied by the bundle entry using Sim's module aliases. */
export function mountBrowserChromeFixture(useOcclusion: BrowserChromeFixtureProps['useOcclusion']) {
const root = document.getElementById('root')
if (!root) throw new Error('Missing fixture root')
createRoot(root).render(<BrowserChromeFixture useOcclusion={useOcclusion} />)
}
Loading
Loading