From 4357be156556c0a6349ffba5d4bfae7023e596ab Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:25:55 -0700 Subject: [PATCH] test(web): cover produced-file lifecycle edges --- .../connection/src/client/connection.ts | 3 +- .../connection/tests/client-apply.spec.ts | 36 +++++++++++++++++++ .../connection/tests/connection.spec.ts | 32 +++++++++++++++++ .../src/client/ProducedFiles.tsx | 21 +++++++---- .../tests/produced-files.spec.tsx | 24 +++++++++++-- 5 files changed, 105 insertions(+), 11 deletions(-) diff --git a/packages/client/connection/src/client/connection.ts b/packages/client/connection/src/client/connection.ts index a8d8d451f4..8b41053424 100644 --- a/packages/client/connection/src/client/connection.ts +++ b/packages/client/connection/src/client/connection.ts @@ -192,8 +192,7 @@ export class ConnectionController { } /** Sink exception isolation: a business-layer throw is logged only, never affecting pump or reconnect semantics. */ - private callSink(fn: (() => void) | undefined): void { - if (fn === undefined) return + private callSink(fn: () => void): void { try { fn() } catch (error) { diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index 1d78a73c95..5461a60c84 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -151,6 +151,42 @@ describe('connection client apply', () => { } }) + it('retracts the host description while reconnecting and republishes the next generation', async () => { + ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + const handle = await mount() + const descriptions: Array = [] + const reconnectSnapshots: Array = [] + const stopDescription = handle.hostDescription.subscribe(() => { + descriptions.push(handle.hostDescription.getSnapshot()?.canOpenPath) + }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const loop = handle.start({ + onStateChange: (state) => { + if (state === 'reconnecting') { + reconnectSnapshots.push(handle.hostDescription.getSnapshot()?.canOpenPath) + } + }, + }, { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, streamOpenTimeoutMs: 500 }) + try { + await vi.waitFor(() => { + expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + }) + const timing = (globalThis as Record).__fxTiming as + | { breakStreams(): void } + | undefined + if (timing === undefined) throw new Error('fixture timing hooks missing') + timing.breakStreams() + + await vi.waitFor(() => { expect(reconnectSnapshots).toEqual([undefined]) }) + await vi.waitFor(() => { expect(descriptions).toEqual([true, undefined, true]) }) + expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + } finally { + stopDescription() + loop.stop() + warnSpy.mockRestore() + } + }) + it('WebApiClient keeps unary calls and respond on globalThis.fetch', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '' } const handle = await mount() diff --git a/packages/client/connection/tests/connection.spec.ts b/packages/client/connection/tests/connection.spec.ts index fcdf831709..3a39122365 100644 --- a/packages/client/connection/tests/connection.spec.ts +++ b/packages/client/connection/tests/connection.spec.ts @@ -180,6 +180,38 @@ describe('connection lifecycle', () => { } }) + it('rejects a generation whose streams end during readiness and retries', async () => { + const api = new FakeApiClient() + const firstDescribe = deferred>>() + let describeCalls = 0 + api.onDescribe = () => { + describeCalls++ + return describeCalls === 1 + ? firstDescribe.promise + : Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })) + } + const states: ConnectionState[] = [] + let connected = 0 + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const controller = new ConnectionController(api, { + onConnected: () => { connected++ }, + onStateChange: state => states.push(state), + }, FAST) + controller.start() + try { + await vi.waitFor(() => { expect(api.openMuxCount).toBe(1) }) + api.endStreams() + firstDescribe.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })) + + await vi.waitFor(() => { expect(describeCalls).toBe(2) }) + await vi.waitFor(() => { expect(connected).toBe(1) }) + expect(states).toEqual(['reconnecting', 'connected']) + } finally { + controller.stop() + warnSpy.mockRestore() + } + }) + it('proceeds as connected via the timeout guard when a carrier never fires onOpen', async () => { const api = new FakeApiClient() api.suppressStreamOpen = true // misbehaving carrier: streams open but onOpen never fires diff --git a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx index 40a9a1cefd..4c537fb221 100644 --- a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx +++ b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx @@ -30,14 +30,19 @@ export function fitProducedFiles( ): number { if (available <= 0) return chipWidths.length const prefix = [0] - for (const width of chipWidths) prefix.push((prefix.at(-1) ?? 0) + width) - for (let shown = chipWidths.length; shown >= 0; shown -= 1) { + let prefixWidth = 0 + for (const width of chipWidths) { + prefixWidth += width + prefix.push(prefixWidth) + } + let largestFit = 0 + for (const [shown, width] of prefix.entries()) { const more = moreWidthsByShown[shown] const items = shown + (more === undefined ? 0 : 1) - const needed = (prefix[shown] ?? 0) + (more ?? 0) + Math.max(0, items - 1) * gap - if (needed <= available) return shown + const needed = width + (more ?? 0) + Math.max(0, items - 1) * gap + if (needed <= available) largestFit = shown } - return 0 + return largestFit } /** Matched paths plus the opener and locale seats needed to present them. */ @@ -68,12 +73,14 @@ export function ProducedFiles({ matched: paths, openFile, canOpenPath, t }: Prod useLayoutEffect(() => { const row = rowRef.current + /* v8 ignore next -- the row ref is attached before the layout effect runs. */ if (row === null) return const measure = (): void => { const styles = getComputedStyle(row) const gap = Number.parseFloat(styles.columnGap || styles.gap) || 0 - const chips = chipProbes.current.slice(0, limit) - .map(probe => probe?.getBoundingClientRect().width ?? 0) + // React attaches every still-mounted callback ref before layout effects run. + const activeChipProbes = chipProbes.current.slice(0, limit) as HTMLButtonElement[] + const chips = activeChipProbes.map(probe => probe.getBoundingClientRect().width) const more = Array.from({ length: limit + 1 }, (_, candidate) => paths.length === candidate ? undefined diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index 1c7f999d1d..5d14f34b7b 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -288,6 +288,7 @@ describe('ProducedFiles row', () => { // A zero-width lane is a pre-layout test/hidden state, not evidence that // every chip overflowed; keep the bounded initial prefix until measured. expect(fitProducedFiles(0, 8, [70, 60], [60, 50, undefined])).toBe(2) + expect(fitProducedFiles(128, 8, [60, 60], [70, 50, undefined])).toBe(2) // Candidate-specific suffix widths matter at the 10 -> 9 digit boundary. expect(fitProducedFiles(126, 8, [60], [70, 50])).toBe(1) expect(fitProducedFiles(20, 8, [60], [70, 50])).toBe(0) @@ -299,9 +300,13 @@ describe('ProducedFiles row', () => { let available = 226 let resize: ResizeObserverCallback | undefined const disconnect = vi.fn() + const observeNode = vi.fn<(target: Element) => void>() vi.stubGlobal('ResizeObserver', class { constructor(callback: ResizeObserverCallback) { resize = callback } - observe(): void {} + observe(target: Element): void { + expect(target).toBeInstanceOf(Element) + observeNode(target) + } disconnect(): void { disconnect() } }) Object.defineProperty(HTMLElement.prototype, 'clientWidth', { @@ -344,8 +349,23 @@ describe('ProducedFiles row', () => { expect(within(row).getAllByRole('button')).toHaveLength(1) expect(within(row).getByText('+ 6 个文件')).toBeTruthy() + // A missing/unsupported computed gap falls back to zero rather than NaN. + vi.stubGlobal('getComputedStyle', () => ({ columnGap: '', gap: '' } as CSSStyleDeclaration)) + available = 165 + act(() => { resize?.([], {} as ResizeObserver) }) + expect(within(row).getAllByRole('button')).toHaveLength(2) + + // Ref callbacks leave nulls in the probe arrays when the candidate set + // shrinks; the replacement observer must skip those stale slots. + observeNode.mockClear() + view.rerender( + , + ) + expect(within(row).getAllByRole('button')).toHaveLength(1) + expect(observeNode).toHaveBeenCalledTimes(3) + view.unmount() - expect(disconnect).toHaveBeenCalledOnce() + expect(disconnect).toHaveBeenCalledTimes(2) bounds.mockRestore() })