Skip to content

Commit d2d230d

Browse files
committed
fix(browser): stage agent downloads synchronously so Electron never opens a Save dialog
The save path was set only after an asynchronous non-conflicting-name lookup. When Electron's own path step won that race it opened a native Save dialog, so an agent download stalled forever at full size (and a user would see a surprise dialog). Downloads now write to a hidden staging file set during will-download and are renamed to the allocated name when they complete.
1 parent 8eea2d0 commit d2d230d

2 files changed

Lines changed: 327 additions & 92 deletions

File tree

‎apps/desktop/src/main/browser-agent/session.test.ts‎

Lines changed: 200 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { mkdtempSync, writeFileSync } from 'node:fs'
1+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs'
22
import { tmpdir } from 'node:os'
3-
import { join } from 'node:path'
3+
import { basename, dirname, join } from 'node:path'
44
import type { MenuItemConstructorOptions, WebContents } from 'electron'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
66

@@ -304,11 +304,32 @@ function mockDownloadItem({
304304
const handler = item.once.mock.calls.find(([eventName]) => eventName === 'done')?.[1] as
305305
| ((event: unknown, nextState: MockDownloadDoneState) => void)
306306
| undefined
307+
const savePath = item.setSavePath.mock.calls.at(-1)?.[0] as string | undefined
308+
if (state === 'completed' && savePath) writeFileSync(savePath, filename)
307309
handler?.({}, state)
308310
},
309311
}
310312
}
311313

314+
/** Asserts Electron was only ever given the hidden staging file in `directory`. */
315+
function expectOnlyStagingSavePath(
316+
item: { setSavePath: ReturnType<typeof vi.fn> },
317+
directory: string
318+
) {
319+
expect(item.setSavePath).toHaveBeenCalledOnce()
320+
const savePath = item.setSavePath.mock.calls[0]?.[0] as string
321+
expect(dirname(savePath)).toBe(directory)
322+
expect(basename(savePath)).toMatch(/^\.sim-download-/)
323+
return savePath
324+
}
325+
326+
/** Visible files in a download directory, excluding in-flight staging files. */
327+
function finishedDownloadFiles(directory: string): string[] {
328+
return readdirSync(directory)
329+
.filter((name) => !name.startsWith('.'))
330+
.sort()
331+
}
332+
312333
function startMockDownload(contents: MockView['webContents'], download: MockDownloadHarness): void {
313334
const webSession = contents.session as typeof contents.session & {
314335
on: ReturnType<typeof vi.fn>
@@ -3357,7 +3378,7 @@ describe('browser-agent session', () => {
33573378
expect(item.resume).not.toHaveBeenCalled()
33583379
await vi.waitFor(() => expect(item.resume).toHaveBeenCalledOnce())
33593380
expect(item.cancel).not.toHaveBeenCalled()
3360-
expect(item.setSavePath).toHaveBeenCalledWith(join(directory, 'report.csv'))
3381+
const stagingPath = expectOnlyStagingSavePath(item, directory)
33613382
expect(item.once).toHaveBeenCalledWith('done', expect.any(Function))
33623383
expect(onDownloadsChanged).toHaveBeenLastCalledWith({
33633384
scopeId: 'chat-test',
@@ -3375,13 +3396,17 @@ describe('browser-agent session', () => {
33753396
const done = item.once.mock.calls.find(([eventName]) => eventName === 'done')?.[1] as
33763397
| ((event: unknown, state: 'completed') => void)
33773398
| undefined
3378-
writeFileSync(join(directory, 'report.csv'), 'report')
3399+
writeFileSync(stagingPath, 'report')
33793400
done?.({}, 'completed')
33803401

3381-
expect(session.getBrowserDownloadsState('chat-test').downloads[0]).toMatchObject({
3382-
filename: 'report.csv',
3383-
state: 'completed',
3384-
})
3402+
await vi.waitFor(() =>
3403+
expect(session.getBrowserDownloadsState('chat-test').downloads[0]).toMatchObject({
3404+
filename: 'report.csv',
3405+
state: 'completed',
3406+
})
3407+
)
3408+
expect(existsSync(stagingPath)).toBe(false)
3409+
expect(finishedDownloadFiles(directory)).toEqual(['report.csv'])
33853410
expect(snapshots.get('chat-test')?.downloads[0]).toMatchObject({
33863411
filename: 'report.csv',
33873412
state: 'completed',
@@ -3410,6 +3435,131 @@ describe('browser-agent session', () => {
34103435
})
34113436
})
34123437

3438+
it('sets a staging save path before will-download returns so Electron never prompts', () => {
3439+
const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
3440+
session = freshSession(win, {}, undefined, {
3441+
getDirectory: () => directory,
3442+
getFreeDiskBytes: () => Number.MAX_SAFE_INTEGER,
3443+
})
3444+
const contents = (session.ensureTab().view as unknown as MockView).webContents
3445+
const download = mockDownloadItem({ filename: 'report.csv', totalBytes: 100 })
3446+
3447+
startMockDownload(contents, download)
3448+
3449+
const stagingPath = expectOnlyStagingSavePath(download.item, directory)
3450+
expect(stagingPath).not.toBe(join(directory, 'report.csv'))
3451+
expect(download.item.cancel).not.toHaveBeenCalled()
3452+
})
3453+
3454+
it('waits for a slow filename allocation before publishing the completed file', async () => {
3455+
const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
3456+
const { persistence, snapshots } = memoryBrowserPersistence()
3457+
const pathProbe = deferred<boolean>()
3458+
session = freshSession(win, {}, persistence, {
3459+
getDirectory: () => directory,
3460+
getFreeDiskBytes: () => Number.MAX_SAFE_INTEGER,
3461+
pathExists: () => pathProbe.promise,
3462+
})
3463+
const contents = (session.ensureTab().view as unknown as MockView).webContents
3464+
const download = mockDownloadItem({ filename: 'slow.bin', totalBytes: 100 })
3465+
3466+
startMockDownload(contents, download)
3467+
const stagingPath = expectOnlyStagingSavePath(download.item, directory)
3468+
download.emitDone('completed')
3469+
await Promise.resolve()
3470+
3471+
expect(session.getBrowserDownloadsState('chat-test').downloads[0]).toMatchObject({
3472+
filename: 'slow.bin',
3473+
state: 'progressing',
3474+
})
3475+
expect(existsSync(stagingPath)).toBe(true)
3476+
3477+
pathProbe.resolve(false)
3478+
3479+
await vi.waitFor(() =>
3480+
expect(session.getBrowserDownloadsState('chat-test').downloads[0]).toMatchObject({
3481+
filename: 'slow.bin',
3482+
state: 'completed',
3483+
})
3484+
)
3485+
expect(existsSync(stagingPath)).toBe(false)
3486+
expect(finishedDownloadFiles(directory)).toEqual(['slow.bin'])
3487+
const { id } = session.getBrowserDownloadsState('chat-test').downloads[0]
3488+
expect(session.completedBrowserDownload('chat-test', id)).toEqual({
3489+
filename: 'slow.bin',
3490+
savePath: join(directory, 'slow.bin'),
3491+
})
3492+
expect(snapshots.get('chat-test')?.downloads[0]).toMatchObject({
3493+
state: 'completed',
3494+
savePath: join(directory, 'slow.bin'),
3495+
})
3496+
})
3497+
3498+
it('interrupts a completed download whose staging file cannot be moved into place', async () => {
3499+
const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
3500+
session = freshSession(win, {}, undefined, {
3501+
getDirectory: () => directory,
3502+
getFreeDiskBytes: () => Number.MAX_SAFE_INTEGER,
3503+
})
3504+
const contents = (session.ensureTab().view as unknown as MockView).webContents
3505+
const download = mockDownloadItem({ filename: 'blocked.bin', totalBytes: 100 })
3506+
3507+
startMockDownload(contents, download)
3508+
const stagingPath = expectOnlyStagingSavePath(download.item, directory)
3509+
await vi.waitFor(() => expect(download.item.resume).toHaveBeenCalledOnce())
3510+
mkdirSync(join(directory, 'blocked.bin'))
3511+
download.emitDone('completed')
3512+
3513+
await vi.waitFor(() =>
3514+
expect(session.getBrowserDownloadsState('chat-test').downloads[0]).toMatchObject({
3515+
filename: 'blocked.bin',
3516+
state: 'interrupted',
3517+
})
3518+
)
3519+
await vi.waitFor(() => expect(existsSync(stagingPath)).toBe(false))
3520+
const { id } = session.getBrowserDownloadsState('chat-test').downloads[0]
3521+
expect(session.completedBrowserDownload('chat-test', id)).toBeNull()
3522+
3523+
vi.mocked(Menu.buildFromTemplate).mockClear()
3524+
session.showBrowserDownloadsMenu('chat-test', win, { x: 10, y: 20 })
3525+
const template = vi.mocked(Menu.buildFromTemplate).mock.calls[0]?.[0] as
3526+
| MenuItemConstructorOptions[]
3527+
| undefined
3528+
expect(template?.[0]?.sublabel).toContain(
3529+
'the finished download could not be moved to its destination'
3530+
)
3531+
})
3532+
3533+
it('removes the staging file when a download is cancelled or interrupted', async () => {
3534+
const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
3535+
session = freshSession(win, {}, undefined, {
3536+
getDirectory: () => directory,
3537+
getFreeDiskBytes: () => Number.MAX_SAFE_INTEGER,
3538+
})
3539+
const contents = (session.ensureTab().view as unknown as MockView).webContents
3540+
const cancelled = mockDownloadItem({ filename: 'cancelled.bin', totalBytes: 100 })
3541+
const interrupted = mockDownloadItem({ filename: 'interrupted.bin', totalBytes: 100 })
3542+
3543+
startMockDownload(contents, cancelled)
3544+
startMockDownload(contents, interrupted)
3545+
await vi.waitFor(() => expect(cancelled.item.resume).toHaveBeenCalledOnce())
3546+
await vi.waitFor(() => expect(interrupted.item.resume).toHaveBeenCalledOnce())
3547+
const stagingPaths = [
3548+
expectOnlyStagingSavePath(cancelled.item, directory),
3549+
expectOnlyStagingSavePath(interrupted.item, directory),
3550+
]
3551+
for (const stagingPath of stagingPaths) writeFileSync(stagingPath, 'partial')
3552+
3553+
cancelled.emitDone('cancelled')
3554+
interrupted.emitDone('interrupted')
3555+
3556+
await vi.waitFor(() => expect(readdirSync(directory)).toEqual([]))
3557+
expect(session.getBrowserDownloadsState('chat-test').downloads).toEqual([
3558+
expect.objectContaining({ filename: 'interrupted.bin', state: 'interrupted' }),
3559+
expect.objectContaining({ filename: 'cancelled.bin', state: 'cancelled' }),
3560+
])
3561+
})
3562+
34133563
it('does not let a pre-allocation progress event consume the admission probe', async () => {
34143564
const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
34153565
const getFreeDiskBytes = vi.fn(() => Number.MAX_SAFE_INTEGER)
@@ -3610,19 +3760,18 @@ describe('browser-agent session', () => {
36103760
firstProbe.resolve(false)
36113761
secondProbe.reject(new Error('late path lookup failure'))
36123762
await vi.advanceTimersByTimeAsync(0)
3613-
expect(first.item.setSavePath).not.toHaveBeenCalled()
3614-
expect(second.item.setSavePath).not.toHaveBeenCalled()
3763+
expectOnlyStagingSavePath(first.item, directory)
3764+
expectOnlyStagingSavePath(second.item, directory)
36153765
expect(first.item.resume).not.toHaveBeenCalled()
36163766
expect(second.item.resume).not.toHaveBeenCalled()
36173767

36183768
first.emitDone('cancelled')
36193769
second.emitDone('cancelled')
36203770
const replacement = mockDownloadItem({ filename: 'hung-path.bin', totalBytes: 100 })
36213771
startMockDownload(contents, replacement)
3622-
await vi.waitFor(() =>
3623-
expect(replacement.item.setSavePath).toHaveBeenCalledWith(join(directory, 'hung-path.bin'))
3624-
)
36253772
await vi.waitFor(() => expect(replacement.item.resume).toHaveBeenCalledOnce())
3773+
replacement.emitDone('completed')
3774+
await vi.waitFor(() => expect(finishedDownloadFiles(directory)).toEqual(['hung-path.bin']))
36263775
} finally {
36273776
vi.useRealTimers()
36283777
}
@@ -3694,7 +3843,7 @@ describe('browser-agent session', () => {
36943843
startMockDownload(contents, download)
36953844

36963845
await vi.waitFor(() => expect(download.item.cancel).toHaveBeenCalledOnce())
3697-
expect(download.item.setSavePath).not.toHaveBeenCalled()
3846+
expectOnlyStagingSavePath(download.item, notDirectory)
36983847
expect(download.item.resume).not.toHaveBeenCalled()
36993848
expect(session.getBrowserDownloadsState('chat-test').downloads[0]).toMatchObject({
37003849
filename: 'cannot-save.bin',
@@ -3735,7 +3884,6 @@ describe('browser-agent session', () => {
37353884

37363885
startMockDownload(contents, download)
37373886
await vi.waitFor(() => expect(download.item.resume).toHaveBeenCalledOnce())
3738-
const firstSavePath = download.item.setSavePath.mock.calls[0]?.[0]
37393887
download.setReceivedBytes(2 * 1024 ** 3 + 1)
37403888
download.emitUpdated()
37413889

@@ -3750,7 +3898,9 @@ describe('browser-agent session', () => {
37503898
const replacement = mockDownloadItem({ filename: 'stream.bin', totalBytes: 100 })
37513899
startMockDownload(contents, replacement)
37523900
expect(replacement.item.cancel).not.toHaveBeenCalled()
3753-
await vi.waitFor(() => expect(replacement.item.setSavePath).toHaveBeenCalledWith(firstSavePath))
3901+
await vi.waitFor(() => expect(replacement.item.resume).toHaveBeenCalledOnce())
3902+
replacement.emitDone('completed')
3903+
await vi.waitFor(() => expect(finishedDownloadFiles(directory)).toEqual(['stream.bin']))
37543904
})
37553905

37563906
it('throttles free-disk checks while stopping promptly after the interval', async () => {
@@ -3867,9 +4017,11 @@ describe('browser-agent session', () => {
38674017
.mockReturnValueOnce(firstProbe.promise)
38684018
.mockReturnValueOnce(secondProbe.promise)
38694019
.mockReturnValue(Number.MAX_SAFE_INTEGER)
4020+
const pathExists = vi.fn((_path: string) => false)
38704021
session = freshSession(win, {}, persistence, {
38714022
getDirectory: () => directory,
38724023
getFreeDiskBytes,
4024+
pathExists,
38734025
})
38744026
const contents = (session.ensureTab().view as unknown as MockView).webContents
38754027
const first = mockDownloadItem({ filename: 'same-name.bin', totalBytes: 100 })
@@ -3878,12 +4030,11 @@ describe('browser-agent session', () => {
38784030
startMockDownload(contents, first)
38794031
startMockDownload(contents, second)
38804032
await vi.waitFor(() => expect(getFreeDiskBytes).toHaveBeenCalledTimes(2))
3881-
const allocatedPaths = [
3882-
first.item.setSavePath.mock.calls[0]?.[0],
3883-
second.item.setSavePath.mock.calls[0]?.[0],
3884-
]
3885-
expect(new Set(allocatedPaths).size).toBe(2)
3886-
expect(allocatedPaths).toContain(join(directory, 'same-name.bin'))
4033+
expect(pathExists.mock.calls.map(([candidate]) => candidate)).toEqual([
4034+
join(directory, 'same-name.bin'),
4035+
join(directory, 'same-name.bin'),
4036+
expect.stringMatching(/same-name \(.+\)\.bin$/),
4037+
])
38874038

38884039
await session.clearProfileStorage()
38894040

@@ -3903,18 +4054,24 @@ describe('browser-agent session', () => {
39034054
const nextContents = (session.ensureTab().view as unknown as MockView).webContents
39044055
const replacement = mockDownloadItem({ filename: 'same-name.bin', totalBytes: 100 })
39054056
startMockDownload(nextContents, replacement)
3906-
await vi.waitFor(() =>
3907-
expect(replacement.item.setSavePath).toHaveBeenCalledWith(join(directory, 'same-name.bin'))
3908-
)
39094057
await vi.waitFor(() => expect(replacement.item.resume).toHaveBeenCalledOnce())
39104058
expect(replacement.item.cancel).not.toHaveBeenCalled()
39114059

39124060
first.emitDone('completed')
39134061
second.emitDone('cancelled')
39144062
const concurrent = mockDownloadItem({ filename: 'same-name.bin', totalBytes: 100 })
39154063
startMockDownload(nextContents, concurrent)
3916-
await vi.waitFor(() => expect(concurrent.item.setSavePath).toHaveBeenCalledOnce())
3917-
expect(concurrent.item.setSavePath).not.toHaveBeenCalledWith(join(directory, 'same-name.bin'))
4064+
await vi.waitFor(() => expect(concurrent.item.resume).toHaveBeenCalledOnce())
4065+
concurrent.emitDone('completed')
4066+
await vi.waitFor(() =>
4067+
expect(finishedDownloadFiles(directory)).toEqual([
4068+
expect.stringMatching(/^same-name \(.+\)\.bin$/),
4069+
])
4070+
)
4071+
replacement.emitDone('completed')
4072+
await vi.waitFor(() => expect(finishedDownloadFiles(directory)).toHaveLength(2))
4073+
expect(finishedDownloadFiles(directory)).toContain('same-name.bin')
4074+
await vi.waitFor(() => expect(readdirSync(directory)).toHaveLength(2))
39184075
})
39194076

39204077
it('does not reserve a late filename after profile teardown starts', async () => {
@@ -3931,7 +4088,7 @@ describe('browser-agent session', () => {
39314088
await Promise.resolve()
39324089

39334090
expect(download.item.cancel).toHaveBeenCalledOnce()
3934-
expect(download.item.setSavePath).not.toHaveBeenCalled()
4091+
expectOnlyStagingSavePath(download.item, directory)
39354092
expect(download.item.resume).not.toHaveBeenCalled()
39364093
})
39374094

@@ -3970,15 +4127,21 @@ describe('browser-agent session', () => {
39704127
queueMicrotask(() => session.disposeBrowserScope('chat-first'))
39714128
secondPathProbe.resolve(false)
39724129

3973-
await vi.waitFor(() =>
3974-
expect(second.item.setSavePath).toHaveBeenCalledWith(join(directory, 'shared.bin'))
3975-
)
3976-
expect(first.item.setSavePath).not.toHaveBeenCalled()
4130+
await vi.waitFor(() => expect(second.item.resume).toHaveBeenCalledOnce())
4131+
expectOnlyStagingSavePath(first.item, directory)
4132+
expect(first.item.resume).not.toHaveBeenCalled()
39774133

39784134
const third = mockDownloadItem({ filename: 'shared.bin', totalBytes: 100 })
39794135
startMockDownload(thirdContents, third)
3980-
await vi.waitFor(() => expect(third.item.setSavePath).toHaveBeenCalledOnce())
3981-
expect(third.item.setSavePath).not.toHaveBeenCalledWith(join(directory, 'shared.bin'))
4136+
await vi.waitFor(() => expect(third.item.resume).toHaveBeenCalledOnce())
4137+
third.emitDone('completed')
4138+
await vi.waitFor(() =>
4139+
expect(finishedDownloadFiles(directory)).toEqual([
4140+
expect.stringMatching(/^shared \(.+\)\.bin$/),
4141+
])
4142+
)
4143+
second.emitDone('completed')
4144+
await vi.waitFor(() => expect(finishedDownloadFiles(directory)).toContain('shared.bin'))
39824145
})
39834146

39844147
it('cancels only the disposed scope and ignores its late download callbacks', async () => {
@@ -4017,7 +4180,7 @@ describe('browser-agent session', () => {
40174180
disposedDownload.emitUpdated()
40184181
disposedDownload.emitDone('cancelled')
40194182

4020-
expect(disposedDownload.item.setSavePath).not.toHaveBeenCalled()
4183+
expectOnlyStagingSavePath(disposedDownload.item, directory)
40214184
expect(disposedDownload.item.resume).not.toHaveBeenCalled()
40224185
expect(session.getBrowserDownloadsState('chat-disposed').downloads).toEqual([])
40234186
expect(onDownloadsChanged).not.toHaveBeenCalledWith(
@@ -4101,10 +4264,11 @@ describe('browser-agent session', () => {
41014264
expect(rejected.item.cancel).toHaveBeenCalledOnce()
41024265

41034266
first.emitDone('completed')
4267+
await vi.waitFor(() => expect(finishedDownloadFiles(directory)).toEqual(['first.txt']))
41044268
const replacement = mockDownloadItem({ filename: 'fourth.txt', totalBytes: 100 })
41054269
startMockDownload(contents, replacement)
41064270
expect(replacement.item.cancel).not.toHaveBeenCalled()
4107-
await vi.waitFor(() => expect(replacement.item.setSavePath).toHaveBeenCalledOnce())
4271+
await vi.waitFor(() => expect(replacement.item.resume).toHaveBeenCalledOnce())
41084272
})
41094273

41104274
it('bounds active browser downloads across tasks', () => {

0 commit comments

Comments
 (0)