test(web): cover produced-file lifecycle edges
This commit is contained in:
@@ -192,8 +192,7 @@ export class ConnectionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Sink exception isolation: a business-layer throw is logged only, never affecting pump or reconnect semantics. */
|
/** Sink exception isolation: a business-layer throw is logged only, never affecting pump or reconnect semantics. */
|
||||||
private callSink(fn: (() => void) | undefined): void {
|
private callSink(fn: () => void): void {
|
||||||
if (fn === undefined) return
|
|
||||||
try {
|
try {
|
||||||
fn()
|
fn()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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 () => {
|
it('WebApiClient keeps unary calls and respond on globalThis.fetch', async () => {
|
||||||
;(globalThis as Win).location = { hostname: 'localhost', search: '' }
|
;(globalThis as Win).location = { hostname: 'localhost', search: '' }
|
||||||
const handle = await mount()
|
const handle = await mount()
|
||||||
|
|||||||
@@ -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 () => {
|
it('proceeds as connected via the timeout guard when a carrier never fires onOpen', async () => {
|
||||||
const api = new FakeApiClient()
|
const api = new FakeApiClient()
|
||||||
api.suppressStreamOpen = true // misbehaving carrier: streams open but onOpen never fires
|
api.suppressStreamOpen = true // misbehaving carrier: streams open but onOpen never fires
|
||||||
|
|||||||
@@ -30,14 +30,19 @@ export function fitProducedFiles(
|
|||||||
): number {
|
): number {
|
||||||
if (available <= 0) return chipWidths.length
|
if (available <= 0) return chipWidths.length
|
||||||
const prefix = [0]
|
const prefix = [0]
|
||||||
for (const width of chipWidths) prefix.push((prefix.at(-1) ?? 0) + width)
|
let prefixWidth = 0
|
||||||
for (let shown = chipWidths.length; shown >= 0; shown -= 1) {
|
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 more = moreWidthsByShown[shown]
|
||||||
const items = shown + (more === undefined ? 0 : 1)
|
const items = shown + (more === undefined ? 0 : 1)
|
||||||
const needed = (prefix[shown] ?? 0) + (more ?? 0) + Math.max(0, items - 1) * gap
|
const needed = width + (more ?? 0) + Math.max(0, items - 1) * gap
|
||||||
if (needed <= available) return shown
|
if (needed <= available) largestFit = shown
|
||||||
}
|
}
|
||||||
return 0
|
return largestFit
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Matched paths plus the opener and locale seats needed to present them. */
|
/** 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(() => {
|
useLayoutEffect(() => {
|
||||||
const row = rowRef.current
|
const row = rowRef.current
|
||||||
|
/* v8 ignore next -- the row ref is attached before the layout effect runs. */
|
||||||
if (row === null) return
|
if (row === null) return
|
||||||
const measure = (): void => {
|
const measure = (): void => {
|
||||||
const styles = getComputedStyle(row)
|
const styles = getComputedStyle(row)
|
||||||
const gap = Number.parseFloat(styles.columnGap || styles.gap) || 0
|
const gap = Number.parseFloat(styles.columnGap || styles.gap) || 0
|
||||||
const chips = chipProbes.current.slice(0, limit)
|
// React attaches every still-mounted callback ref before layout effects run.
|
||||||
.map(probe => probe?.getBoundingClientRect().width ?? 0)
|
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) =>
|
const more = Array.from({ length: limit + 1 }, (_, candidate) =>
|
||||||
paths.length === candidate
|
paths.length === candidate
|
||||||
? undefined
|
? undefined
|
||||||
|
|||||||
@@ -288,6 +288,7 @@ describe('ProducedFiles row', () => {
|
|||||||
// A zero-width lane is a pre-layout test/hidden state, not evidence that
|
// A zero-width lane is a pre-layout test/hidden state, not evidence that
|
||||||
// every chip overflowed; keep the bounded initial prefix until measured.
|
// every chip overflowed; keep the bounded initial prefix until measured.
|
||||||
expect(fitProducedFiles(0, 8, [70, 60], [60, 50, undefined])).toBe(2)
|
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.
|
// Candidate-specific suffix widths matter at the 10 -> 9 digit boundary.
|
||||||
expect(fitProducedFiles(126, 8, [60], [70, 50])).toBe(1)
|
expect(fitProducedFiles(126, 8, [60], [70, 50])).toBe(1)
|
||||||
expect(fitProducedFiles(20, 8, [60], [70, 50])).toBe(0)
|
expect(fitProducedFiles(20, 8, [60], [70, 50])).toBe(0)
|
||||||
@@ -299,9 +300,13 @@ describe('ProducedFiles row', () => {
|
|||||||
let available = 226
|
let available = 226
|
||||||
let resize: ResizeObserverCallback | undefined
|
let resize: ResizeObserverCallback | undefined
|
||||||
const disconnect = vi.fn()
|
const disconnect = vi.fn()
|
||||||
|
const observeNode = vi.fn<(target: Element) => void>()
|
||||||
vi.stubGlobal('ResizeObserver', class {
|
vi.stubGlobal('ResizeObserver', class {
|
||||||
constructor(callback: ResizeObserverCallback) { resize = callback }
|
constructor(callback: ResizeObserverCallback) { resize = callback }
|
||||||
observe(): void {}
|
observe(target: Element): void {
|
||||||
|
expect(target).toBeInstanceOf(Element)
|
||||||
|
observeNode(target)
|
||||||
|
}
|
||||||
disconnect(): void { disconnect() }
|
disconnect(): void { disconnect() }
|
||||||
})
|
})
|
||||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
|
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
|
||||||
@@ -344,8 +349,23 @@ describe('ProducedFiles row', () => {
|
|||||||
expect(within(row).getAllByRole('button')).toHaveLength(1)
|
expect(within(row).getAllByRole('button')).toHaveLength(1)
|
||||||
expect(within(row).getByText('+ 6 个文件')).toBeTruthy()
|
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()
|
view.unmount()
|
||||||
expect(disconnect).toHaveBeenCalledOnce()
|
expect(disconnect).toHaveBeenCalledTimes(2)
|
||||||
bounds.mockRestore()
|
bounds.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user