|
| 1 | +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' |
| 2 | +import { createServer } from 'node:http' |
| 3 | +import { tmpdir } from 'node:os' |
| 4 | +import { dirname, join } from 'node:path' |
| 5 | +import { fileURLToPath } from 'node:url' |
| 6 | +import { _electron as electron, expect, test } from '@playwright/test' |
| 7 | +import { getErrorMessage } from '@sim/utils/errors' |
| 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('../../sim/scripts/fixtures/chat-panel.tsx', import.meta.url)) |
| 15 | + |
| 16 | +test('chat panel sizes survive navigation, chat switches, collapse, and layout constraints', async () => { |
| 17 | + const reportPath = process.env.CHAT_PANEL_REPORT_PATH ?? test.info().outputPath('chat-panel.json') |
| 18 | + const checks: { |
| 19 | + name: string |
| 20 | + status: 'passed' | 'failed' |
| 21 | + durationMs: number |
| 22 | + error?: string |
| 23 | + }[] = [] |
| 24 | + const check = async (name: string, run: () => Promise<void>) => { |
| 25 | + const started = Date.now() |
| 26 | + try { |
| 27 | + await test.step(name, run) |
| 28 | + checks.push({ name, status: 'passed', durationMs: Date.now() - started }) |
| 29 | + } catch (error) { |
| 30 | + checks.push({ |
| 31 | + name, |
| 32 | + status: 'failed', |
| 33 | + durationMs: Date.now() - started, |
| 34 | + error: getErrorMessage(error), |
| 35 | + }) |
| 36 | + throw error |
| 37 | + } |
| 38 | + } |
| 39 | + const userData = mkdtempSync(join(tmpdir(), 'sim-chat-panel-e2e-')) |
| 40 | + let app: Awaited<ReturnType<typeof electron.launch>> | undefined |
| 41 | + let passed = false |
| 42 | + let javascript = '' |
| 43 | + let stylesheet = '' |
| 44 | + const server = createServer((request, response) => { |
| 45 | + const path = new URL(request.url ?? '/', 'http://localhost').pathname |
| 46 | + if (path === '/fixture.js' || path === '/fixture.css') { |
| 47 | + response.setHeader('Content-Type', path.endsWith('.js') ? 'text/javascript' : 'text/css') |
| 48 | + response.end(path.endsWith('.js') ? javascript : stylesheet) |
| 49 | + } else if (path.startsWith('/api/')) { |
| 50 | + response.setHeader('Content-Type', 'application/json') |
| 51 | + response.end('{}') |
| 52 | + } else { |
| 53 | + response.setHeader('Content-Type', 'text/html') |
| 54 | + response.end( |
| 55 | + '<!doctype html><html class="dark"><head><link rel="stylesheet" href="/fixture.css"></head><body style="margin:0;background:var(--bg);color:var(--text-primary)"><div id="root"></div><script src="/fixture.js"></script></body></html>' |
| 56 | + ) |
| 57 | + } |
| 58 | + }) |
| 59 | + |
| 60 | + try { |
| 61 | + await check('load the production resource panel resize hook in Electron', async () => { |
| 62 | + const config = await loadPostcssConfig({}, SIM_DIR) |
| 63 | + const cssPath = join(SIM_DIR, 'app/_styles/globals.css') |
| 64 | + const css = await postcss(config.plugins).process( |
| 65 | + `${readFileSync(cssPath, 'utf8')}\n@source ${JSON.stringify(FIXTURE)};`, |
| 66 | + { from: cssPath } |
| 67 | + ) |
| 68 | + const bundle = await build({ |
| 69 | + entryPoints: [FIXTURE], |
| 70 | + bundle: true, |
| 71 | + write: false, |
| 72 | + outfile: test.info().outputPath('fixture.js'), |
| 73 | + external: ['node:async_hooks'], |
| 74 | + banner: { js: 'var process={env:{NODE_ENV:"development"},browser:true};' }, |
| 75 | + format: 'iife', |
| 76 | + platform: 'browser', |
| 77 | + tsconfig: join(SIM_DIR, 'tsconfig.json'), |
| 78 | + define: { 'process.env.NODE_ENV': '"development"' }, |
| 79 | + }) |
| 80 | + javascript = bundle.outputFiles.find((file) => file.path.endsWith('.js'))?.text ?? '' |
| 81 | + stylesheet = `${css.css}\n${bundle.outputFiles.find((file) => file.path.endsWith('.css'))?.text ?? ''}` |
| 82 | + await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)) |
| 83 | + const address = server.address() |
| 84 | + if (!address || typeof address === 'string') throw new Error('Missing fixture address') |
| 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 | + }) |
| 95 | + if (!app) throw new Error('Electron did not launch') |
| 96 | + const shell = app |
| 97 | + const page = await shell.firstWindow() |
| 98 | + const errors: string[] = [] |
| 99 | + page.on('pageerror', (error) => errors.push(error.message)) |
| 100 | + await shell.evaluate(({ app, BrowserWindow }) => { |
| 101 | + const window = BrowserWindow.getAllWindows()[0] |
| 102 | + window.setContentSize(1440, 800) |
| 103 | + window.webContents.setBackgroundThrottling(false) |
| 104 | + app.focus({ steal: true }) |
| 105 | + window.focus() |
| 106 | + }) |
| 107 | + await page.reload() |
| 108 | + expect(errors).toEqual([]) |
| 109 | + const panel = page.locator('[data-mothership-panel]') |
| 110 | + const divider = page.getByRole('separator', { name: 'Resize resource view' }) |
| 111 | + const width = () => panel.evaluate((element) => element.getBoundingClientRect().width) |
| 112 | + const expectWidth = async (expected: number) => { |
| 113 | + await expect.poll(async () => Math.abs((await width()) - expected)).toBeLessThan(2) |
| 114 | + } |
| 115 | + const dragTo = async (target: number) => { |
| 116 | + await divider.hover({ position: { x: 4, y: 100 } }) |
| 117 | + const rect = await panel.boundingBox() |
| 118 | + if (!rect) throw new Error('Missing panel bounds') |
| 119 | + await page.mouse.down() |
| 120 | + await expect |
| 121 | + .poll(() => divider.evaluate((element) => element.hasPointerCapture(1))) |
| 122 | + .toBe(true) |
| 123 | + await page.mouse.move(rect.x + rect.width - target, rect.y + 100, { steps: 12 }) |
| 124 | + await page.mouse.up() |
| 125 | + await expectWidth(target) |
| 126 | + } |
| 127 | + |
| 128 | + await check('a chosen width survives a settings round trip and reload', async () => { |
| 129 | + await expectWidth(720) |
| 130 | + await dragTo(620) |
| 131 | + await page.getByRole('button', { name: 'Settings', exact: true }).click() |
| 132 | + await expect(panel).toHaveCount(0) |
| 133 | + await page.getByRole('button', { name: 'Back', exact: true }).click() |
| 134 | + await expectWidth(620) |
| 135 | + await page.reload() |
| 136 | + await expectWidth(620) |
| 137 | + }) |
| 138 | + |
| 139 | + await check( |
| 140 | + 'workspace and organization chats restore independent widths without remounting', |
| 141 | + async () => { |
| 142 | + await page.getByRole('button', { name: 'workspace-chat-b', exact: true }).click() |
| 143 | + await expectWidth(720) |
| 144 | + await dragTo(830) |
| 145 | + await page.getByRole('button', { name: 'organization-chat-a', exact: true }).click() |
| 146 | + await expectWidth(720) |
| 147 | + await dragTo(560) |
| 148 | + await page.getByRole('button', { name: 'Settings', exact: true }).click() |
| 149 | + await page.getByRole('button', { name: 'Back', exact: true }).click() |
| 150 | + await expectWidth(560) |
| 151 | + await page.getByRole('button', { name: 'workspace-chat-a', exact: true }).click() |
| 152 | + await expectWidth(620) |
| 153 | + await page.getByRole('button', { name: 'workspace-chat-b', exact: true }).click() |
| 154 | + await expectWidth(830) |
| 155 | + } |
| 156 | + ) |
| 157 | + |
| 158 | + await check( |
| 159 | + 'collapse preserves the expanded preference, including keyboard changes', |
| 160 | + async () => { |
| 161 | + await page.getByRole('button', { name: 'Collapse resource view' }).click() |
| 162 | + await expectWidth(0) |
| 163 | + await page.getByRole('button', { name: 'Expand resource view' }).click() |
| 164 | + await expectWidth(830) |
| 165 | + await divider.focus() |
| 166 | + await page.keyboard.press('ArrowRight') |
| 167 | + await expectWidth(798) |
| 168 | + await page.getByRole('button', { name: 'Settings', exact: true }).click() |
| 169 | + await page.getByRole('button', { name: 'Back', exact: true }).click() |
| 170 | + await expectWidth(798) |
| 171 | + } |
| 172 | + ) |
| 173 | + |
| 174 | + await check('container and window clamps do not overwrite the preferred width', async () => { |
| 175 | + await page.getByRole('button', { name: 'Resize container' }).click() |
| 176 | + await expectWidth(520) |
| 177 | + await page.getByRole('button', { name: 'Resize container' }).click() |
| 178 | + await expectWidth(798) |
| 179 | + await shell.evaluate(({ BrowserWindow }) => |
| 180 | + BrowserWindow.getAllWindows()[0].setContentSize(1050, 800) |
| 181 | + ) |
| 182 | + await expectWidth(570) |
| 183 | + await page.getByRole('button', { name: 'Settings', exact: true }).click() |
| 184 | + await page.getByRole('button', { name: 'Back', exact: true }).click() |
| 185 | + await expectWidth(570) |
| 186 | + await shell.evaluate(({ BrowserWindow }) => |
| 187 | + BrowserWindow.getAllWindows()[0].setContentSize(1440, 800) |
| 188 | + ) |
| 189 | + await expectWidth(798) |
| 190 | + }) |
| 191 | + |
| 192 | + await check('resizing writes storage only when the gesture ends', async () => { |
| 193 | + const before = await page.evaluate(() => JSON.stringify(localStorage)) |
| 194 | + const rect = await panel.boundingBox() |
| 195 | + if (!rect) throw new Error('Missing panel bounds') |
| 196 | + await page.mouse.move(rect.x, rect.y + 100) |
| 197 | + await page.mouse.down() |
| 198 | + await expect |
| 199 | + .poll(() => divider.evaluate((element) => element.hasPointerCapture(1))) |
| 200 | + .toBe(true) |
| 201 | + await page.mouse.move(rect.x + 100, rect.y + 100, { steps: 12 }) |
| 202 | + await expectWidth(698) |
| 203 | + expect(await page.evaluate(() => JSON.stringify(localStorage))).toBe(before) |
| 204 | + await page.mouse.up() |
| 205 | + expect(await page.evaluate(() => JSON.stringify(localStorage))).not.toBe(before) |
| 206 | + }) |
| 207 | + |
| 208 | + await check('another account cannot inherit the current chat width', async () => { |
| 209 | + await page.getByRole('button', { name: 'Switch account' }).click() |
| 210 | + await expectWidth(720) |
| 211 | + await dragTo(580) |
| 212 | + await page.getByRole('button', { name: 'Switch account' }).click() |
| 213 | + await expectWidth(698) |
| 214 | + expect(errors).toEqual([]) |
| 215 | + }) |
| 216 | + passed = true |
| 217 | + } finally { |
| 218 | + mkdirSync(dirname(reportPath), { recursive: true }) |
| 219 | + writeFileSync(reportPath, JSON.stringify({ passed, checks }, null, 2)) |
| 220 | + await app?.close() |
| 221 | + await new Promise<void>((resolve) => server.close(() => resolve())) |
| 222 | + rmSync(userData, { recursive: true, force: true }) |
| 223 | + } |
| 224 | +}) |
0 commit comments