Explorar o código

test(web): cover produced-file lifecycle edges

ZiyaZhang hai 1 mes
pai
achega
4357be1565

+ 1 - 2
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) {

+ 36 - 0
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<boolean | undefined> = []
+    const reconnectSnapshots: Array<boolean | undefined> = []
+    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<string, unknown>).__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()

+ 32 - 0
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<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
+    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

+ 14 - 7
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

+ 22 - 2
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(
+      <ProducedFiles matched={paths.slice(0, 1)} openFile={openFile} canOpenPath t={t} />,
+    )
+    expect(within(row).getAllByRole('button')).toHaveLength(1)
+    expect(observeNode).toHaveBeenCalledTimes(3)
+
     view.unmount()
-    expect(disconnect).toHaveBeenCalledOnce()
+    expect(disconnect).toHaveBeenCalledTimes(2)
     bounds.mockRestore()
   })