|
| 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 type { DesktopUpdateState } from '@sim/desktop-bridge' |
| 8 | +import { getErrorMessage } from '@sim/utils/errors' |
| 9 | +import { sleep } from '@sim/utils/helpers' |
| 10 | +import { build } from 'esbuild' |
| 11 | +import postcss from 'postcss' |
| 12 | +import loadPostcssConfig from 'postcss-load-config' |
| 13 | + |
| 14 | +const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url)) |
| 15 | +const SIM_DIR = fileURLToPath(new URL('../../sim/', import.meta.url)) |
| 16 | +const FIXTURE = fileURLToPath(new URL('./fixtures/update-notification.tsx', import.meta.url)) |
| 17 | + |
| 18 | +test('desktop update actions survive navigation without repeating dismissed or stale offers', async () => { |
| 19 | + const reportPath = |
| 20 | + process.env.DESKTOP_UPDATE_NOTICE_REPORT_PATH ?? test.info().outputPath('update-notice.json') |
| 21 | + const checks: { |
| 22 | + name: string |
| 23 | + status: 'passed' | 'failed' |
| 24 | + durationMs: number |
| 25 | + error?: string |
| 26 | + }[] = [] |
| 27 | + const check = async (name: string, run: () => Promise<void>) => { |
| 28 | + const started = Date.now() |
| 29 | + try { |
| 30 | + await test.step(name, run) |
| 31 | + checks.push({ name, status: 'passed', durationMs: Date.now() - started }) |
| 32 | + } catch (error) { |
| 33 | + checks.push({ |
| 34 | + name, |
| 35 | + status: 'failed', |
| 36 | + durationMs: Date.now() - started, |
| 37 | + error: getErrorMessage(error), |
| 38 | + }) |
| 39 | + throw error |
| 40 | + } |
| 41 | + } |
| 42 | + const userData = mkdtempSync(join(tmpdir(), 'sim-update-notice-e2e-')) |
| 43 | + let app: Awaited<ReturnType<typeof electron.launch>> | undefined |
| 44 | + let passed = false |
| 45 | + let javascript = '' |
| 46 | + let stylesheet = '' |
| 47 | + const server = createServer((request, response) => { |
| 48 | + const path = new URL(request.url ?? '/', 'http://localhost').pathname |
| 49 | + if (path === '/fixture.js' || path === '/fixture.css') { |
| 50 | + response.setHeader('Content-Type', path.endsWith('.js') ? 'text/javascript' : 'text/css') |
| 51 | + response.end(path.endsWith('.js') ? javascript : stylesheet) |
| 52 | + } else if (path.startsWith('/api/')) { |
| 53 | + response.setHeader('Content-Type', 'application/json') |
| 54 | + response.end('{}') |
| 55 | + } else { |
| 56 | + response.setHeader('Content-Type', 'text/html') |
| 57 | + response.end( |
| 58 | + '<!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>' |
| 59 | + ) |
| 60 | + } |
| 61 | + }) |
| 62 | + |
| 63 | + try { |
| 64 | + await check('load the production notification in Electron', async () => { |
| 65 | + const config = await loadPostcssConfig({}, SIM_DIR) |
| 66 | + const cssPath = join(SIM_DIR, 'app/_styles/globals.css') |
| 67 | + const css = await postcss(config.plugins).process( |
| 68 | + `${readFileSync(cssPath, 'utf8')}\n@source ${JSON.stringify(FIXTURE)};`, |
| 69 | + { from: cssPath } |
| 70 | + ) |
| 71 | + const bundle = await build({ |
| 72 | + stdin: { |
| 73 | + contents: `import { mountUpdateNotificationFixture } from ${JSON.stringify(FIXTURE)}; |
| 74 | +import { DesktopUpdateNotification } from '@/app/_shell/desktop-update-notification'; |
| 75 | +mountUpdateNotificationFixture(DesktopUpdateNotification);`, |
| 76 | + resolveDir: SIM_DIR, |
| 77 | + loader: 'tsx', |
| 78 | + }, |
| 79 | + bundle: true, |
| 80 | + write: false, |
| 81 | + outfile: test.info().outputPath('fixture.js'), |
| 82 | + external: ['node:async_hooks'], |
| 83 | + banner: { js: 'var process={env:{NODE_ENV:"development"},browser:true};' }, |
| 84 | + format: 'iife', |
| 85 | + platform: 'browser', |
| 86 | + tsconfig: join(SIM_DIR, 'tsconfig.json'), |
| 87 | + define: { 'process.env.NODE_ENV': '"development"' }, |
| 88 | + }) |
| 89 | + javascript = bundle.outputFiles.find((file) => file.path.endsWith('.js'))?.text ?? '' |
| 90 | + stylesheet = `${css.css}\n${bundle.outputFiles.find((file) => file.path.endsWith('.css'))?.text ?? ''}` |
| 91 | + await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)) |
| 92 | + const address = server.address() |
| 93 | + if (!address || typeof address === 'string') throw new Error('Missing fixture address') |
| 94 | + app = await electron.launch({ |
| 95 | + args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'], |
| 96 | + cwd: DESKTOP_DIR, |
| 97 | + env: { |
| 98 | + ...process.env, |
| 99 | + SIM_DESKTOP_ORIGIN: `http://127.0.0.1:${address.port}`, |
| 100 | + SIM_DESKTOP_USER_DATA: userData, |
| 101 | + }, |
| 102 | + }) |
| 103 | + }) |
| 104 | + if (!app) throw new Error('Electron did not launch') |
| 105 | + const shell = app |
| 106 | + const page = await shell.firstWindow() |
| 107 | + const errors: string[] = [] |
| 108 | + page.on('pageerror', (error) => errors.push(error.message)) |
| 109 | + await shell.evaluate(({ app, BrowserWindow }) => { |
| 110 | + const window = BrowserWindow.getAllWindows()[0] |
| 111 | + window.setContentSize(1000, 700) |
| 112 | + window.webContents.setBackgroundThrottling(false) |
| 113 | + app.focus({ steal: true }) |
| 114 | + window.focus() |
| 115 | + }) |
| 116 | + const publish = (state: DesktopUpdateState) => |
| 117 | + shell.evaluate(({ BrowserWindow }, next) => { |
| 118 | + BrowserWindow.getAllWindows()[0].webContents.send('desktop:updates:state', next) |
| 119 | + }, state) |
| 120 | + const notice = page.getByRole('list', { name: 'Notifications' }) |
| 121 | + const restart = notice.getByRole('button', { name: 'Restart to update' }) |
| 122 | + const download = notice.getByRole('button', { name: 'Download update' }) |
| 123 | + |
| 124 | + await check( |
| 125 | + 'initial snapshot survives StrictMode and remains actionable across navigation', |
| 126 | + async () => { |
| 127 | + await shell.evaluate(({ ipcMain }) => { |
| 128 | + ipcMain.removeHandler('desktop:updates:get-state') |
| 129 | + ipcMain.handle('desktop:updates:get-state', () => ({ status: 'ready', version: '2.0.0' })) |
| 130 | + }) |
| 131 | + await page.reload() |
| 132 | + await expect(restart).toHaveCount(1) |
| 133 | + await expect(restart).toBeVisible() |
| 134 | + await page.getByRole('button', { name: 'Switch workspace' }).click() |
| 135 | + await expect(page.getByLabel('Current route')).toHaveText('/workspace/second') |
| 136 | + await sleep(5_500) |
| 137 | + await expect(restart).toBeVisible() |
| 138 | + await page.screenshot({ |
| 139 | + path: test.info().outputPath('update-ready-dark.png'), |
| 140 | + animations: 'disabled', |
| 141 | + }) |
| 142 | + await page.evaluate(() => document.documentElement.classList.remove('dark')) |
| 143 | + await page.screenshot({ |
| 144 | + path: test.info().outputPath('update-ready-light.png'), |
| 145 | + animations: 'disabled', |
| 146 | + }) |
| 147 | + } |
| 148 | + ) |
| 149 | + |
| 150 | + await check('dismissal survives repeated events and checking the same release', async () => { |
| 151 | + await notice.getByRole('button', { name: 'Dismiss notification' }).click() |
| 152 | + await expect(restart).toHaveCount(0) |
| 153 | + await publish({ status: 'ready', version: '2.0.0' }) |
| 154 | + await publish({ status: 'checking', version: '2.0.0' }) |
| 155 | + await page.evaluate( |
| 156 | + () => |
| 157 | + new Promise<void>((resolve) => |
| 158 | + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) |
| 159 | + ) |
| 160 | + ) |
| 161 | + await publish({ status: 'ready', version: '2.0.0' }) |
| 162 | + await sleep(500) |
| 163 | + await expect(restart).toHaveCount(0) |
| 164 | + }) |
| 165 | + |
| 166 | + await check( |
| 167 | + 'a new release appears without stealing focus, and stale actions are withdrawn', |
| 168 | + async () => { |
| 169 | + const input = page.getByRole('textbox', { name: 'Work in progress' }) |
| 170 | + await input.fill('Unsaved work') |
| 171 | + await publish({ status: 'ready', version: '2.1.0' }) |
| 172 | + await expect(restart).toBeVisible() |
| 173 | + await expect(input).toBeFocused() |
| 174 | + for (const status of ['checking', 'downloading', 'error', 'idle'] as const) { |
| 175 | + await publish({ status, version: '2.1.0' }) |
| 176 | + await expect(restart).toHaveCount(0) |
| 177 | + await publish({ status: 'ready', version: '2.1.0' }) |
| 178 | + await expect(restart).toHaveCount(1) |
| 179 | + } |
| 180 | + await expect(input).toHaveValue('Unsaved work') |
| 181 | + } |
| 182 | + ) |
| 183 | + |
| 184 | + await check( |
| 185 | + 'a dismissed download offer can announce that the same release is ready', |
| 186 | + async () => { |
| 187 | + await publish({ status: 'available', version: '2.1.0' }) |
| 188 | + await expect(download).toBeVisible() |
| 189 | + await expect(restart).toHaveCount(0) |
| 190 | + await notice.getByRole('button', { name: 'Dismiss notification' }).click() |
| 191 | + await expect(download).toHaveCount(0) |
| 192 | + await publish({ status: 'ready', version: '2.1.0' }) |
| 193 | + await expect(restart).toBeVisible() |
| 194 | + } |
| 195 | + ) |
| 196 | + |
| 197 | + await check('actions reach the real preload IPC with the correct operation', async () => { |
| 198 | + for (const { state, action, channel } of [ |
| 199 | + { |
| 200 | + state: { status: 'ready', version: '2.1.0' }, |
| 201 | + action: restart, |
| 202 | + channel: 'desktop:updates:install', |
| 203 | + }, |
| 204 | + { |
| 205 | + state: { status: 'available', version: '2.2.0' }, |
| 206 | + action: download, |
| 207 | + channel: 'desktop:updates:check', |
| 208 | + }, |
| 209 | + { |
| 210 | + state: { status: 'available', version: '2.3.0', manual: true }, |
| 211 | + action: download, |
| 212 | + channel: 'desktop:updates:install', |
| 213 | + }, |
| 214 | + ] satisfies { state: DesktopUpdateState; action: typeof restart; channel: string }[]) { |
| 215 | + await publish(state) |
| 216 | + await expect(action).toBeVisible() |
| 217 | + const received = await shell.evaluateHandle(({ ipcMain }, name) => { |
| 218 | + const receipt: { url: string | null } = { url: null } |
| 219 | + ipcMain.once(name, (event) => { |
| 220 | + receipt.url = event.sender.getURL() |
| 221 | + }) |
| 222 | + return receipt |
| 223 | + }, channel) |
| 224 | + try { |
| 225 | + await action.click() |
| 226 | + await expect.poll(() => received.evaluate((receipt) => receipt.url)).toBe(page.url()) |
| 227 | + await expect(action).toHaveCount(0) |
| 228 | + } finally { |
| 229 | + await received.dispose() |
| 230 | + } |
| 231 | + } |
| 232 | + expect(errors).toEqual([]) |
| 233 | + }) |
| 234 | + passed = true |
| 235 | + } finally { |
| 236 | + mkdirSync(dirname(reportPath), { recursive: true }) |
| 237 | + writeFileSync(reportPath, JSON.stringify({ passed, checks }, null, 2)) |
| 238 | + await app?.close() |
| 239 | + await new Promise<void>((resolve) => server.close(() => resolve())) |
| 240 | + rmSync(userData, { recursive: true, force: true }) |
| 241 | + } |
| 242 | +}) |
0 commit comments