Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 4 additions & 0 deletions .github/workflows/desktop-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ on:
- '.github/workflows/desktop-e2e.yml'
- '.github/workflows/desktop-release.yml'
- 'apps/desktop/**'
- 'apps/sim/app/_shell/desktop-update-*.tsx'
Comment thread
waleedlatif1 marked this conversation as resolved.
- 'apps/sim/app/layout.tsx'
- 'apps/sim/hooks/use-desktop-update-state.ts'
- 'apps/sim/lib/desktop/**'
Comment thread
waleedlatif1 marked this conversation as resolved.
- 'apps/sim/app/workspace/**/browser-session/**'
- 'apps/sim/app/_styles/**'
- 'apps/sim/lib/postcss/**'
Expand Down
45 changes: 45 additions & 0 deletions apps/desktop/e2e/fixtures/update-notification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { type ComponentType, StrictMode, useState } from 'react'
import { Chip, ChipInput, ToastProvider, toast } from '@sim/emcn'
Comment thread
waleedlatif1 marked this conversation as resolved.
import { PathnameContext } from 'next/dist/shared/lib/hooks-client-context.shared-runtime'
import { createRoot } from 'react-dom/client'

interface UpdateNotificationFixtureProps {
Notification: ComponentType
}

function UpdateNotificationFixture({ Notification }: UpdateNotificationFixtureProps) {
const [pathname, setPathname] = useState('/workspace/first')
return (
<PathnameContext.Provider value={pathname}>
<ToastProvider>
<Notification />
<main className='flex flex-col gap-[16px] p-[48px]'>
<ChipInput aria-label='Work in progress' placeholder='Keep working' />
<Chip onClick={() => setPathname('/workspace/second')}>Switch workspace</Chip>
<Chip
onClick={() => {
for (let index = 0; index < 3; index++) {
toast({ message: `Background task ${index + 1}`, duration: 0 })
}
}}
>
Fill notification stack
</Chip>
<Chip onClick={() => toast.dismissAll()}>Clear notifications</Chip>
<output aria-label='Current route'>{pathname}</output>
</main>
</ToastProvider>
</PathnameContext.Provider>
)
}

/** Supplies the production notification through Sim's module aliases. */
export function mountUpdateNotificationFixture(Notification: ComponentType) {
const root = document.getElementById('root')
if (!root) throw new Error('Missing fixture root')
createRoot(root).render(
<StrictMode>
<UpdateNotificationFixture Notification={Notification} />
</StrictMode>
)
}
3 changes: 3 additions & 0 deletions apps/desktop/e2e/password-autofill.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,9 @@ test.describe('saved password autofill', () => {
await expect
.poll(() => pageScript('document.getElementById("user").value'))
.toBe('account9@example.test')
await expect
.poll(() => app.windows().some((page) => page.url().includes('credential-picker.html')))
.toBe(false)
await clickField()
await picker()
await pickerKey('Escape')
Expand Down
262 changes: 262 additions & 0 deletions apps/desktop/e2e/update-notification.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { createServer } from 'node:http'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { _electron as electron, expect, test } from '@playwright/test'
import type { DesktopUpdateState } from '@sim/desktop-bridge'
import { getErrorMessage } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
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/update-notification.tsx', import.meta.url))

test('desktop update actions survive navigation without repeating dismissed or stale offers', async () => {
const reportPath =
process.env.DESKTOP_UPDATE_NOTICE_REPORT_PATH ?? test.info().outputPath('update-notice.json')
const checks: {
name: string
status: 'passed' | 'failed'
durationMs: number
error?: string
}[] = []
const check = async (name: string, run: () => Promise<void>) => {
const started = Date.now()
try {
await test.step(name, run)
checks.push({ name, status: 'passed', durationMs: Date.now() - started })
} catch (error) {
checks.push({
name,
status: 'failed',
durationMs: Date.now() - started,
error: getErrorMessage(error),
})
throw error
}
}
const userData = mkdtempSync(join(tmpdir(), 'sim-update-notice-e2e-'))
let app: Awaited<ReturnType<typeof electron.launch>> | undefined
let passed = false
let javascript = ''
let stylesheet = ''
const 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') ? javascript : stylesheet)
} else if (path.startsWith('/api/')) {
response.setHeader('Content-Type', 'application/json')
response.end('{}')
} else {
response.setHeader('Content-Type', 'text/html')
response.end(
'<!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>'
)
}
})

try {
await check('load the production notification in Electron', async () => {
const config = await loadPostcssConfig({}, SIM_DIR)
const cssPath = join(SIM_DIR, 'app/_styles/globals.css')
const css = await postcss(config.plugins).process(
`${readFileSync(cssPath, 'utf8')}\n@source ${JSON.stringify(FIXTURE)};`,
{ from: cssPath }
)
const bundle = await build({
stdin: {
contents: `import { mountUpdateNotificationFixture } from ${JSON.stringify(FIXTURE)};
import { DesktopUpdateNotification } from '@/app/_shell/desktop-update-notification';
mountUpdateNotificationFixture(DesktopUpdateNotification);`,
resolveDir: SIM_DIR,
loader: 'tsx',
},
bundle: true,
write: false,
outfile: test.info().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"' },
})
javascript = bundle.outputFiles.find((file) => file.path.endsWith('.js'))?.text ?? ''
stylesheet = `${css.css}\n${bundle.outputFiles.find((file) => file.path.endsWith('.css'))?.text ?? ''}`
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing fixture address')
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,
},
})
})
if (!app) throw new Error('Electron did not launch')
const shell = app
const page = await shell.firstWindow()
const errors: string[] = []
page.on('pageerror', (error) => errors.push(error.message))
await shell.evaluate(({ app, BrowserWindow }) => {
const window = BrowserWindow.getAllWindows()[0]
window.setContentSize(1000, 700)
window.webContents.setBackgroundThrottling(false)
app.focus({ steal: true })
window.focus()
})
const publish = (state: DesktopUpdateState) =>
shell.evaluate(({ BrowserWindow }, next) => {
BrowserWindow.getAllWindows()[0].webContents.send('desktop:updates:state', next)
}, state)
const notice = page.getByRole('list', { name: 'Notifications' })
const restart = notice.getByRole('button', { name: 'Restart to update' })
const download = notice.getByRole('button', { name: 'Download update' })

await check(
'initial snapshot survives StrictMode and remains actionable across navigation',
async () => {
await shell.evaluate(({ ipcMain }) => {
ipcMain.removeHandler('desktop:updates:get-state')
ipcMain.handle('desktop:updates:get-state', () => ({ status: 'ready', version: '2.0.0' }))
})
await page.reload()
await expect(restart).toHaveCount(1)
await expect(restart).toBeVisible()
await page.getByRole('button', { name: 'Switch workspace' }).click()
await expect(page.getByLabel('Current route')).toHaveText('/workspace/second')
await sleep(5_500)
await expect(restart).toBeVisible()
await page.screenshot({
path: test.info().outputPath('update-ready-dark.png'),
animations: 'disabled',
})
await page.evaluate(() => document.documentElement.classList.remove('dark'))
await page.screenshot({
path: test.info().outputPath('update-ready-light.png'),
animations: 'disabled',
})
}
)

await check('dismissal survives repeated events and checking the same release', async () => {
await notice.getByRole('button', { name: 'Dismiss notification' }).click()
await expect(restart).toHaveCount(0)
await publish({ status: 'ready', version: '2.0.0' })
await publish({ status: 'checking', version: '2.0.0' })
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
)
)
await publish({ status: 'ready', version: '2.0.0' })
await sleep(500)
await expect(restart).toHaveCount(0)
})

await check(
'a new release appears without stealing focus, and stale actions are withdrawn',
async () => {
const input = page.getByRole('textbox', { name: 'Work in progress' })
await input.fill('Unsaved work')
await publish({ status: 'ready', version: '2.1.0' })
await expect(restart).toBeVisible()
await expect(input).toBeFocused()
for (const status of ['checking', 'downloading', 'error', 'idle'] as const) {
await publish({ status, version: '2.1.0' })
await expect(restart).toHaveCount(0)
await publish({ status: 'ready', version: '2.1.0' })
await expect(restart).toHaveCount(1)
}
await expect(input).toHaveValue('Unsaved work')
}
)

await check(
'a dismissed download offer can announce that the same release is ready',
async () => {
await publish({ status: 'available', version: '2.1.0' })
await expect(download).toBeVisible()
await expect(restart).toHaveCount(0)
await notice.getByRole('button', { name: 'Dismiss notification' }).click()
await expect(download).toHaveCount(0)
await publish({ status: 'ready', version: '2.1.0' })
await expect(restart).toBeVisible()
}
)

await check('stack eviction does not count as dismissing the update offer', async () => {
await publish({ status: 'checking', version: '2.4.0' })
await expect(restart).toHaveCount(0)
await publish({ status: 'ready', version: '2.4.0' })
await expect(restart).toHaveCount(1)
await page.getByRole('button', { name: 'Fill notification stack' }).click()
await expect(restart).toHaveCount(0)
await page.getByRole('button', { name: 'Clear notifications' }).click()
await expect(notice).toHaveCount(0)
await publish({ status: 'checking', version: '2.4.0' })
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
)
)
await publish({ status: 'ready', version: '2.4.0' })
await expect(restart).toBeVisible()
})

await check('actions reach the real preload IPC with the correct operation', async () => {
for (const { state, action, channel } of [
{
state: { status: 'ready', version: '2.4.0' },
action: restart,
channel: 'desktop:updates:install',
},
{
state: { status: 'available', version: '2.5.0' },
action: download,
channel: 'desktop:updates:check',
},
{
state: { status: 'available', version: '2.6.0', manual: true },
action: download,
channel: 'desktop:updates:install',
},
] satisfies { state: DesktopUpdateState; action: typeof restart; channel: string }[]) {
await publish(state)
await expect(action).toBeVisible()
const received = await shell.evaluateHandle(({ ipcMain }, name) => {
const receipt: { url: string | null } = { url: null }
ipcMain.once(name, (event) => {
receipt.url = event.sender.getURL()
})
return receipt
}, channel)
try {
await action.click()
await expect.poll(() => received.evaluate((receipt) => receipt.url)).toBe(page.url())
await expect(action).toHaveCount(0)
} finally {
await received.dispose()
}
}
expect(errors).toEqual([])
})
passed = true
} finally {
mkdirSync(dirname(reportPath), { recursive: true })
writeFileSync(reportPath, JSON.stringify({ passed, checks }, null, 2))
await app?.close()
await new Promise<void>((resolve) => server.close(() => resolve()))
rmSync(userData, { recursive: true, force: true })
}
})
58 changes: 58 additions & 0 deletions apps/sim/app/_shell/desktop-update-notification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
'use client'

import { useEffect, useRef } from 'react'
import { useToast } from '@sim/emcn'
import { getDesktopShellVersion, getDesktopUpdates } from '@/lib/desktop'
import { isShellOutdated } from '@/lib/desktop/min-version'
import { useDesktopUpdateState } from '@/hooks/use-desktop-update-state'

interface DesktopUpdateNotificationProps {
[key: string]: never
}

/** Keeps optional desktop updates actionable across routes for the current window session. */
export function DesktopUpdateNotification(_props: DesktopUpdateNotificationProps) {
const { status, version, manual } = useDesktopUpdateState()
const { toast, dismiss } = useToast()
const dismissedOffer = useRef<string | null>(null)

useEffect(() => {
const updates = getDesktopUpdates()
if (
!updates ||
isShellOutdated(getDesktopShellVersion()) ||
(status !== 'available' && status !== 'ready')
) {
return
}

const offer = `${version ?? ''}:${status}:${Boolean(manual)}`
if (dismissedOffer.current === offer) return

let active = true
const id = toast({
message:
status === 'ready' ? 'A Sim update is ready to install' : 'A Sim update is available',
action: {
label: status === 'ready' ? 'Restart to update' : 'Download update',
onClick: () => {
if (!active) return
if (status === 'ready' || manual) updates.install()
else updates.check()
},
},
persistAcrossRoutes: true,
onUserDismiss: () => {
if (active) dismissedOffer.current = offer
Comment thread
waleedlatif1 marked this conversation as resolved.
},
})

return () => {
// Ignore clicks while a withdrawn toast finishes its exit animation.
active = false
dismiss(id)
}
}, [status, version, manual, toast, dismiss])

return null
}
Loading
Loading