Merge PR #500 into CI optimization

This commit is contained in:
Tianyi Cui
2026-07-22 18:31:28 +08:00
382 changed files with 33688 additions and 419 deletions

View File

@@ -0,0 +1,158 @@
// @vitest-environment jsdom
/**
* apply wiring on a real cordis Context + SlotsService: tree store built and
* subscribed, SidebarRoot registered into the layout-owned sidebar slot with
* the inject surface bound off the root binding ctx, effect teardown
* unregisters and drops the list subscription. Behavior-level assertions
* only — the inject factory's cast shape is due to change with the slot
* type-chain redesign.
*/
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act } from 'react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { scopedSlots, RootBindingProvider } from '@deepseek-ai/dsh-client-web-react'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client'
// Type-only: ui-layout's SlotMap merge so the sidebar slot key typechecks.
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
const sid = (s: string) => s as SessionId
afterEach(cleanup)
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const list = createSnapshotStore<SessionListState>({
ids: [sid('a')],
byId: { [sid('a')]: { id: sid('a'), title: 'alpha', cwd: '/proj', running: false, updatedAt: 1 } },
})
const sessions = { list, create: vi.fn(async () => sid('minted')) }
const layout = {
current: createSnapshotStore<{ sessionId?: SessionId }>({}),
open: vi.fn(),
toggleSidebar: vi.fn(),
}
ctx.provide('sessions', sessions)
ctx.provide('layout', layout)
const slots = ctx.get('slots') as SlotsService
slots.define('sidebar', { kind: 'single', scope: 'root' })
return { ctx, slots, sessions, layout }
}
function mountSlot(ctx: Context, slots: SlotsService) {
const surface = scopedSlots(slots.core, 'sidebar')
return render(
<RootBindingProvider value={{ ctx }}>
{surface.renderSlot('sidebar', {})}
</RootBindingProvider>,
)
}
describe('apply', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['slots', 'layout', 'sessions'])
})
it('fails loud when mounted without the inject declaration', async () => {
// ctx.sessions rides the cordis property proxy: reading it from a plugin
// that never declared the dependency throws instead of yielding undefined.
// Await the fiber thenable itself, not a second .await() chain: the test
// invariant host wraps plugin() with an eager readiness promise, and only
// the thenable settles it (a parallel .await() leaves it unhandled).
const ctx = new Context()
await ctx.plugin(SlotsService).await()
await expect(ctx.plugin({ apply })).rejects.toThrow(/without inject/)
})
it('registers SidebarRoot which renders from the live list', async () => {
const { ctx, slots } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
mountSlot(ctx, slots)
expect(screen.getByText('proj')).toBeTruthy()
expect(screen.getByText('1 session')).toBeTruthy()
})
it('binds actions to layout/sessions off the root binding', async () => {
const { ctx, slots, sessions, layout } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
mountSlot(ctx, slots)
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
expect(layout.toggleSidebar).toHaveBeenCalledOnce()
act(() => { fireEvent.click(screen.getByText('proj')) })
act(() => { fireEvent.click(screen.getByText('alpha')) })
expect(layout.open).toHaveBeenCalledWith('a')
act(() => { fireEvent.click(screen.getByText('New Session')) })
expect(sessions.create).toHaveBeenCalledWith({})
// create-then-open lands after the create promise resolves.
await act(async () => { await Promise.resolve() })
expect(layout.open).toHaveBeenCalledWith('minted')
act(() => { fireEvent.click(screen.getAllByLabelText('New session here')[0]!) })
expect(sessions.create).toHaveBeenCalledWith({ cwd: '/proj' })
})
it('throws from the inject factory when binding ctx lacks the services', async () => {
const { ctx, slots } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
const bare = new Context()
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const surface = scopedSlots(slots.core, 'sidebar')
render(
<RootBindingProvider value={{ ctx: bare }}>
{surface.renderSlot('sidebar', {})}
</RootBindingProvider>,
)
// The slot error boundary absorbs the throw and logs it.
expect(document.querySelector('[data-slot-error="sidebar"]')).toBeTruthy()
} finally {
spy.mockRestore()
}
})
it('search input drives the plugin-owned tree store', async () => {
const { ctx, slots } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
mountSlot(ctx, slots)
act(() => {
fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'zzz' } })
})
expect(screen.getByText('No matches')).toBeTruthy()
})
it('expansion toggles route through the injected tree actions', async () => {
const { ctx, slots, sessions } = await bench()
sessions.list.update((draft) => {
draft.ids.push(sid('kid'))
draft.byId[sid('kid')] = {
id: sid('kid'), title: 'child', cwd: '/proj', parentId: sid('a'), running: false, updatedAt: 2,
}
})
await ctx.plugin({ inject: [...inject], apply }).await()
mountSlot(ctx, slots)
act(() => { fireEvent.click(screen.getByText('proj')) })
expect(screen.getByText('alpha')).toBeTruthy()
act(() => { fireEvent.click(screen.getByLabelText('Expand')) })
expect(screen.getByText('child')).toBeTruthy()
})
it('teardown unregisters the slot and drops the list subscription', async () => {
const { ctx, slots, sessions } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(slots.entries('sidebar')).toHaveLength(1)
await fiber.dispose()
expect(slots.entries('sidebar')).toHaveLength(0)
// A post-teardown list change must not reach a disposed store.
expect(() => {
sessions.list.update((draft) => { draft.ids = [] })
}).not.toThrow()
})
})

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import * as SidebarInvariant from '@deepseek-ai/dsh-client-ui-sidebar/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'
describe('invariant companion', () => {
it('registers under the package name with an empty installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(SidebarInvariant).await()).resolves.toBeDefined()
})
it('node-half apply is a no-op host placeholder', async () => {
const { apply } = await import('@deepseek-ai/dsh-client-ui-sidebar')
apply()
expect(true).toBe(true) // reaching here without throw is the contract
})
})

View File

@@ -0,0 +1,195 @@
// @vitest-environment jsdom
/**
* SidebarRoot interaction spec on the real framework stack: real tree store
* (web-react SnapshotStore) feeding the component through the same selector
* hook the inject surface hands out. Covers expand/collapse, subtree unfold,
* search filtering, row activation, and the creation entries.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act } from 'react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import {
createSidebarTreeStore, SidebarRoot,
type SidebarActions, type SidebarTreeStore,
} from '@deepseek-ai/dsh-client-ui-sidebar/client'
const sid = (s: string) => s as SessionId
/** Bare-string init; brands ids and omits absent optional keys (exactOptionalPropertyTypes). */
interface SummaryInit {
id: string
title?: string
cwd?: string
parentId?: string
running?: boolean
updatedAt?: number
}
function summary(init: SummaryInit): SessionSummary {
const s: SessionSummary = {
id: sid(init.id),
title: init.title ?? init.id,
running: init.running ?? false,
updatedAt: init.updatedAt ?? 0,
}
if (init.cwd !== undefined) s.cwd = init.cwd
if (init.parentId !== undefined) s.parentId = sid(init.parentId)
return s
}
function listStateOf(...summaries: SessionSummary[]): SessionListState {
const byId: Record<SessionId, SessionSummary> = {}
for (const s of summaries) byId[s.id] = s
return { ids: summaries.map((s) => s.id), byId }
}
afterEach(cleanup)
function mount(...summaries: SessionSummary[]) {
const list = createSnapshotStore<SessionListState>(listStateOf(...summaries))
const tree: SidebarTreeStore = createSidebarTreeStore({ list })
const current = createSnapshotStore<{ id: SessionId | undefined }>({ id: undefined })
const actions: SidebarActions = {
open: vi.fn((id: SessionId) => { current.update((d) => { d.id = id }) }),
create: vi.fn(),
toggleSidebar: vi.fn(),
}
const utils = render(
<SidebarRoot
useTree={tree.store.useSelector}
useCurrent={() => current.useSelector((s) => s.id)}
actions={actions}
tree={tree}
/>,
)
return { list, tree, current, actions, ...utils }
}
const projectData = () => [
summary({ id: 'root', title: 'root work', cwd: '/proj', updatedAt: 5 }),
summary({ id: 'kid', title: 'forked child', cwd: '/proj', parentId: sid('root'), updatedAt: 4 }),
summary({ id: 'lone', title: 'elsewhere', cwd: '/other', updatedAt: 3 }),
]
describe('SidebarRoot', () => {
it('renders chrome and collapsed project rows', () => {
mount(...projectData())
expect(screen.getByText('HARNESS')).toBeTruthy()
expect(screen.getByText('New Session')).toBeTruthy()
expect(screen.getByText('proj')).toBeTruthy()
expect(screen.getByText('2 sessions')).toBeTruthy()
expect(screen.getByText('1 session')).toBeTruthy()
expect(screen.queryByText('root work')).toBeNull()
})
it('expands a project on click and unfolds a subtree via the twist', () => {
mount(...projectData())
act(() => { fireEvent.click(screen.getByText('proj')) })
expect(screen.getByText('root work')).toBeTruthy()
expect(screen.queryByText('forked child')).toBeNull()
act(() => { fireEvent.click(screen.getByLabelText('Expand')) })
expect(screen.getByText('forked child')).toBeTruthy()
act(() => { fireEvent.click(screen.getByLabelText('Collapse')) })
expect(screen.queryByText('forked child')).toBeNull()
})
it('opens a session on row click and marks it selected', () => {
const { actions } = mount(...projectData())
act(() => { fireEvent.click(screen.getByText('proj')) })
act(() => { fireEvent.click(screen.getByText('root work')) })
expect(actions.open).toHaveBeenCalledWith('root')
expect(screen.getByText('root work').closest('[role="treeitem"]')!.getAttribute('aria-selected')).toBe('true')
})
it('search filters across groups and forces ancestor chains visible', () => {
mount(...projectData())
const input = screen.getByPlaceholderText('Search name, keywords...')
act(() => { fireEvent.change(input, { target: { value: 'forked' } }) })
expect(screen.getByText('forked child')).toBeTruthy()
expect(screen.getByText('root work')).toBeTruthy()
expect(screen.queryByText('elsewhere')).toBeNull()
expect(screen.queryByText(/^other$/)).toBeNull()
act(() => { fireEvent.click(screen.getByLabelText('Clear search')) })
expect(screen.queryByText('root work')).toBeNull()
expect(screen.getByText('proj')).toBeTruthy()
})
it('shows the blank-list empty state without a query', () => {
mount()
expect(screen.getByText('No sessions yet')).toBeTruthy()
})
it('shows the no-match empty state', () => {
mount(...projectData())
const input = screen.getByPlaceholderText('Search name, keywords...')
act(() => { fireEvent.change(input, { target: { value: 'zzz-none' } }) })
expect(screen.getByText('No matches')).toBeTruthy()
})
it('routes the three creation entries with the right cwd', () => {
const { actions } = mount(...projectData())
act(() => { fireEvent.click(screen.getByText('New Session')) })
expect(actions.create).toHaveBeenLastCalledWith()
act(() => { fireEvent.click(screen.getByLabelText('New workspace')) })
expect(actions.create).toHaveBeenLastCalledWith()
// Per-project "+" is hover-revealed by CSS; still clickable in jsdom.
act(() => { fireEvent.click(screen.getAllByLabelText('New session here')[0]!) })
expect(actions.create).toHaveBeenLastCalledWith('/proj')
})
it('collapse button and group-by menu behave', () => {
const { actions } = mount(...projectData())
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
expect(actions.toggleSidebar).toHaveBeenCalledOnce()
expect(screen.queryByText('Update')).toBeNull()
act(() => { fireEvent.click(screen.getByLabelText('Group by')) })
expect(screen.getByText('Update')).toBeTruthy()
expect(screen.getByText('Status')).toBeTruthy()
// Selecting the active strategy closes the list (only workspace is enabled).
act(() => { fireEvent.click(screen.getByText('WorkSpace', { selector: 'button *' })) })
expect(screen.queryByText('Update')).toBeNull()
// Reopen and dismiss via Escape (Menu onClose channel).
act(() => { fireEvent.click(screen.getByLabelText('Group by')) })
act(() => { fireEvent.keyDown(document, { key: 'Escape' }) })
expect(screen.queryByText('Update')).toBeNull()
})
it('re-renders when the sessions list gains a session', async () => {
const { list } = mount(...projectData())
act(() => {
list.update((draft) => {
draft.ids.push(sid('fresh'))
draft.byId[sid('fresh')] = summary({ id: 'fresh', title: 'brand new', cwd: '/fresh', updatedAt: 99 })
})
})
// Store notifications are microtask-batched.
await act(async () => { await Promise.resolve() })
expect(screen.getByText('fresh')).toBeTruthy()
})
it('row "More" anchors swallow the click without opening or toggling', () => {
const { actions, tree } = mount(...projectData())
act(() => { fireEvent.click(screen.getByText('proj')) })
const before = tree.store.getSnapshot().expandedProjects.length
// Project-row anchor: must not collapse the project.
act(() => { fireEvent.click(screen.getAllByLabelText('More')[0]!) })
expect(tree.store.getSnapshot().expandedProjects).toHaveLength(before)
// Session-row anchor: must not open the session.
act(() => { fireEvent.click(screen.getAllByLabelText('More')[1]!) })
expect(actions.open).not.toHaveBeenCalled()
})
it('shows the running state dot only for running sessions', () => {
mount(
summary({ id: 'busy', title: 'busy one', cwd: '/p', running: true, updatedAt: 2 }),
summary({ id: 'idle', title: 'idle one', cwd: '/p', updatedAt: 1 }),
)
act(() => { fireEvent.click(screen.getByText('p')) })
const busyRow = screen.getByText('busy one').closest('[role="treeitem"]')!
const idleRow = screen.getByText('idle one').closest('[role="treeitem"]')!
expect(busyRow.querySelector('[data-state="ongoing"]')).toBeTruthy()
expect(idleRow.querySelector('[data-state="ongoing"]')).toBeNull()
})
})

View File

@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import { createSidebarTreeStore } from '@deepseek-ai/dsh-client-ui-sidebar/client'
const sid = (s: string) => s as SessionId
/** Bare-string init; brands ids and omits absent optional keys (exactOptionalPropertyTypes). */
interface SummaryInit {
id: string
title?: string
cwd?: string
parentId?: string
running?: boolean
updatedAt?: number
}
function summary(init: SummaryInit): SessionSummary {
const s: SessionSummary = {
id: sid(init.id),
title: init.title ?? init.id,
running: init.running ?? false,
updatedAt: init.updatedAt ?? 0,
}
if (init.cwd !== undefined) s.cwd = init.cwd
if (init.parentId !== undefined) s.parentId = sid(init.parentId)
return s
}
function listStateOf(...summaries: SessionSummary[]): SessionListState {
const byId: Record<SessionId, SessionSummary> = {}
for (const s of summaries) byId[s.id] = s
return { ids: summaries.map(s => s.id), byId }
}
function setup(...summaries: SessionSummary[]) {
const list = createSnapshotStore<SessionListState>(listStateOf(...summaries))
const tree = createSidebarTreeStore({ list })
return { list, tree }
}
const flushMicrotasks = () => new Promise<void>((resolve) => { queueMicrotask(resolve) })
describe('createSidebarTreeStore', () => {
it('materializes rows from the initial list snapshot', () => {
const { tree } = setup(summary({ id: 'a', cwd: '/p' }))
expect(tree.store.getSnapshot().rows).toEqual([
expect.objectContaining({ type: 'project', key: '/p', sessionCount: 1 }),
])
})
it('re-derives when the sessions list changes', async () => {
const { list, tree } = setup(summary({ id: 'a', cwd: '/p' }))
list.update((draft) => {
draft.ids.push(sid('b'))
draft.byId[sid('b')] = summary({ id: 'b', cwd: '/q', updatedAt: 99 })
})
// Snapshot-store notifications are microtask-batched.
await flushMicrotasks()
expect(tree.store.getSnapshot().rows.map(r => r.type === 'project' && r.key)).toEqual(['/q', '/p'])
})
it('toggleProject expands and collapses synchronously', () => {
const { tree } = setup(summary({ id: 'a', cwd: '/p' }))
tree.toggleProject('/p')
expect(tree.store.getSnapshot().rows).toHaveLength(2)
tree.toggleProject('/p')
expect(tree.store.getSnapshot().rows).toHaveLength(1)
})
it('toggleSession unfolds a subtree', () => {
const { tree } = setup(
summary({ id: 'root', cwd: '/p', updatedAt: 2 }),
summary({ id: 'kid', cwd: '/p', parentId: sid('root'), updatedAt: 1 }),
)
tree.toggleProject('/p')
expect(tree.store.getSnapshot().rows).toHaveLength(2)
tree.toggleSession(sid('root'))
expect(tree.store.getSnapshot().rows).toHaveLength(3)
})
it('setQuery switches into search mode and back', () => {
const { tree } = setup(
summary({ id: 'a', title: 'needle', cwd: '/p' }),
summary({ id: 'b', title: 'other', cwd: '/q' }),
)
tree.setQuery('needle')
const rows = tree.store.getSnapshot().rows
expect(rows.map(r => r.type)).toEqual(['project', 'session'])
tree.setQuery('')
expect(tree.store.getSnapshot().rows.every(r => r.type === 'project')).toBe(true)
})
it('setGroupBy records the strategy and re-derives', () => {
const { tree } = setup(summary({ id: 'a', cwd: '/p' }))
tree.setGroupBy('workspace')
expect(tree.store.getSnapshot().groupBy).toBe('workspace')
expect(tree.store.getSnapshot().rows).toHaveLength(1)
})
it('dispose stops re-derivation on list changes', async () => {
const { list, tree } = setup(summary({ id: 'a', cwd: '/p' }))
tree.dispose()
list.update((draft) => {
draft.ids.push(sid('b'))
draft.byId[sid('b')] = summary({ id: 'b', cwd: '/q' })
})
await flushMicrotasks()
expect(tree.store.getSnapshot().rows).toHaveLength(1)
})
})

View File

@@ -0,0 +1,234 @@
import { describe, expect, it } from 'vitest'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import {
deriveRows, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL,
type SessionRow, type TreeView,
} from '@deepseek-ai/dsh-client-ui-sidebar/client'
const sid = (s: string) => s as SessionId
/** Bare-string init; brands ids and omits absent optional keys (exactOptionalPropertyTypes). */
interface SummaryInit {
id: string
title?: string
cwd?: string
parentId?: string
running?: boolean
updatedAt?: number
}
function summary(init: SummaryInit): SessionSummary {
const s: SessionSummary = {
id: sid(init.id),
title: init.title ?? init.id,
running: init.running ?? false,
updatedAt: init.updatedAt ?? 0,
}
if (init.cwd !== undefined) s.cwd = init.cwd
if (init.parentId !== undefined) s.parentId = sid(init.parentId)
return s
}
function listOf(...summaries: SessionSummary[]): SessionListState {
const byId: Record<SessionId, SessionSummary> = {}
for (const s of summaries) byId[s.id] = s
return { ids: summaries.map(s => s.id), byId }
}
const view = (partial: Partial<TreeView> = {}): TreeView => ({
expandedProjects: partial.expandedProjects ?? new Set(),
expandedSessions: partial.expandedSessions ?? new Set(),
query: partial.query ?? '',
})
describe('projectLabel', () => {
it('takes the basename and survives trailing separators', () => {
expect(projectLabel('/home/me/proj')).toBe('proj')
expect(projectLabel('/home/me/proj/')).toBe('proj')
expect(projectLabel('C:\\work\\thing')).toBe('thing')
})
it('falls back for empty and root-only paths', () => {
expect(projectLabel(undefined)).toBe(UNGROUPED_LABEL)
expect(projectLabel('')).toBe(UNGROUPED_LABEL)
expect(projectLabel('///')).toBe('///')
})
})
describe('deriveRows grouping', () => {
it('groups by cwd into project rows with counts, newest group first', () => {
const rows = deriveRows(listOf(
summary({ id: 'a', cwd: '/x/alpha', updatedAt: 10 }),
summary({ id: 'b', cwd: '/x/beta', updatedAt: 30 }),
summary({ id: 'c', cwd: '/x/alpha', updatedAt: 20 }),
), view())
expect(rows).toEqual([
expect.objectContaining({ type: 'project', key: '/x/beta', label: 'beta', sessionCount: 1, expanded: false }),
expect.objectContaining({ type: 'project', key: '/x/alpha', label: 'alpha', sessionCount: 2 }),
])
})
it('orders equally-recent groups by label and skips ids missing from byId', () => {
const list = listOf(
summary({ id: 'b1', cwd: '/x/beta', updatedAt: 5 }),
summary({ id: 'a1', cwd: '/x/alpha', updatedAt: 5 }),
// Same basename and same recency as beta: label comparator returns 0,
// insertion order breaks the tie.
summary({ id: 'b2', cwd: '/y/beta', updatedAt: 5 }),
)
list.ids.push(sid('ghost'))
const rows = deriveRows(list, view())
expect(rows.map(r => r.type === 'project' && r.key)).toEqual(['/x/alpha', '/x/beta', '/y/beta'])
})
it('buckets cwd-less sessions under the ungrouped project row', () => {
const rows = deriveRows(listOf(summary({ id: 'a' })), view())
expect(rows).toEqual([
expect.objectContaining({ type: 'project', key: UNGROUPED_KEY, cwd: undefined, label: UNGROUPED_LABEL }),
])
})
it('hides sessions under collapsed projects and shows them when expanded', () => {
const list = listOf(
summary({ id: 'a', cwd: '/p', updatedAt: 1 }),
summary({ id: 'b', cwd: '/p', updatedAt: 2 }),
)
expect(deriveRows(list, view()).filter(r => r.type === 'session')).toHaveLength(0)
const rows = deriveRows(list, view({ expandedProjects: new Set(['/p']) }))
expect(rows.slice(1)).toEqual([
expect.objectContaining({ type: 'session', id: 'b', depth: 0 }),
expect.objectContaining({ type: 'session', id: 'a', depth: 0 }),
])
})
})
describe('deriveRows session tree', () => {
const treeList = listOf(
summary({ id: 'root', cwd: '/p', updatedAt: 5 }),
summary({ id: 'kid', cwd: '/p', parentId: sid('root'), updatedAt: 4 }),
summary({ id: 'grandkid', cwd: '/p', parentId: sid('kid'), updatedAt: 3 }),
summary({ id: 'other', cwd: '/p', updatedAt: 9 }),
)
it('nests children under expanded parents with increasing depth', () => {
const rows = deriveRows(treeList, view({
expandedProjects: new Set(['/p']),
expandedSessions: new Set(['root', 'kid']),
}))
expect(rows.slice(1)).toEqual([
expect.objectContaining({ id: 'other', depth: 0, hasChildren: false }),
expect.objectContaining({ id: 'root', depth: 0, hasChildren: true, expanded: true }),
expect.objectContaining({ id: 'kid', depth: 1, hasChildren: true, expanded: true }),
expect.objectContaining({ id: 'grandkid', depth: 2, hasChildren: false }),
])
})
it('collapses subtrees at unexpanded sessions', () => {
const rows = deriveRows(treeList, view({ expandedProjects: new Set(['/p']) }))
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
expect(ids).toEqual(['other', 'root'])
})
it('degrades a cross-group parent link to a group root', () => {
const rows = deriveRows(listOf(
summary({ id: 'p1', cwd: '/a', updatedAt: 2 }),
summary({ id: 'stray', cwd: '/b', parentId: sid('p1'), updatedAt: 1 }),
), view({ expandedProjects: new Set(['/a', '/b']) }))
expect(rows).toEqual([
expect.objectContaining({ type: 'project', key: '/a' }),
expect.objectContaining({ id: 'p1', depth: 0 }),
expect.objectContaining({ type: 'project', key: '/b' }),
expect.objectContaining({ id: 'stray', depth: 0 }),
])
})
it('keeps cycle members visible as extra roots without looping', () => {
const rows = deriveRows(listOf(
summary({ id: 'x', cwd: '/p', parentId: sid('y'), updatedAt: 2 }),
summary({ id: 'y', cwd: '/p', parentId: sid('x'), updatedAt: 1 }),
summary({ id: 'self', cwd: '/p', parentId: sid('self'), updatedAt: 3 }),
), view({ expandedProjects: new Set(['/p']), expandedSessions: new Set(['x', 'y', 'self']) }))
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
expect(ids).toContain('self')
expect(ids).toContain('x')
expect(ids).toContain('y')
expect(ids).toHaveLength(3)
})
it('breaks updatedAt ties deterministically by id', () => {
const rows = deriveRows(listOf(
summary({ id: 'b', cwd: '/p', updatedAt: 7 }),
summary({ id: 'a', cwd: '/p', updatedAt: 7 }),
summary({ id: 'c', cwd: '/p', updatedAt: 7 }),
), view({ expandedProjects: new Set(['/p']) }))
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
expect(ids).toEqual(['a', 'b', 'c'])
})
it('collects multiple children under one parent in recency order', () => {
const rows = deriveRows(listOf(
summary({ id: 'p', cwd: '/p', updatedAt: 9 }),
summary({ id: 'old', cwd: '/p', parentId: sid('p'), updatedAt: 1 }),
summary({ id: 'new', cwd: '/p', parentId: sid('p'), updatedAt: 5 }),
), view({ expandedProjects: new Set(['/p']), expandedSessions: new Set(['p']) }))
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
expect(ids).toEqual(['p', 'new', 'old'])
})
it('carries the running flag onto rows', () => {
const rows = deriveRows(
listOf(summary({ id: 'a', cwd: '/p', running: true })),
view({ expandedProjects: new Set(['/p']) }))
expect(rows[1]).toEqual(expect.objectContaining({ id: 'a', running: true }))
})
})
describe('deriveRows search', () => {
const list = listOf(
summary({ id: 'root', title: 'alpha work', cwd: '/p', updatedAt: 5 }),
summary({ id: 'kid', title: 'deep needle here', cwd: '/p', parentId: sid('root'), updatedAt: 4 }),
summary({ id: 'noise', title: 'unrelated', cwd: '/p', updatedAt: 3 }),
summary({ id: 'q', title: 'quiet', cwd: '/other', updatedAt: 2 }),
)
it('forces matched sessions and their ancestor chains visible, ignoring expansion', () => {
const rows = deriveRows(list, view({ query: 'NEEDLE' }))
expect(rows).toEqual([
expect.objectContaining({ type: 'project', key: '/p', expanded: true }),
expect.objectContaining({ id: 'root', depth: 0, expanded: true }),
expect.objectContaining({ id: 'kid', depth: 1 }),
])
})
it('drops groups without a hit and keeps a bare project row on label-only hits', () => {
const rows = deriveRows(list, view({ query: 'other' }))
expect(rows).toEqual([
expect.objectContaining({ type: 'project', key: '/other', expanded: false }),
])
})
it('blank query means normal mode', () => {
const rows = deriveRows(list, view({ query: ' ' }))
expect(rows.every(r => r.type === 'project')).toBe(true)
})
})
describe('formatRelativeTime', () => {
const now = 1_000_000_000_000
it.each([
[now, 'now'],
[now - 30_000, 'now'],
[now - 2 * 60_000, '2min'],
[now - 3_600_000, '1h'],
[now - 2 * 86_400_000, '2d'],
[now - 18 * 86_400_000, '18d'],
[now - 65 * 86_400_000, '2mo'],
[now - 400 * 86_400_000, '1y'],
])('%d -> %s', (at, label) => {
expect(formatRelativeTime(at, now)).toBe(label)
})
it('clamps future timestamps to now', () => {
expect(formatRelativeTime(now + 5_000, now)).toBe('now')
})
})