|
| 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 >= 96 && 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('Crowded tabs keep a readable minimum and scroll', async () => { |
| 124 | + await page.locator('#many-tabs').click() |
| 125 | + await expect(page.locator('[data-tab-strip-item]')).toHaveCount(18) |
| 126 | + const overflow = await page |
| 127 | + .locator('[data-tab-strip-item]') |
| 128 | + .first() |
| 129 | + .evaluate((tab) => ({ |
| 130 | + width: tab.getBoundingClientRect().width, |
| 131 | + scrollWidth: tab.parentElement?.scrollWidth ?? 0, |
| 132 | + clientWidth: tab.parentElement?.clientWidth ?? 0, |
| 133 | + })) |
| 134 | + expect(overflow.width).toBeGreaterThanOrEqual(96) |
| 135 | + expect(overflow.scrollWidth).toBeGreaterThan(overflow.clientWidth) |
| 136 | + await page.locator('#eight-tabs').click() |
| 137 | + }) |
| 138 | + await test.step('An open menu recovers when no native page was available for its initial capture', async () => { |
| 139 | + await page.locator('#menu-trigger').click() |
| 140 | + await expect(page.getByRole('menu')).toBeVisible() |
| 141 | + expect( |
| 142 | + await page.evaluate( |
| 143 | + (scope) => |
| 144 | + ( |
| 145 | + globalThis as typeof globalThis & { simDesktop: SimDesktopApi } |
| 146 | + ).simDesktop.browserAgent.capturePanelSnapshot(scope), |
| 147 | + SCOPE |
| 148 | + ) |
| 149 | + ).toBeNull() |
| 150 | + await expect(page.locator('#snapshot')).toHaveCount(0) |
| 151 | + await page |
| 152 | + .locator('#start-native') |
| 153 | + .evaluate((button) => (button as HTMLButtonElement).click()) |
| 154 | + await expect.poll(nativeVisible).toBe(false) |
| 155 | + await expect(page.locator('#snapshot')).toBeVisible() |
| 156 | + await expect(page.getByRole('menu')).toBeVisible() |
| 157 | + await page.keyboard.press('Escape') |
| 158 | + await expect.poll(nativeVisible).toBe(true) |
| 159 | + }) |
| 160 | + await test.step('Tooltip appears above the real native browser', async () => { |
| 161 | + await page.locator('#reference').hover() |
| 162 | + await expect(page.getByRole('tooltip')).toBeVisible() |
| 163 | + await expect.poll(nativeVisible).toBe(false) |
| 164 | + await expect(page.locator('#snapshot')).toBeVisible() |
| 165 | + await page.screenshot({ path: testInfo.outputPath('tooltip.png') }) |
| 166 | + }) |
| 167 | + await test.step('Leaving the tooltip restores the native browser', async () => { |
| 168 | + await page.mouse.move(200, 180) |
| 169 | + await expect.poll(nativeVisible).toBe(true) |
| 170 | + await expect(page.locator('#snapshot')).toHaveCount(0) |
| 171 | + }) |
| 172 | + await test.step('An open menu remains above the page when its item tooltip disappears', async () => { |
| 173 | + await page.locator('#menu-trigger').click() |
| 174 | + await expect(page.getByRole('menu')).toBeVisible() |
| 175 | + await expect.poll(nativeVisible).toBe(false) |
| 176 | + await page.locator('#tooltip-item').hover() |
| 177 | + await expect(page.getByRole('tooltip')).toBeVisible() |
| 178 | + await page.locator('#plain-item').hover() |
| 179 | + await expect(page.getByRole('tooltip')).toHaveCount(0) |
| 180 | + await expect(page.getByRole('menu')).toBeVisible() |
| 181 | + await expect.poll(nativeVisible).toBe(false) |
| 182 | + await expect(page.locator('#snapshot')).toBeVisible() |
| 183 | + }) |
| 184 | + await test.step('Resizing with an open menu refreshes the captured viewport', async () => { |
| 185 | + await shell.evaluate(({ BrowserWindow }) => |
| 186 | + BrowserWindow.getAllWindows()[0].setContentSize(1000, 750) |
| 187 | + ) |
| 188 | + await expect |
| 189 | + .poll(() => |
| 190 | + page.locator('#snapshot').evaluate((image) => image.getBoundingClientRect().width) |
| 191 | + ) |
| 192 | + .toBe(450) |
| 193 | + await expect.poll(nativeVisible).toBe(false) |
| 194 | + await page.screenshot({ path: testInfo.outputPath('menu.png') }) |
| 195 | + }) |
| 196 | + await test.step('Escape dismisses the menu and restores the native browser', async () => { |
| 197 | + await page.keyboard.press('Escape') |
| 198 | + await expect(page.getByRole('menu')).toHaveCount(0) |
| 199 | + await expect.poll(nativeVisible).toBe(true) |
| 200 | + await expect(page.locator('#snapshot')).toHaveCount(0) |
| 201 | + }) |
| 202 | + } finally { |
| 203 | + await app?.close() |
| 204 | + if (server?.listening) { |
| 205 | + const listener = server |
| 206 | + await new Promise<void>((resolve, reject) => |
| 207 | + listener.close((error) => (error ? reject(error) : resolve())) |
| 208 | + ) |
| 209 | + } |
| 210 | + if (userData) rmSync(userData, { recursive: true, force: true }) |
| 211 | + } |
| 212 | +}) |
0 commit comments