The branch label left the Session header, and finding which Sessions work in their own worktrees, or which of their change requests need attention, meant opening each Session's tab. - A Session row shows a branch glyph while the Session works in the worktree the panel created for it, and the state glyph of its most urgent change request with a dot for its checks. The row's hover card names the worktree branch and its base, then up to three change requests with their state and checks. - The forgeWorktree projection follows the Session's working directory; its client view is the created worktree while the Session works in it. The forgeChangeRequests projection gains a client view with the bindings, so the Session summary carries both without loading a Session. - The forgePanel.changeRequestStates stream serves the states of the change requests every listed Session binds from one Host run. It polls each until it is merged or closed and stores every change in the forge_panel_change_requests storage domain, so a restart shows the last known states before the next poll. - A new Agent Note records the decision; the READMEs, the forge subsystem page, and the catalogs follow.
302 lines
12 KiB
TypeScript
302 lines
12 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import { createNoticeSource, createPanelSource, createStreamSource, createVisibilitySource, foldStates } from '../src/client/source.ts'
|
|
import type { PanelStream } from '../src/client/source.ts'
|
|
import type { ForgeChangeRequestListState, ForgeChangeRequestStatesFrame, ForgePanelState } from '../src/types.ts'
|
|
import { entry, listState, ready, snapshot } from './client-fixtures.client.ts'
|
|
|
|
/** A stream the test feeds by hand. */
|
|
function feed<F = ForgePanelState>() {
|
|
const queue: F[] = []
|
|
let wake: (() => void) | undefined
|
|
let closed = false
|
|
let failure: unknown
|
|
const stream = {
|
|
signal: undefined as AbortSignal | undefined,
|
|
disposed: vi.fn(),
|
|
push(frame: F) { queue.push(frame); wake?.() },
|
|
fail(error: unknown) { failure = error; wake?.() },
|
|
close() { closed = true; wake?.() },
|
|
dispose() { stream.disposed(); closed = true; wake?.() },
|
|
async *[Symbol.asyncIterator](): AsyncIterator<F> {
|
|
while (true) {
|
|
const next = queue.shift()
|
|
if (next !== undefined) { yield next; continue }
|
|
if (failure !== undefined) throw failure
|
|
if (closed) return
|
|
await new Promise<void>((resolve) => { wake = resolve })
|
|
wake = undefined
|
|
}
|
|
},
|
|
}
|
|
return stream
|
|
}
|
|
|
|
function source() {
|
|
const streams: ReturnType<typeof feed>[] = []
|
|
const resets = new Set<() => void>()
|
|
const panel = createPanelSource({
|
|
open: (signal): PanelStream => {
|
|
const stream = feed()
|
|
stream.signal = signal
|
|
streams.push(stream)
|
|
return stream
|
|
},
|
|
subscribeReset: (listener) => {
|
|
resets.add(listener)
|
|
return () => { resets.delete(listener) }
|
|
},
|
|
})
|
|
return { panel, streams, reset: () => { for (const listener of [...resets]) listener() }, resets }
|
|
}
|
|
|
|
/** Let the stream readers run every iteration that is ready. */
|
|
async function settle(): Promise<void> {
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
}
|
|
|
|
describe('createPanelSource', () => {
|
|
it('opens one stream for all subscribers and closes it with the last one', async () => {
|
|
const { panel, streams, resets } = source()
|
|
const observable = panel.hooks.panel
|
|
expect(streams).toHaveLength(0)
|
|
expect(observable.getSnapshot()).toEqual({ state: null, disconnected: false })
|
|
const seen: unknown[] = []
|
|
const first = observable.subscribe(() => { seen.push(observable.getSnapshot()) })
|
|
const second = observable.subscribe(() => {})
|
|
expect(streams).toHaveLength(1)
|
|
expect(resets.size).toBe(1)
|
|
|
|
streams[0]?.push(ready())
|
|
await vi.waitFor(() => { expect(seen).toEqual([{ state: ready(), disconnected: false }]) })
|
|
first()
|
|
expect(streams[0]?.disposed).not.toHaveBeenCalled()
|
|
second()
|
|
expect(streams[0]?.disposed).toHaveBeenCalled()
|
|
expect(streams[0]?.signal?.aborted).toBe(true)
|
|
expect(resets.size).toBe(0)
|
|
expect(observable.getSnapshot()).toEqual({ state: ready(), disconnected: false })
|
|
|
|
const again = observable.subscribe(() => {})
|
|
expect(streams).toHaveLength(2)
|
|
again()
|
|
})
|
|
|
|
it('publishes every state of the running stream in order', async () => {
|
|
const { panel, streams } = source()
|
|
const seen: (ForgePanelState | null)[] = []
|
|
const unsubscribe = panel.hooks.panel.subscribe(() => { seen.push(panel.hooks.panel.getSnapshot().state) })
|
|
const later = ready({ changeRequests: [entry(1, snapshot(1))] })
|
|
|
|
streams[0]?.push(ready())
|
|
streams[0]?.push(later)
|
|
await vi.waitFor(() => { expect(seen).toEqual([ready(), later]) })
|
|
unsubscribe()
|
|
})
|
|
|
|
it('ignores frames from a stream it closed', async () => {
|
|
const { panel, streams } = source()
|
|
const unsubscribe = panel.hooks.panel.subscribe(() => {})
|
|
const stream = streams[0]
|
|
unsubscribe()
|
|
stream?.push(ready({ directory: '/late' }))
|
|
await settle()
|
|
|
|
expect(panel.hooks.panel.getSnapshot()).toEqual({ state: null, disconnected: false })
|
|
})
|
|
|
|
it.each([
|
|
['ends', (stream: ReturnType<typeof feed>) => { stream.close() }],
|
|
['fails', (stream: ReturnType<typeof feed>) => { stream.fail(new Error('lost')) }],
|
|
])('marks the snapshot disconnected when the stream %s and reopens on demand', async (_label, end) => {
|
|
const { panel, streams } = source()
|
|
const unsubscribe = panel.hooks.panel.subscribe(() => {})
|
|
streams[0]?.push(ready())
|
|
await vi.waitFor(() => { expect(panel.hooks.panel.getSnapshot().state).not.toBeNull() })
|
|
|
|
end(streams[0]!)
|
|
await vi.waitFor(() => { expect(panel.hooks.panel.getSnapshot().disconnected).toBe(true) })
|
|
expect(panel.hooks.panel.getSnapshot().state).toEqual(ready())
|
|
panel.reconnect()
|
|
expect(streams).toHaveLength(2)
|
|
expect(panel.hooks.panel.getSnapshot()).toEqual({ state: ready(), disconnected: false })
|
|
panel.reconnect()
|
|
expect(streams).toHaveLength(2)
|
|
unsubscribe()
|
|
panel.reconnect()
|
|
expect(streams).toHaveLength(2)
|
|
})
|
|
|
|
it('lets the next subscriber reopen a stream that ended after the last one left', async () => {
|
|
const { panel, streams, resets } = source()
|
|
const unsubscribe = panel.hooks.panel.subscribe(() => {})
|
|
streams[0]?.push(ready())
|
|
streams[0]?.close()
|
|
await vi.waitFor(() => { expect(panel.hooks.panel.getSnapshot()).toEqual({ state: ready(), disconnected: true }) })
|
|
|
|
unsubscribe()
|
|
expect(resets.size).toBe(0)
|
|
expect(streams[0]?.disposed).not.toHaveBeenCalled()
|
|
expect(panel.hooks.panel.getSnapshot()).toEqual({ state: ready(), disconnected: true })
|
|
|
|
const again = panel.hooks.panel.subscribe(() => {})
|
|
expect(streams).toHaveLength(2)
|
|
expect(panel.hooks.panel.getSnapshot()).toEqual({ state: ready(), disconnected: false })
|
|
again()
|
|
})
|
|
|
|
it('replaces the stream after a transport reset and ignores the replaced one', async () => {
|
|
const { panel, streams, reset } = source()
|
|
const unsubscribe = panel.hooks.panel.subscribe(() => {})
|
|
|
|
reset()
|
|
|
|
expect(streams).toHaveLength(2)
|
|
expect(streams[0]?.disposed).toHaveBeenCalled()
|
|
expect(streams[0]?.signal?.aborted).toBe(true)
|
|
streams[0]?.push(ready({ directory: '/stale' }))
|
|
await settle()
|
|
expect(panel.hooks.panel.getSnapshot()).toEqual({ state: null, disconnected: false })
|
|
streams[1]?.push(ready())
|
|
await vi.waitFor(() => { expect(panel.hooks.panel.getSnapshot()).toEqual({ state: ready(), disconnected: false }) })
|
|
unsubscribe()
|
|
})
|
|
|
|
it('reopens a stream that ended when the transport resets', async () => {
|
|
const { panel, streams, reset } = source()
|
|
const unsubscribe = panel.hooks.panel.subscribe(() => {})
|
|
streams[0]?.fail(new Error('lost'))
|
|
await vi.waitFor(() => { expect(panel.hooks.panel.getSnapshot().disconnected).toBe(true) })
|
|
|
|
reset()
|
|
|
|
expect(streams).toHaveLength(2)
|
|
expect(panel.hooks.panel.getSnapshot()).toEqual({ state: null, disconnected: false })
|
|
unsubscribe()
|
|
})
|
|
|
|
it('closes the stream and drops every subscriber on close', async () => {
|
|
const { panel, streams, resets } = source()
|
|
const first = vi.fn()
|
|
const second = vi.fn()
|
|
const unsubscribe = panel.hooks.panel.subscribe(first)
|
|
panel.hooks.panel.subscribe(second)
|
|
|
|
panel.close()
|
|
|
|
expect(streams).toHaveLength(1)
|
|
expect(streams[0]?.disposed).toHaveBeenCalledOnce()
|
|
expect(streams[0]?.signal?.aborted).toBe(true)
|
|
expect(resets.size).toBe(0)
|
|
panel.reconnect()
|
|
expect(streams).toHaveLength(1)
|
|
unsubscribe()
|
|
panel.close()
|
|
streams[0]?.push(ready())
|
|
await settle()
|
|
expect(first).not.toHaveBeenCalled()
|
|
expect(second).not.toHaveBeenCalled()
|
|
expect(streams[0]?.disposed).toHaveBeenCalledOnce()
|
|
expect(panel.hooks.panel.getSnapshot()).toEqual({ state: null, disconnected: false })
|
|
})
|
|
|
|
it('closes a source nobody subscribed to', () => {
|
|
const { panel, streams } = source()
|
|
panel.close()
|
|
expect(streams).toHaveLength(0)
|
|
expect(panel.hooks.panel.getSnapshot()).toEqual({ state: null, disconnected: false })
|
|
})
|
|
})
|
|
|
|
describe('change request states', () => {
|
|
it('builds states from a baseline and its changes, and lets the baseline after a transport reset replace them', async () => {
|
|
const streams: ReturnType<typeof feed<ForgeChangeRequestStatesFrame>>[] = []
|
|
const resets = new Set<() => void>()
|
|
const source = createStreamSource<ForgeChangeRequestStatesFrame, ReadonlyMap<string, ForgeChangeRequestListState>>({
|
|
open: () => {
|
|
const stream = feed<ForgeChangeRequestStatesFrame>()
|
|
streams.push(stream)
|
|
return stream
|
|
},
|
|
subscribeReset: (listener) => {
|
|
resets.add(listener)
|
|
return () => { resets.delete(listener) }
|
|
},
|
|
}, new Map(), foldStates)
|
|
const seen: (readonly ForgeChangeRequestListState[])[] = []
|
|
const unsubscribe = source.observable.subscribe(() => { seen.push([...source.observable.getSnapshot().state.values()]) })
|
|
|
|
streams[0]?.push({ type: 'baseline', states: [listState(1), listState(2)] })
|
|
streams[0]?.push({ type: 'changed', states: [listState(2, { state: 'merged' })] })
|
|
await vi.waitFor(() => { expect(seen).toHaveLength(2) })
|
|
for (const listener of [...resets]) listener()
|
|
streams[1]?.push({ type: 'baseline', states: [listState(3)] })
|
|
await vi.waitFor(() => { expect(seen).toHaveLength(3) })
|
|
unsubscribe()
|
|
|
|
expect(seen).toEqual([
|
|
[listState(1), listState(2)],
|
|
[listState(1), listState(2, { state: 'merged' })],
|
|
[listState(3)],
|
|
])
|
|
})
|
|
|
|
it('folds a change into new states and leaves the earlier ones unchanged', () => {
|
|
const before = foldStates(new Map(), { type: 'baseline', states: [listState(1)] })
|
|
|
|
const after = foldStates(before, { type: 'changed', states: [listState(1, { draft: true })] })
|
|
|
|
expect([...before.values()]).toEqual([listState(1)])
|
|
expect([...after.values()]).toEqual([listState(1, { draft: true })])
|
|
})
|
|
})
|
|
|
|
describe('createNoticeSource', () => {
|
|
it('publishes numbered failure notices without values or undo by default, and their dismissal', () => {
|
|
const notices = createNoticeSource()
|
|
expect(notices.hooks.notice.getSnapshot()).toBeNull()
|
|
const seen: unknown[] = []
|
|
const unsubscribe = notices.hooks.notice.subscribe(() => { seen.push(notices.hooks.notice.getSnapshot()) })
|
|
|
|
notices.report('toast.refreshFailed')
|
|
notices.report('toast.refreshFailed')
|
|
notices.dismiss()
|
|
unsubscribe()
|
|
notices.report('toast.copyFailed')
|
|
|
|
const failure = { key: 'toast.refreshFailed', params: {}, tone: 'failure', undo: undefined }
|
|
expect(seen).toEqual([{ ...failure, seq: 1 }, { ...failure, seq: 2 }, null])
|
|
expect(notices.hooks.notice.getSnapshot()).toEqual({ ...failure, key: 'toast.copyFailed', seq: 3 })
|
|
})
|
|
|
|
it('carries the tone, values, and undo action a report names', () => {
|
|
const notices = createNoticeSource()
|
|
const undo = vi.fn()
|
|
|
|
notices.report('toast.removed', { tone: 'success', params: { name: '#7' }, undo })
|
|
|
|
expect(notices.hooks.notice.getSnapshot()).toEqual({ key: 'toast.removed', params: { name: '#7' }, tone: 'success', undo, seq: 1 })
|
|
expect(undo).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('createVisibilitySource', () => {
|
|
it('starts hidden and notifies only when the visibility changes', () => {
|
|
const visibility = createVisibilitySource()
|
|
const listener = vi.fn()
|
|
const unsubscribe = visibility.hooks.visible.subscribe(listener)
|
|
expect(visibility.hooks.visible.getSnapshot()).toBe(false)
|
|
|
|
visibility.set(false)
|
|
expect(listener).not.toHaveBeenCalled()
|
|
visibility.set(true)
|
|
visibility.set(true)
|
|
expect(listener).toHaveBeenCalledOnce()
|
|
expect(visibility.hooks.visible.getSnapshot()).toBe(true)
|
|
|
|
unsubscribe()
|
|
visibility.set(false)
|
|
expect(listener).toHaveBeenCalledOnce()
|
|
expect(visibility.hooks.visible.getSnapshot()).toBe(false)
|
|
})
|
|
})
|