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
84 changes: 81 additions & 3 deletions apps/desktop/e2e/fixtures/updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,54 @@ import { writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join, relative } from 'node:path'
import type { DesktopUpdateState } from '@sim/desktop-bridge'
import { app, autoUpdater as nativeUpdater, net } from 'electron'
import { app, BrowserWindow, autoUpdater as nativeUpdater, net } from 'electron'
import { MacUpdater } from 'electron-updater'
import { countWindowsWithUnsavedChanges, describeInFlightWork } from '@/main/in-flight-work'
import { initUpdater } from '@/main/updater'

/** What a window's page does on `beforeunload`, shaped like the web app's own listeners. */
type FixtureUnloadListener = 'none' | 'passive' | 'unsaved-edits'

type FixtureDialog = Pick<
Electron.MessageBoxOptions,
'title' | 'message' | 'buttons' | 'defaultId' | 'cancelId'
>

declare global {
var desktopUpdaterFixture: {
check(): void
install(): void
finishStaging(): void
failStaging(): void
setWork(work: {
terminalCommands: string[]
browserToolRunning: boolean
unloadListener: FixtureUnloadListener
/** Adds a second window whose renderer is blocked past the shell's probe deadline. */
hungWindow?: boolean
}): Promise<void>
read(): {
state: DesktopUpdateState
nativeArchive: string | null
installed: string[]
events: { name: string; data: unknown }[]
dialogs: FixtureDialog[]
}
}
/** Filled by the spec's `showShellDialog` stub, which answers with `updaterFixtureDialogResponse`. */
var updaterFixtureDialogs: FixtureDialog[]
var updaterFixtureDialogResponse: number
}

const directory = process.env.SIM_UPDATER_FIXTURE_DIR
const origin = process.env.SIM_UPDATER_FIXTURE_ORIGIN
if (!directory || !origin) throw new Error('Updater fixture configuration is missing')
app.setPath('userData', join(directory, 'user-data'))

void app.whenReady().then(() => {
globalThis.updaterFixtureDialogs = []
globalThis.updaterFixtureDialogResponse = 0

void app.whenReady().then(async () => {
const configPath = join(directory, 'updater.yml')
const cache = relative(join(homedir(), 'Library', 'Caches'), join(directory, 'cache'))
writeFileSync(configPath, `updaterCacheDirName: ${JSON.stringify(cache)}\n`)
Expand Down Expand Up @@ -55,6 +78,14 @@ void app.whenReady().then(() => {
installed.push(nativeArchive)
}

// A real renderer, so the unsaved-edits probe runs against Chromium's own event dispatch.
const page = new BrowserWindow({ show: false })
page.webContents.on('will-prevent-unload', (event) => event.preventDefault())
await page.loadURL('about:blank')
let terminalCommands: string[] = []
let browserToolRunning = false
let hungPage: BrowserWindow | null = null

const updater = new MacUpdater()
updater.forceDevUpdateConfig = true
updater.disableDifferentialDownload = true
Expand All @@ -68,6 +99,16 @@ void app.whenReady().then(() => {
canSelfUpdate: async () => true,
probeOriginFeed: async () => false,
installStatePath: join(directory, 'update-install.json'),
describeInFlightWork: async () =>
describeInFlightWork({
terminalCommands,
computerUseApp: null,
browserToolRunning,
agentWorkRunning: false,
...(await countWindowsWithUnsavedChanges(
hungPage ? [page.webContents, hungPage.webContents] : [page.webContents]
)),
}),
})

globalThis.desktopUpdaterFixture = {
Expand All @@ -78,6 +119,43 @@ void app.whenReady().then(() => {
nativeUpdater.emit('update-downloaded', {}, '', nativeArchive, new Date(), '')
},
failStaging: () => nativeUpdater.emit('error', new Error('Native verification failed')),
read: () => ({ state: handle.getState(), nativeArchive, installed, events }),
setWork: async (work) => {
terminalCommands = work.terminalCommands
browserToolRunning = work.browserToolRunning
if (hungPage) {
// Let the blocked renderer finish first; destroying it mid-loop stalls the app's exit.
await hungPage.webContents.executeJavaScript('0')
hungPage.destroy()
hungPage = null
}
if (work.hungWindow) {
hungPage = new BrowserWindow({ show: false })
await hungPage.loadURL('about:blank')
await hungPage.webContents.executeJavaScript(`window.addEventListener('beforeunload', () => {
const until = Date.now() + 1500
while (Date.now() < until) {}
})`)
}
await page.webContents.executeJavaScript(`(() => {
for (const listener of window.fixtureUnloadListeners ?? []) {
window.removeEventListener('beforeunload', listener)
}
window.fixtureUnloadListeners = []
const kind = ${JSON.stringify(work.unloadListener)}
if (kind === 'none') return
// 'passive' runs on unload without holding the page, like the chat's handoff cleanup.
const listener =
kind === 'unsaved-edits' ? (event) => event.preventDefault() : () => {}
window.addEventListener('beforeunload', listener)
window.fixtureUnloadListeners.push(listener)
})()`)
},
read: () => ({
state: handle.getState(),
nativeArchive,
installed,
events,
dialogs: globalThis.updaterFixtureDialogs,
}),
}
})
89 changes: 81 additions & 8 deletions apps/desktop/e2e/updater.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { build } from 'esbuild'

const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url))

test('the real MacUpdater waits for native staging, replaces old builds, and retries failed staging', async () => {
test('the real MacUpdater waits for native staging, replaces old builds, retries failed staging, and restarts on one click', async () => {
test.skip(process.platform !== 'darwin', 'Squirrel.Mac lifecycle')
const directory = mkdtempSync(join(tmpdir(), 'sim-updater-e2e-'))
const reportPath =
Expand Down Expand Up @@ -40,6 +40,7 @@ test('the real MacUpdater waits for native staging, replaces old builds, and ret
}
}
let snapshot: unknown
let completed = false
const server = createServer((request, response) => {
const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname
requests.push({ path, status: 200 })
Expand Down Expand Up @@ -72,15 +73,18 @@ test('the real MacUpdater waits for native staging, replaces old builds, and ret
tsconfig: join(DESKTOP_DIR, 'tsconfig.json'),
plugins: [
{
name: 'approve-fixture-restart',
name: 'record-fixture-dialogs',
setup(builder) {
builder.onResolve({ filter: /^@\/main\/dialogs$/ }, () => ({
path: 'dialog',
namespace: 'fixture',
}))
builder.onLoad({ filter: /.*/, namespace: 'fixture' }, () => ({
contents:
'export async function showShellDialog() { return { response: 1, checkboxChecked: false } }',
contents: `export async function showShellDialog(...args) {
const { title, message, buttons, defaultId, cancelId } = args.at(-1)
globalThis.updaterFixtureDialogs.push({ title, message, buttons, defaultId, cancelId })
return { response: globalThis.updaterFixtureDialogResponse, checkboxChecked: false }
}`,
}))
},
},
Expand Down Expand Up @@ -129,17 +133,85 @@ test('the real MacUpdater waits for native staging, replaces old builds, and ret
expect((await read()).state).toEqual({ status: 'error', version: '2.1.0' })
})

await check('cached retry installs only the verified replacement', async () => {
await check('cached retry stages only the verified replacement', async () => {
await shell.evaluate(() => globalThis.desktopUpdaterFixture.check())
await expect
.poll(async () => (await read()).state)
.toEqual({ status: 'downloading', version: '2.1.0', percent: 100 })
await expect.poll(async () => (await read()).nativeArchive).toBe('2.1.0')
await shell.evaluate(() => globalThis.desktopUpdaterFixture.finishStaging())
expect((await read()).state).toEqual({ status: 'ready', version: '2.1.0' })
await shell.evaluate(() => globalThis.desktopUpdaterFixture.install())
await expect.poll(async () => (await read()).installed).toEqual(['2.1.0'])
})

await check(
'restart asks only about real in-flight work, then installs on one click',
async () => {
/** Sets the work in progress, clicks Restart to update, and answers any prompt with `response`. */
const installWith = async (
work: {
terminalCommands?: string[]
browserToolRunning?: boolean
unloadListener: 'passive' | 'unsaved-edits'
hungWindow?: boolean
},
response: number
) => {
await shell.evaluate(
async (
_electron,
[{ terminalCommands = [], browserToolRunning = false, ...rest }, answer]
) => {
await globalThis.desktopUpdaterFixture.setWork({
terminalCommands,
browserToolRunning,
...rest,
})
globalThis.updaterFixtureDialogResponse = answer
globalThis.desktopUpdaterFixture.install()
},
[work, response] as const
)
}
const expectNotNowPrompt = async (count: number, message: string) => {
await expect.poll(async () => (await read()).dialogs.length).toBe(count)
expect((await read()).dialogs.at(-1)).toEqual({
title: 'Restart to install Sim 2.1.0?',
message,
buttons: ['Restart and Update', 'Not Now'],
defaultId: 0,
cancelId: 1,
})
// The decline settles asynchronously; nothing may install, and the update stays ready.
await expect
.poll(async () => (await read()).state)
.toEqual({ status: 'ready', version: '2.1.0' })
expect((await read()).installed).toEqual([])
}

await installWith(
{ terminalCommands: ['cd /repo && claude "fix the build"'], unloadListener: 'passive' },
1
)
await expectNotNowPrompt(1, 'claude is still running in a terminal.')

await installWith({ unloadListener: 'unsaved-edits' }, 1)
await expectNotNowPrompt(2, 'A window has unsaved changes.')

await installWith(
{ browserToolRunning: true, unloadListener: 'passive', hungWindow: true },
1
)
await expectNotNowPrompt(
3,
"An agent is using the browser. A window isn't responding and may have unsaved changes."
)

// Answering Not Now again would leave nothing installed if a prompt appeared.
await installWith({ unloadListener: 'passive' }, 1)
await expect.poll(async () => (await read()).installed).toEqual(['2.1.0'])
expect((await read()).dialogs).toHaveLength(3)
}
)
snapshot = await read()
await check(
'the next process distinguishes an incomplete update from a successful install',
Expand Down Expand Up @@ -182,6 +254,7 @@ test('the real MacUpdater waits for native staging, replaces old builds, and ret
})
}
)
completed = true
} finally {
if (!snapshot && app)
snapshot = await app
Expand All @@ -192,7 +265,7 @@ test('the real MacUpdater waits for native staging, replaces old builds, and ret
reportPath,
JSON.stringify(
{
passed: checks.length === 4 && checks.every((check) => check.status === 'passed'),
passed: completed && checks.every((check) => check.status === 'passed'),
checks,
requests,
snapshot,
Expand Down
25 changes: 25 additions & 0 deletions apps/desktop/src/main/browser-agent/driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1150,6 +1150,31 @@ describe('executeTool', () => {
}
})

it('reports a browser action as running from queueing until it settles', async () => {
vi.useFakeTimers()
try {
await driver.executeTool('chat-test', 'browser_open_tab', {})
expect(driver.isBrowserToolRunning()).toBe(false)
const queued = driver.captureBrowserToolQueueBoundary('chat-test')
expect(driver.isBrowserToolRunning()).toBe(true)
if (queued) driver.releaseBrowserToolQueueBoundary(queued)
expect(driver.isBrowserToolRunning()).toBe(false)
const contents = session.requireTab().view.webContents
vi.mocked(contents.getURL).mockReturnValue('https://example.com/')
vi.mocked(contents.executeJavaScript).mockImplementation(() => new Promise<never>(() => {}))

const held = driver.executeTool('chat-test', 'browser_snapshot', {}, 'held-running')
await vi.advanceTimersByTimeAsync(0)
expect(driver.isBrowserToolRunning()).toBe(true)

driver.cancelTool('chat-test', 'held-running')
await held
expect(driver.isBrowserToolRunning()).toBe(false)
} finally {
vi.useRealTimers()
}
})

it('sanitizes hostile tab titles before returning them across the tool boundary', async () => {
await driver.executeTool('chat-test', 'browser_open_tab', {})
const contents = session.requireTab().view.webContents
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/main/browser-agent/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,11 @@ function releaseBrowserToolAdmission(state: DriverScopeState, admission: symbol)
activeBrowserToolAdmissions.delete(admission)
}

/** Whether any chat has a browser action queued or running in this process. */
export function isBrowserToolRunning(): boolean {
return activeBrowserToolAdmissions.size > 0 || pendingBrowserToolQueueBoundaries.size > 0
}

function retireDriverScopeState(state: DriverScopeState): void {
state.disposed = true
state.toolQueueCancellationEpoch++
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ export interface DesktopSettings {
windowBounds?: WindowBounds
zoomLevel?: number
lastRoute?: string
/** Routes of the other app windows open when an update relaunch was committed. */
updateRelaunchRoutes?: string[]
themeBackground?: 'dark' | 'light'
blockThirdPartyAnalytics?: boolean
trayEnabled?: boolean
Expand Down
26 changes: 26 additions & 0 deletions apps/desktop/src/main/in-flight-work.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { WebContents } from 'electron'
import { describe, expect, it, vi } from 'vitest'
import { countWindowsWithUnsavedChanges } from '@/main/in-flight-work'

vi.mock('electron', () => import('@/test/electron-mock'))

function windowAnswering(answer: () => Promise<unknown>, state = { destroyed: false }) {
return {
isDestroyed: () => state.destroyed,
isCrashed: () => false,
executeJavaScript: vi.fn(answer),
} as unknown as WebContents
}

describe('countWindowsWithUnsavedChanges', () => {
it('never reports a window it could not ask as clean', async () => {
const counts = await countWindowsWithUnsavedChanges([
windowAnswering(() => Promise.resolve(true)),
windowAnswering(() => Promise.resolve(false)),
windowAnswering(() => Promise.reject(new Error('Script failed to execute'))),
windowAnswering(() => Promise.resolve(true), { destroyed: true }),
])

expect(counts).toEqual({ unsavedWindowCount: 1, unresponsiveWindowCount: 1 })
})
})
Loading
Loading