Skip to content

Commit 9a21fbb

Browse files
authored
fix(ui): compact crowded tabs and layer native browser overlays (#8306)
* fix(ui): compact crowded tabs and layer native browser overlays * fix(ui): preserve native occlusion during snapshot refresh * chore(tests): keep native refresh coverage at the Electron boundary
1 parent 4ead961 commit 9a21fbb

13 files changed

Lines changed: 781 additions & 97 deletions

File tree

‎.github/workflows/desktop-e2e.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ on:
1010
- '.github/workflows/desktop-e2e.yml'
1111
- '.github/workflows/desktop-release.yml'
1212
- 'apps/desktop/**'
13+
- 'apps/sim/app/workspace/**/browser-session/**'
1314
- 'apps/sim/app/_styles/**'
1415
- 'apps/sim/lib/postcss/**'
1516
- 'apps/sim/postcss.config.mjs'
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
2+
import { createServer, type Server } from 'node:http'
3+
import { tmpdir } from 'node:os'
4+
import { join } from 'node:path'
5+
import { fileURLToPath } from 'node:url'
6+
import { _electron as electron, expect, test } from '@playwright/test'
7+
import type { SimDesktopApi } from '@sim/desktop-bridge'
8+
import { build } from 'esbuild'
9+
import postcss from 'postcss'
10+
import loadPostcssConfig from 'postcss-load-config'
11+
12+
const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url))
13+
const SIM_DIR = fileURLToPath(new URL('../../sim/', import.meta.url))
14+
const FIXTURE = fileURLToPath(new URL('./fixtures/browser-chrome.tsx', import.meta.url))
15+
const SCOPE = 'browser-chrome-fixture'
16+
17+
test('crowded tabs and renderer overlays work with a real native browser page', async () => {
18+
const testInfo = test.info()
19+
let server: Server | undefined
20+
let userData: string | undefined
21+
let app: Awaited<ReturnType<typeof electron.launch>> | undefined
22+
try {
23+
const config = await loadPostcssConfig({}, SIM_DIR)
24+
const stylesheet = join(SIM_DIR, 'app/_styles/globals.css')
25+
const css = await postcss(config.plugins).process(
26+
`${readFileSync(stylesheet, 'utf8')}\n@source ${JSON.stringify(FIXTURE)};`,
27+
{ from: stylesheet }
28+
)
29+
const hook = join(
30+
SIM_DIR,
31+
'app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.ts'
32+
)
33+
const bundle = await build({
34+
stdin: {
35+
contents: `import { mountBrowserChromeFixture } from ${JSON.stringify(FIXTURE)};
36+
import { useBrowserPanelOcclusion } from ${JSON.stringify(hook)};
37+
mountBrowserChromeFixture(useBrowserPanelOcclusion);`,
38+
resolveDir: SIM_DIR,
39+
loader: 'tsx',
40+
},
41+
bundle: true,
42+
write: false,
43+
outfile: testInfo.outputPath('fixture.js'),
44+
external: ['node:async_hooks'],
45+
banner: { js: 'var process={env:{NODE_ENV:"development"},browser:true};' },
46+
format: 'iife',
47+
platform: 'browser',
48+
tsconfig: join(SIM_DIR, 'tsconfig.json'),
49+
define: { 'process.env.NODE_ENV': '"development"' },
50+
})
51+
server = createServer((request, response) => {
52+
const path = new URL(request.url ?? '/', 'http://localhost').pathname
53+
if (path === '/fixture.js' || path === '/fixture.css') {
54+
response.setHeader('Content-Type', path.endsWith('.js') ? 'text/javascript' : 'text/css')
55+
response.end(
56+
path.endsWith('.js')
57+
? bundle.outputFiles.find((file) => file.path.endsWith('.js'))?.text
58+
: css.css
59+
)
60+
return
61+
}
62+
if (path.startsWith('/api/')) {
63+
response.setHeader('Content-Type', 'application/json')
64+
response.end(
65+
path === '/api/auth/get-session'
66+
? JSON.stringify({ user: { id: 'fixture-user' }, session: { id: 'fixture-session' } })
67+
: '{}'
68+
)
69+
return
70+
}
71+
response.writeHead(200, {
72+
'Content-Type': 'text/html',
73+
'Set-Cookie': 'better-auth.session_token=fixture; HttpOnly; SameSite=Lax; Path=/',
74+
})
75+
response.end(
76+
path === '/page'
77+
? '<!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>'
78+
: '<!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>'
79+
)
80+
})
81+
await new Promise<void>((resolve) => server?.listen(0, resolve))
82+
const address = server.address()
83+
if (!address || typeof address === 'string') throw new Error('Missing fixture address')
84+
userData = mkdtempSync(join(tmpdir(), 'sim-browser-chrome-e2e-'))
85+
app = await electron.launch({
86+
args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'],
87+
cwd: DESKTOP_DIR,
88+
env: {
89+
...process.env,
90+
SIM_DESKTOP_ORIGIN: `http://127.0.0.1:${address.port}`,
91+
SIM_DESKTOP_USER_DATA: userData,
92+
},
93+
})
94+
const shell = app
95+
const page = await shell.firstWindow()
96+
await shell.evaluate(({ app, BrowserWindow }) => {
97+
const window = BrowserWindow.getAllWindows()[0]
98+
window.setContentSize(1100, 750)
99+
window.webContents.setBackgroundThrottling(false)
100+
app.focus({ steal: true })
101+
window.focus()
102+
})
103+
const nativeVisible = () =>
104+
shell.evaluate(({ BrowserWindow, WebContentsView }) =>
105+
BrowserWindow.getAllWindows()[0]
106+
.contentView.children.find(
107+
(view) => view instanceof WebContentsView && view.webContents.getURL().endsWith('/page')
108+
)
109+
?.getVisible()
110+
)
111+
await expect(page.locator('[data-tab-strip-item]')).toHaveCount(8)
112+
await test.step('Eight tabs shrink to available width without early overflow', async () => {
113+
const geometry = await page.locator('[data-tab-strip-item]').evaluateAll((tabs) =>
114+
tabs.map((tab) => {
115+
const bounds = tab.getBoundingClientRect()
116+
return { width: bounds.width, right: bounds.right }
117+
})
118+
)
119+
expect(geometry.every((tab) => tab.width >= 64 && tab.width < 160)).toBe(true)
120+
expect(geometry.at(-1)?.right).toBeLessThan(1070)
121+
await page.screenshot({ path: testInfo.outputPath('tabs.png') })
122+
})
123+
await test.step('Short labels keep their compact intrinsic width', async () => {
124+
await page.locator('#short-tabs').click()
125+
const widths = await page
126+
.locator('[data-tab-strip-item]')
127+
.evaluateAll((tabs) => tabs.map((tab) => tab.getBoundingClientRect().width))
128+
expect(widths.every((width) => width >= 64 && width < 96)).toBe(true)
129+
await page.locator('#eight-tabs').click()
130+
})
131+
await test.step('Crowded tabs preserve controls and scroll', async () => {
132+
await page.locator('#many-tabs').click()
133+
await expect(page.locator('[data-tab-strip-item]')).toHaveCount(18)
134+
const overflow = await page
135+
.locator('[data-tab-strip-item]')
136+
.first()
137+
.evaluate((tab) => ({
138+
width: tab.getBoundingClientRect().width,
139+
scrollWidth: tab.parentElement?.scrollWidth ?? 0,
140+
clientWidth: tab.parentElement?.clientWidth ?? 0,
141+
}))
142+
expect(overflow.width).toBeGreaterThanOrEqual(64)
143+
expect(overflow.scrollWidth).toBeGreaterThan(overflow.clientWidth)
144+
await page.locator('#eight-tabs').click()
145+
})
146+
await test.step('An open menu recovers when no native page was available for its initial capture', async () => {
147+
await page.locator('#menu-trigger').click()
148+
await expect(page.getByRole('menu')).toBeVisible()
149+
expect(
150+
await page.evaluate(
151+
(scope) =>
152+
(
153+
globalThis as typeof globalThis & { simDesktop: SimDesktopApi }
154+
).simDesktop.browserAgent.capturePanelSnapshot(scope),
155+
SCOPE
156+
)
157+
).toBeNull()
158+
await expect(page.locator('#snapshot')).toHaveCount(0)
159+
await page
160+
.locator('#start-native')
161+
.evaluate((button) => (button as HTMLButtonElement).click())
162+
await expect.poll(nativeVisible).toBe(false)
163+
await expect(page.locator('#snapshot')).toBeVisible()
164+
await expect(page.getByRole('menu')).toBeVisible()
165+
await page.keyboard.press('Escape')
166+
await expect.poll(nativeVisible).toBe(true)
167+
})
168+
await test.step('Tooltip appears above the real native browser', async () => {
169+
await page.locator('#reference').hover()
170+
await expect(page.getByRole('tooltip')).toBeVisible()
171+
await expect.poll(nativeVisible).toBe(false)
172+
await expect(page.locator('#snapshot')).toBeVisible()
173+
await page.screenshot({ path: testInfo.outputPath('tooltip.png') })
174+
})
175+
await test.step('Leaving the tooltip restores the native browser', async () => {
176+
await page.mouse.move(200, 180)
177+
await expect.poll(nativeVisible).toBe(true)
178+
await expect(page.locator('#snapshot')).toHaveCount(0)
179+
})
180+
await test.step('An open menu remains above the page when its item tooltip disappears', async () => {
181+
await page.locator('#menu-trigger').click()
182+
await expect(page.getByRole('menu')).toBeVisible()
183+
await expect.poll(nativeVisible).toBe(false)
184+
await page.locator('#tooltip-item').hover()
185+
await expect(page.getByRole('tooltip')).toBeVisible()
186+
await page.locator('#plain-item').hover()
187+
await expect(page.getByRole('tooltip')).toHaveCount(0)
188+
await expect(page.getByRole('menu')).toBeVisible()
189+
await expect.poll(nativeVisible).toBe(false)
190+
await expect(page.locator('#snapshot')).toBeVisible()
191+
})
192+
await test.step('Resizing with an open menu refreshes the captured viewport', async () => {
193+
await shell.evaluate(({ BrowserWindow }) =>
194+
BrowserWindow.getAllWindows()[0].setContentSize(1000, 750)
195+
)
196+
await expect
197+
.poll(() =>
198+
page.locator('#snapshot').evaluate((image) => image.getBoundingClientRect().width)
199+
)
200+
.toBe(450)
201+
await expect.poll(nativeVisible).toBe(false)
202+
await page.screenshot({ path: testInfo.outputPath('menu.png') })
203+
})
204+
await test.step('Escape dismisses the menu and restores the native browser', async () => {
205+
await page.keyboard.press('Escape')
206+
await expect(page.getByRole('menu')).toHaveCount(0)
207+
await expect.poll(nativeVisible).toBe(true)
208+
await expect(page.locator('#snapshot')).toHaveCount(0)
209+
})
210+
} catch (error) {
211+
const page = app?.windows()[0]
212+
if (page && !page.isClosed()) {
213+
await page.screenshot({ path: testInfo.outputPath('failure.png') })
214+
const overlays = await page.locator('[data-native-surface-overlay]').evaluateAll((elements) =>
215+
elements.map((element) => ({
216+
text: element.textContent,
217+
bounds: element.getBoundingClientRect().toJSON(),
218+
opacity: getComputedStyle(element).opacity,
219+
visibility: getComputedStyle(element).visibility,
220+
}))
221+
)
222+
await testInfo.attach('overlay-state', {
223+
body: JSON.stringify(overlays, null, 2),
224+
contentType: 'application/json',
225+
})
226+
}
227+
throw error
228+
} finally {
229+
await app?.close()
230+
if (server?.listening) {
231+
const listener = server
232+
await new Promise<void>((resolve, reject) =>
233+
listener.close((error) => (error ? reject(error) : resolve()))
234+
)
235+
}
236+
if (userData) rmSync(userData, { recursive: true, force: true })
237+
}
238+
})

0 commit comments

Comments
 (0)