feat(web): make produced-file overflow discoverable

This commit is contained in:
ZiyaZhang
2026-08-10 08:08:24 -07:00
parent a40155ad23
commit ee1a88c9f1
40 changed files with 748 additions and 122 deletions

View File

@@ -87,10 +87,68 @@ describe('connection client apply', () => {
it('start() hands out one loop, rejects a second consumer, and stop() aborts the streams', async () => {
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
const handle = await mount()
const descriptions: Array<boolean | undefined> = []
const stopDescription = handle.hostDescription.subscribe(() => {
descriptions.push(handle.hostDescription.getSnapshot()?.canOpenPath)
})
expect(handle.hostDescription.getSnapshot()).toBeUndefined()
// config omitted: the `config ?? {}` default arm is part of the surface.
const loop = handle.start({})
expect(() => handle.start({})).toThrow(/already owned by another consumer/)
await vi.waitFor(() => {
expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true)
})
loop.stop() // teardown must not throw; the fixture streams abort quietly
expect(handle.hostDescription.getSnapshot()).toBeUndefined()
expect(descriptions).toEqual([true, undefined])
stopDescription()
})
it('isolates description subscribers so later listeners and the consumer sink still run', async () => {
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
const handle = await mount()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const seen: boolean[] = []
const stopThrowing = handle.hostDescription.subscribe(() => { throw new Error('subscriber bug') })
const stopRecording = handle.hostDescription.subscribe(() => {
const value = handle.hostDescription.getSnapshot()?.canOpenPath
if (value !== undefined) seen.push(value)
})
let connected = 0
const loop = handle.start({ onConnected: () => { connected++ } })
try {
await vi.waitFor(() => { expect(connected).toBe(1) })
expect(seen).toEqual([true])
expect(errorSpy).toHaveBeenCalledOnce()
} finally {
stopThrowing()
stopRecording()
loop.stop()
errorSpy.mockRestore()
}
})
it('does not announce a generation synchronously stopped by a description subscriber', async () => {
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
const handle = await mount()
const owner: { loop?: ReturnType<ConnectionHandle['start']> } = {}
let sawDescription = false
const stopDescription = handle.hostDescription.subscribe(() => {
if (handle.hostDescription.getSnapshot() === undefined) return
sawDescription = true
owner.loop?.stop()
})
const connected = vi.fn()
const loop = handle.start({ onConnected: connected })
owner.loop = loop
try {
await vi.waitFor(() => { expect(sawDescription).toBe(true) })
expect(handle.hostDescription.getSnapshot()).toBeUndefined()
expect(connected).not.toHaveBeenCalled()
} finally {
stopDescription()
loop.stop()
}
})
it('WebApiClient keeps unary calls and respond on globalThis.fetch', async () => {

View File

@@ -23,10 +23,14 @@ describe('connection lifecycle', () => {
it('announces connected after describe + both streams open, then pumps frames to sinks', async () => {
const api = new FakeApiClient()
const muxSeen: string[] = []
const descriptions: boolean[] = []
let connected = 0
const controller = new ConnectionController(api, {
onMuxEnvelope: envelope => muxSeen.push(envelope.payload.type),
onConnected: () => { connected++ },
onConnected: (description) => {
connected++
descriptions.push(description.canOpenPath)
},
}, FAST)
controller.start()
try {
@@ -34,6 +38,7 @@ describe('connection lifecycle', () => {
api.pushMux(subscribedFrame())
await vi.waitFor(() => { expect(muxSeen).toEqual(['session/subscribed']) })
expect(api.callsOf('host.describe')).toHaveLength(1)
expect(descriptions).toEqual([true])
} finally {
controller.stop()
}
@@ -75,7 +80,7 @@ describe('connection lifecycle', () => {
try {
await vi.waitFor(() => { expect(describeCalls).toBe(2) }) // retried after backoff
expect(connected).toBe(0) // never announced during the failed generation
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
await vi.waitFor(() => { expect(connected).toBe(1) })
} finally {
controller.stop()
@@ -97,7 +102,7 @@ describe('connection lifecycle', () => {
},
})
}
return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
}
let connected = 0
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
@@ -210,6 +215,24 @@ describe('connection lifecycle', () => {
}
})
it('does not announce a generation stopped synchronously by its connected state sink', async () => {
const api = new FakeApiClient()
const states: ConnectionState[] = []
let connected = 0
const controller = new ConnectionController(api, {
onConnected: () => { connected++ },
onStateChange: (state) => {
states.push(state)
if (state === 'connected') controller.stop()
},
}, FAST)
controller.start()
await vi.waitFor(() => { expect(states).toEqual(['connected']) })
await vi.waitFor(() => { expect(api.openMuxCount).toBe(0) })
expect(connected).toBe(0)
})
it('deduplicates consecutive reconnecting emissions across two straight failures', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
@@ -228,7 +251,7 @@ describe('connection lifecycle', () => {
controller.start()
try {
await vi.waitFor(() => { expect(describeCalls).toBe(3) })
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
await vi.waitFor(() => { expect(connected).toBe(1) })
expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission
} finally {

View File

@@ -71,8 +71,15 @@ export class FakeApiClient implements IApiClient {
() => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' }))
onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{
version: string
cwd: string
attachedSessions: number
canOpenPath: boolean
}>> =
() => Promise.resolve(ok({
version: '0-fake', cwd: '/f', attachedSessions: 0, canOpenPath: true,
}))
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
() => Promise.resolve(ok({ path: null }))
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =