Files
web-terminal/test/projects-panel.test.ts
Yaojia Wang f711db9315 fix(projects): build the git panel the way the design specifies
The shipped panel was functionally right — every number matched git — but it
was not the approved design. I implemented from the plan's semantics (which
state is green, when to flag stale) and let the mock's layout drift, and
nothing caught it: the tests asserted behaviour, and no assertion covered
structure. Three deviations, now closed against docs/mockups/project-detail-git.html.

1. The sync band was compressed into one row of chips. Restored to the four
   labelled cells: Upstream / To push / To pull / Fetch, with display-size
   numerals and a footnote under each count. The footnotes are load-bearing,
   not decoration — "Last fetched 19d ago — this number cannot be trusted" is
   what tells a reader WHY the zero is suspect. Compressed to
   "fetched 19d ago", the causal link was left for the reader to infer, and
   not having to infer it is the entire point of the panel. The unverified
   state is a chip on the count again, so the doubt attaches to the digit.

2. The working-tree count sat inside the band. Moved to the title line beside
   the branch, where the design puts it: the band is about the remote, and the
   dirty count is not. Reads "● 3 uncommitted" instead of a bare dot; the dot
   remains as the fallback when a server sends no count.

3. Worktree rows were the old single line with the path right-aligned against
   the chips. Rebuilt as the two-line card the design calls for — identity and
   state on top, path underneath — plus the Open button, which was specified
   and simply missing, so a worktree row could be read but not entered. Open
   reuses the detail view pointed at the worktree path: a linked worktree is a
   project directory, so this needs no new concept and no new route.

Also added the unpushed count beside the commit-list heading, so the number is
readable before the eye walks the rows hunting for rails.

Tests now assert structure, not just behaviour: cell captions exist, the stale
footnote says why, counts render at display size, the path is outside the chip
row, and Open fires with its worktree. That is the gap that let the drift
through, so it is the gap that is now covered.

Verified: tsc and build clean; npm test green (unit 78 files / 2159, e2e 27).
2026-07-29 18:22:50 +02:00

993 lines
39 KiB
TypeScript

// @vitest-environment jsdom
/**
* test/projects-panel.test.ts — unit tests for pure helpers in public/projects.ts.
*
* Covers filterProjects, sortProjects, toggleFav, getFavs/saveFavs.
* localStorage is provided by the jsdom environment.
* mountProjects (DOM wiring) is thin glue and not unit-tested here.
*
* @xterm/xterm is mocked because projects.ts imports preview-grid.ts which
* uses Terminal. The mock must be declared before the dynamic import below.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { ProjectInfo } from '../src/types.js'
// ── Stub @xterm/xterm (transitively required via preview-grid.ts) ─────────────
class FakeTerminal {
open = vi.fn()
dispose = vi.fn()
}
vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal }))
// Stub the PR-status chip (W3) so renderProjectDetail's header mount doesn't hit
// the network; the chip's own logic is covered in test/gh-chip.test.ts.
vi.mock('../public/gh-chip.js', () => ({ mountPrChip: vi.fn(() => ({ destroy: vi.fn() })) }))
// Dynamic import AFTER mock declaration so the factory is in place.
const {
filterProjects,
sortProjects,
getFavs,
saveFavs,
toggleFav,
normalizeProject,
makeProjectCard,
makeSyncChip,
makeSyncBand,
makeWorktreeRow,
countSessionsByWorktree,
renderProjectDetail,
groupProjects,
displayLabel,
ACTIVE_GROUP_KEY,
OTHER_GROUP_KEY,
} = await import('../public/projects.js')
/* ── Helpers ───────────────────────────────────────────────────────────────── */
function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
return {
name: 'test-repo',
path: '/home/user/test-repo',
isGit: true,
sessions: [],
...overrides,
}
}
/* ── filterProjects ────────────────────────────────────────────────────────── */
describe('filterProjects', () => {
const projects: ProjectInfo[] = [
makeProject({ name: 'web-terminal', path: '/home/user/web-terminal' }),
makeProject({ name: 'api-service', path: '/home/user/projects/api-service' }),
makeProject({ name: 'utils', path: '/home/user/utils' }),
]
it('returns all projects when query is empty string', () => {
expect(filterProjects(projects, '')).toHaveLength(3)
})
it('returns all projects when query is only whitespace', () => {
expect(filterProjects(projects, ' ')).toHaveLength(3)
})
it('filters by name substring (case-insensitive)', () => {
const result = filterProjects(projects, 'WEB')
expect(result).toHaveLength(1)
expect(result[0]!.name).toBe('web-terminal')
})
it('filters by path substring', () => {
const result = filterProjects(projects, 'projects')
expect(result).toHaveLength(1)
expect(result[0]!.name).toBe('api-service')
})
it('is case-insensitive on path', () => {
const result = filterProjects(projects, 'UTILS')
expect(result).toHaveLength(1)
expect(result[0]!.name).toBe('utils')
})
it('returns empty array when no project matches', () => {
expect(filterProjects(projects, 'zzz-no-match-at-all')).toHaveLength(0)
})
it('does not mutate the input array', () => {
const original = [...projects]
filterProjects(projects, 'web')
expect(projects).toEqual(original)
})
it('returns a new array each time', () => {
const r1 = filterProjects(projects, '')
const r2 = filterProjects(projects, '')
expect(r1).not.toBe(r2) // different references
})
})
/* ── sortProjects ──────────────────────────────────────────────────────────── */
describe('sortProjects', () => {
const a = makeProject({ name: 'a', path: '/a', lastActiveMs: 1000 })
const b = makeProject({ name: 'b', path: '/b', lastActiveMs: 3000 })
const c = makeProject({ name: 'c', path: '/c', lastActiveMs: 2000 })
it('places favourites before non-favourites', () => {
const favs = new Set(['/c'])
const sorted = sortProjects([a, b, c], favs)
expect(sorted[0]!.path).toBe('/c')
})
it('among non-favourites sorts by lastActiveMs descending', () => {
const sorted = sortProjects([a, b, c], new Set())
expect(sorted[0]!.path).toBe('/b') // 3000 ms
expect(sorted[1]!.path).toBe('/c') // 2000 ms
expect(sorted[2]!.path).toBe('/a') // 1000 ms
})
it('among favourites also sorts by lastActiveMs descending', () => {
const favs = new Set(['/a', '/b'])
const sorted = sortProjects([a, b, c], favs)
expect(sorted[0]!.path).toBe('/b') // fav, 3000 ms
expect(sorted[1]!.path).toBe('/a') // fav, 1000 ms
expect(sorted[2]!.path).toBe('/c') // non-fav, 2000 ms
})
it('handles missing lastActiveMs (treats as 0)', () => {
const noMs = makeProject({ path: '/x' })
const withMs = makeProject({ path: '/y', lastActiveMs: 500 })
const sorted = sortProjects([noMs, withMs], new Set())
expect(sorted[0]!.path).toBe('/y')
expect(sorted[1]!.path).toBe('/x')
})
it('does not mutate the input array', () => {
const arr = [a, b, c]
const origFirst = arr[0]!.path
sortProjects(arr, new Set())
expect(arr[0]!.path).toBe(origFirst)
})
it('returns a new array', () => {
const arr = [a, b, c]
const sorted = sortProjects(arr, new Set())
expect(sorted).not.toBe(arr)
})
it('returns empty array for empty input', () => {
expect(sortProjects([], new Set())).toEqual([])
})
})
/* ── toggleFav ─────────────────────────────────────────────────────────────── */
describe('toggleFav', () => {
it('adds a path that is not present', () => {
const original = new Set(['/a'])
const next = toggleFav(original, '/b')
expect(next.has('/b')).toBe(true)
expect(next.has('/a')).toBe(true)
})
it('removes a path that is already present', () => {
const original = new Set(['/a', '/b'])
const next = toggleFav(original, '/a')
expect(next.has('/a')).toBe(false)
expect(next.has('/b')).toBe(true)
})
it('does not mutate the original set', () => {
const original = new Set(['/a'])
toggleFav(original, '/a')
expect(original.has('/a')).toBe(true) // unchanged
})
it('returns a new Set instance', () => {
const original = new Set(['/a'])
const next = toggleFav(original, '/b')
expect(next).not.toBe(original)
})
it('works on an empty set (add case)', () => {
const next = toggleFav(new Set(), '/x')
expect(next.has('/x')).toBe(true)
expect(next.size).toBe(1)
})
})
/* ── getFavs / saveFavs (localStorage persistence) ─────────────────────────── */
describe('getFavs / saveFavs', () => {
beforeEach(() => {
localStorage.clear()
})
it('getFavs returns empty set when no data is stored', () => {
expect(getFavs().size).toBe(0)
})
it('saveFavs + getFavs round-trips a set of paths', () => {
const paths = new Set(['/home/user/proj-a', '/home/user/proj-b'])
saveFavs(paths)
const loaded = getFavs()
expect(loaded.has('/home/user/proj-a')).toBe(true)
expect(loaded.has('/home/user/proj-b')).toBe(true)
expect(loaded.size).toBe(2)
})
it('getFavs returns empty set on malformed JSON', () => {
localStorage.setItem('web-terminal:proj-favs', 'NOT_JSON{{{')
expect(getFavs().size).toBe(0)
})
it('getFavs returns empty set when stored value is not an array', () => {
localStorage.setItem('web-terminal:proj-favs', JSON.stringify({ not: 'an-array' }))
expect(getFavs().size).toBe(0)
})
it('getFavs returns empty set when stored null', () => {
localStorage.setItem('web-terminal:proj-favs', 'null')
expect(getFavs().size).toBe(0)
})
it('saveFavs persists an empty set (removes all favs)', () => {
saveFavs(new Set(['/a']))
saveFavs(new Set())
expect(getFavs().size).toBe(0)
})
it('getFavs drops non-string entries from a corrupt/tampered array', () => {
localStorage.setItem('web-terminal:proj-favs', JSON.stringify(['/a', 42, null, '/b', { x: 1 }]))
const favs = getFavs()
expect([...favs].sort()).toEqual(['/a', '/b'])
})
})
/* ── normalizeProject (untrusted /projects element coercion) ────────────────── */
describe('normalizeProject', () => {
it('passes a well-formed project through, defaulting sessions to []', () => {
const p = normalizeProject({ name: 'web', path: '/home/u/web', isGit: true })
expect(p).toEqual({ name: 'web', path: '/home/u/web', isGit: true, sessions: [] })
})
it('preserves a sessions array and optional fields', () => {
const sess = { id: 's1', status: 'idle', clientCount: 0, createdAt: 1, exited: false }
const p = normalizeProject({
name: 'web', path: '/p', isGit: true, branch: 'main', dirty: true, lastActiveMs: 99, sessions: [sess],
})
expect(p?.branch).toBe('main')
expect(p?.dirty).toBe(true)
expect(p?.lastActiveMs).toBe(99)
expect(p?.sessions).toHaveLength(1)
})
it('coerces a missing sessions field to [] (prevents card-render TypeError)', () => {
const p = normalizeProject({ name: 'web', path: '/p', isGit: true, sessions: undefined })
expect(p?.sessions).toEqual([])
})
it('returns null for a non-object', () => {
expect(normalizeProject(null)).toBeNull()
expect(normalizeProject('x')).toBeNull()
expect(normalizeProject(42)).toBeNull()
})
it('returns null when name or path is missing/non-string', () => {
expect(normalizeProject({ path: '/p' })).toBeNull()
expect(normalizeProject({ name: 'web' })).toBeNull()
expect(normalizeProject({ name: 1, path: '/p' })).toBeNull()
})
it('drops optional fields of the wrong type', () => {
const p = normalizeProject({ name: 'web', path: '/p', branch: 5, dirty: 'yes', lastActiveMs: 'x' })
expect(p?.branch).toBeUndefined()
expect(p?.dirty).toBeUndefined()
expect(p?.lastActiveMs).toBeUndefined()
expect(p?.isGit).toBe(false)
})
it('passes through numeric sync fields (W3 a)', () => {
const p = normalizeProject({ name: 'web', path: '/p', ahead: 2, behind: 1, lastCommitMs: 123456 })
expect(p?.ahead).toBe(2)
expect(p?.behind).toBe(1)
expect(p?.lastCommitMs).toBe(123456)
})
it('drops non-numeric sync fields (W3 a)', () => {
const p = normalizeProject({ name: 'web', path: '/p', ahead: '2', behind: null, lastCommitMs: 'x' })
expect(p?.ahead).toBeUndefined()
expect(p?.behind).toBeUndefined()
expect(p?.lastCommitMs).toBeUndefined()
})
})
/* ── makeSyncChip / sync chip on the card (W3 a) ─────────────────────────────── */
describe('makeSyncChip', () => {
it('renders ↑ahead ↓behind when there is drift', () => {
const chip = makeSyncChip(makeProject({ ahead: 2, behind: 1 }))
expect(chip).not.toBeNull()
expect(chip?.className).toContain('proj-sync')
expect(chip?.textContent).toBe('↑2 ↓1')
})
it('shows only the ahead arrow when behind is 0', () => {
const chip = makeSyncChip(makeProject({ ahead: 3, behind: 0 }))
expect(chip?.textContent).toBe('↑3')
})
it('returns null when in sync (ahead=behind=0)', () => {
expect(makeSyncChip(makeProject({ ahead: 0, behind: 0 }))).toBeNull()
})
it('returns null when ahead/behind are undefined', () => {
expect(makeSyncChip(makeProject())).toBeNull()
})
it('includes the last-commit time in the tooltip when present', () => {
const chip = makeSyncChip(makeProject({ ahead: 1, lastCommitMs: Date.now() - 3600_000 }))
expect(chip?.title).toContain('last commit')
})
})
describe('makeProjectCard — sync chip', () => {
const noopHooks = () => ({ onOpenProject: vi.fn(), onEnterSession: vi.fn() })
it('renders the sync chip when the project has drift', () => {
const card = makeProjectCard(makeProject({ ahead: 2, behind: 1 }), new Set(), noopHooks(), () => {})
expect(card.querySelector('.proj-sync')?.textContent).toBe('↑2 ↓1')
})
it('omits the sync chip when in sync / undefined', () => {
const inSync = makeProjectCard(makeProject({ ahead: 0, behind: 0 }), new Set(), noopHooks(), () => {})
expect(inSync.querySelector('.proj-sync')).toBeNull()
const noData = makeProjectCard(makeProject(), new Set(), noopHooks(), () => {})
expect(noData.querySelector('.proj-sync')).toBeNull()
})
})
/* ── makeProjectCard launcher row (Claude · Codex · VS Code) ────────────────── */
describe('makeProjectCard — launcher row', () => {
const noopHooks = () => ({ onOpenProject: vi.fn(), onEnterSession: vi.fn() })
it('renders exactly three brand launcher buttons', () => {
const card = makeProjectCard(makeProject(), new Set(), noopHooks(), () => {})
expect(card.querySelectorAll('.proj-launch')).toHaveLength(3)
expect(card.querySelector('.proj-launch-claude')).not.toBeNull()
expect(card.querySelector('.proj-launch-codex')).not.toBeNull()
expect(card.querySelector('.proj-launch-vscode')).not.toBeNull()
// The old single-button action is gone.
expect(card.querySelector('.proj-new')).toBeNull()
})
it('each launcher embeds an inline SVG logo + caption', () => {
const card = makeProjectCard(makeProject(), new Set(), noopHooks(), () => {})
const claude = card.querySelector('.proj-launch-claude')!
expect(claude.querySelector('svg')).not.toBeNull()
expect(claude.querySelector('.proj-launch-cap')?.textContent).toBe('Claude')
})
it('Claude launcher opens the project with the claude command (with CR)', () => {
const hooks = noopHooks()
const card = makeProjectCard(makeProject({ name: 'repo', path: '/p/repo' }), new Set(), hooks, () => {})
;(card.querySelector('.proj-launch-claude') as HTMLButtonElement).click()
expect(hooks.onOpenProject).toHaveBeenCalledWith('/p/repo', 'repo', 'claude\r')
})
it('Codex launcher opens the project with the codex command (with CR)', () => {
const hooks = noopHooks()
const card = makeProjectCard(makeProject({ name: 'repo', path: '/p/repo' }), new Set(), hooks, () => {})
;(card.querySelector('.proj-launch-codex') as HTMLButtonElement).click()
expect(hooks.onOpenProject).toHaveBeenCalledWith('/p/repo', 'repo', 'codex\r')
})
it('still lists running sessions above the launcher row', () => {
const p = makeProject({
sessions: [{ id: 's1', status: 'idle', clientCount: 1, createdAt: 1, exited: false }],
})
const card = makeProjectCard(p, new Set(), noopHooks(), () => {})
expect(card.querySelector('.proj-sessions-list')).not.toBeNull()
expect(card.querySelectorAll('.proj-launch')).toHaveLength(3)
})
it('shows a Kill (✕) on each session row only when onKillSession is given', () => {
const p = makeProject({
sessions: [{ id: 's1', status: 'idle', clientCount: 1, createdAt: 1, exited: false }],
})
// Without the callback: no kill button (enter-only).
const plain = makeProjectCard(p, new Set(), noopHooks(), () => {})
expect(plain.querySelector('.proj-session-kill')).toBeNull()
// With the callback: clicking ✕ kills that session and does not enter it.
const hooks = noopHooks()
const onKill = vi.fn()
const card = makeProjectCard(p, new Set(), hooks, () => {}, undefined, onKill)
const kill = card.querySelector('.proj-session-kill') as HTMLButtonElement
expect(kill).not.toBeNull()
kill.click()
expect(onKill).toHaveBeenCalledWith('s1')
expect(hooks.onEnterSession).not.toHaveBeenCalled()
})
it('highlights the Claude launcher when a Claude session (known status) is live', () => {
const p = makeProject({
sessions: [{ id: 's1', status: 'working', clientCount: 0, createdAt: 1, exited: false }],
})
const card = makeProjectCard(p, new Set(), noopHooks(), () => {})
expect(card.querySelector('.proj-launch-claude')!.classList.contains('proj-launch-active')).toBe(true)
// Codex / VS Code are not highlighted by a Claude session.
expect(card.querySelector('.proj-launch-codex')!.classList.contains('proj-launch-active')).toBe(false)
})
it('does NOT highlight Claude for an unknown-status session (plain shell / codex)', () => {
const p = makeProject({
sessions: [{ id: 's1', status: 'unknown', clientCount: 0, createdAt: 1, exited: false }],
})
const card = makeProjectCard(p, new Set(), noopHooks(), () => {})
expect(card.querySelector('.proj-launch-claude')!.classList.contains('proj-launch-active')).toBe(false)
})
it('does NOT highlight Claude for an exited session', () => {
const p = makeProject({
sessions: [{ id: 's1', status: 'idle', clientCount: 0, createdAt: 1, exited: true }],
})
const card = makeProjectCard(p, new Set(), noopHooks(), () => {})
expect(card.querySelector('.proj-launch-claude')!.classList.contains('proj-launch-active')).toBe(false)
})
it('shows a count badge when more than one Claude session is live', () => {
const p = makeProject({
sessions: [
{ id: 's1', status: 'working', clientCount: 0, createdAt: 1, exited: false },
{ id: 's2', status: 'idle', clientCount: 0, createdAt: 2, exited: false },
],
})
const card = makeProjectCard(p, new Set(), noopHooks(), () => {})
expect(card.querySelector('.proj-launch-claude .proj-launch-badge')?.textContent).toBe('2')
})
it('makes the name a detail link only when onOpenDetail is given', () => {
const onOpenDetail = vi.fn()
const card = makeProjectCard(makeProject({ path: '/p/r' }), new Set(), noopHooks(), () => {}, onOpenDetail)
const name = card.querySelector('.proj-name') as HTMLElement
expect(name.classList.contains('proj-name-link')).toBe(true)
name.click()
expect(onOpenDetail).toHaveBeenCalledWith('/p/r')
const plain = makeProjectCard(makeProject(), new Set(), noopHooks(), () => {})
expect(plain.querySelector('.proj-name')!.classList.contains('proj-name-link')).toBe(false)
})
})
/* ── renderProjectDetail (branch/worktrees + active sessions) ───────────────── */
describe('renderProjectDetail', () => {
const hooks = () => ({ onOpenProject: vi.fn(), onEnterSession: vi.fn() })
const cbs = () => ({ onBack: vi.fn(), onKill: vi.fn() })
function detail(over: Partial<import('../src/types.js').ProjectDetail> = {}) {
return {
name: 'repo',
path: '/p/repo',
isGit: true,
branch: 'main',
worktrees: [],
sessions: [],
...over,
}
}
it('renders "Project not found" + working Back for a null detail', () => {
const cb = cbs()
const root = renderProjectDetail(null, hooks(), cb)
expect(root.textContent).toContain('Project not found')
;(root.querySelector('.proj-back') as HTMLButtonElement).click()
expect(cb.onBack).toHaveBeenCalled()
})
it('shows the header name, path, and branch', () => {
const root = renderProjectDetail(detail(), hooks(), cbs())
expect(root.querySelector('.proj-detail-name')?.textContent).toBe('repo')
expect(root.querySelector('.proj-detail-path')?.textContent).toBe('/p/repo')
expect(root.querySelector('.proj-branch')?.textContent).toBe('main')
})
it('lists worktrees with branch, tags, and path', () => {
const root = renderProjectDetail(
detail({
worktrees: [
{ path: '/p/repo', branch: 'main', head: 'abc12345', isMain: true, isCurrent: true },
{ path: '/p/wt/feat', branch: 'feature/x', head: 'def67890', isMain: false, isCurrent: false },
],
}),
hooks(),
cbs(),
)
const rows = root.querySelectorAll('.proj-wt-row')
expect(rows).toHaveLength(2)
expect(rows[0]?.querySelector('.proj-wt-branch')?.textContent).toBe('main')
expect(rows[0]?.querySelector('.proj-wt-tag-current')).not.toBeNull()
expect(rows[1]?.querySelector('.proj-wt-branch')?.textContent).toBe('feature/x')
})
it('renders detached worktrees as "detached @ head"', () => {
const root = renderProjectDetail(
detail({ worktrees: [{ path: '/p/repo', head: 'deadbeef', isMain: true, isCurrent: true }] }),
hooks(),
cbs(),
)
expect(root.querySelector('.proj-wt-branch')?.textContent).toBe('detached @ deadbeef')
})
it('lists active sessions with Open + Kill wired, ignoring exited ones', () => {
const h = hooks()
const cb = cbs()
const root = renderProjectDetail(
detail({
sessions: [
{ id: 's1', title: 'claude', status: 'working', clientCount: 2, createdAt: 1, exited: false },
{ id: 'gone', status: 'idle', clientCount: 0, createdAt: 1, exited: true },
],
}),
h,
cb,
)
const rows = root.querySelectorAll('.proj-dsession')
expect(rows).toHaveLength(1)
;(rows[0]!.querySelector('.proj-dsession-open') as HTMLButtonElement).click()
expect(h.onEnterSession).toHaveBeenCalledWith('s1')
;(rows[0]!.querySelector('.proj-dsession-kill') as HTMLButtonElement).click()
expect(cb.onKill).toHaveBeenCalledWith('s1')
})
it('shows an empty-state when no sessions are running, plus the launcher row', () => {
const root = renderProjectDetail(detail(), hooks(), cbs())
expect(root.textContent).toContain('No running sessions')
expect(root.querySelectorAll('.proj-launch')).toHaveLength(3)
})
it('flags a non-git directory', () => {
const root = renderProjectDetail(detail({ isGit: false, branch: undefined }), hooks(), cbs())
expect(root.textContent).toContain('Not a git repository')
})
it('mounts a PR-status chip host in the header for a git repo (W3)', () => {
const root = renderProjectDetail(detail(), hooks(), cbs())
expect(root.querySelector('.proj-pr-host')).not.toBeNull()
})
it('omits the PR-status chip host for a non-git directory (W3)', () => {
const root = renderProjectDetail(detail({ isGit: false, branch: undefined }), hooks(), cbs())
expect(root.querySelector('.proj-pr-host')).toBeNull()
})
it('shows CLAUDE.md content + an Update button when present', () => {
const root = renderProjectDetail(detail({ hasClaudeMd: true, claudeMd: '# Rules\nbe nice' }), hooks(), cbs())
expect(root.querySelector('.proj-claudemd')?.textContent).toContain('be nice')
expect(root.querySelector('.proj-claudemd-btn')?.textContent).toContain('Update')
})
it('shows a Generate button + empty state when CLAUDE.md is absent', () => {
const root = renderProjectDetail(detail({ hasClaudeMd: false }), hooks(), cbs())
expect(root.querySelector('.proj-claudemd')).toBeNull()
expect(root.textContent).toContain('No CLAUDE.md yet')
expect(root.querySelector('.proj-claudemd-btn')?.textContent).toContain('Generate')
})
it('the CLAUDE.md button opens a Claude /init session in the repo', () => {
const h = hooks()
const root = renderProjectDetail(
detail({ name: 'r', path: '/p/r', hasClaudeMd: true, claudeMd: 'x' }),
h,
cbs(),
)
;(root.querySelector('.proj-claudemd-btn') as HTMLButtonElement).click()
expect(h.onOpenProject).toHaveBeenCalledWith('/p/r', 'r', 'claude "/init"\r')
})
})
/* ── groupProjects / displayLabel (namespace grouping, v0.6) ─────────────────── */
const runningSession = { id: 's', status: 'working' as const, clientCount: 1, createdAt: 1, exited: false }
function proj(name: string, over: Partial<ProjectInfo> = {}): ProjectInfo {
return makeProject({ name, path: `/repos/${name}`, ...over })
}
describe('groupProjects', () => {
const noFavs = new Set<string>()
it('groups repos sharing a two-segment namespace (≥2 members)', () => {
const groups = groupProjects(
[proj('Billo.Platform.Payment'), proj('Billo.Platform.Document')],
noFavs,
)
expect(groups).toHaveLength(1)
expect(groups[0].kind).toBe('namespace')
expect(groups[0].label).toBe('Billo.Platform')
expect(groups[0].projects.map((p) => p.name)).toEqual([
'Billo.Platform.Payment',
'Billo.Platform.Document',
])
})
it('drops single-member namespaces and no-dot repos into "Other"', () => {
const groups = groupProjects(
[
proj('Billo.Platform.Payment'),
proj('Billo.Platform.Document'),
proj('Billo.Customer'), // lone "Billo.Customer" namespace
proj('web-terminal'), // no dot
],
noFavs,
)
const other = groups.find((g) => g.kind === 'other')
expect(other).toBeDefined()
expect(other!.projects.map((p) => p.name).sort()).toEqual(['Billo.Customer', 'web-terminal'])
})
it('falls back to a single flat group when no namespace earns a section', () => {
const groups = groupProjects([proj('web-terminal'), proj('graphrag'), proj('piano')], noFavs)
expect(groups).toHaveLength(1)
expect(groups[0].kind).toBe('flat')
expect(groups[0].projects).toHaveLength(3)
})
it('pins an "Active now" section first with projects that have a running session', () => {
const groups = groupProjects(
[
proj('Billo.Platform.Payment', { sessions: [runningSession] }),
proj('Billo.Platform.Document'),
],
noFavs,
)
expect(groups[0].kind).toBe('active')
expect(groups[0].label).toBe('Active now')
expect(groups[0].projects.map((p) => p.name)).toEqual(['Billo.Platform.Payment'])
// still also present inside its namespace group (deliberate duplication)
const ns = groups.find((g) => g.kind === 'namespace')!
expect(ns.projects.map((p) => p.name)).toContain('Billo.Platform.Payment')
expect(ns.activeCount).toBe(1)
})
it('orders namespace sections by their most-recently-active member', () => {
const groups = groupProjects(
[
proj('Old.Alpha.a', { lastActiveMs: 10 }),
proj('Old.Alpha.b', { lastActiveMs: 20 }),
proj('New.Beta.a', { lastActiveMs: 900 }),
proj('New.Beta.b', { lastActiveMs: 100 }),
],
noFavs,
)
const ns = groups.filter((g) => g.kind === 'namespace').map((g) => g.label)
expect(ns).toEqual(['New.Beta', 'Old.Alpha'])
})
it('keeps "Other" last', () => {
const groups = groupProjects(
[proj('Billo.Platform.Payment'), proj('Billo.Platform.Document'), proj('loose')],
noFavs,
)
expect(groups[groups.length - 1].kind).toBe('other')
})
it('merges namespaces case-insensitively', () => {
const groups = groupProjects([proj('Billo.Platform.a'), proj('billo.platform.b')], noFavs)
expect(groups).toHaveLength(1)
expect(groups[0].projects).toHaveLength(2)
})
it('does not mutate the input array', () => {
const input = [proj('A.b.c'), proj('A.b.d')]
const copy = [...input]
groupProjects(input, noFavs)
expect(input).toEqual(copy)
})
})
describe('displayLabel', () => {
it('strips the namespace prefix inside a namespace group', () => {
expect(displayLabel('Billo.Platform.Payment', 'Billo.Platform')).toBe('Payment')
expect(displayLabel('Billo.Platform.Shared.DomainEvents', 'Billo.Platform')).toBe(
'Shared.DomainEvents',
)
})
it('shows the full name in the active and other sections', () => {
expect(displayLabel('Billo.Platform.Payment', ACTIVE_GROUP_KEY)).toBe('Billo.Platform.Payment')
expect(displayLabel('web-terminal', OTHER_GROUP_KEY)).toBe('web-terminal')
})
it('is case-insensitive on the prefix but preserves the tail casing', () => {
expect(displayLabel('Billo.Platform.Payment', 'billo.platform')).toBe('Payment')
})
})
/* ── w6/G3 makeSyncBand — the header's ambient git state ────────────────────── */
// The band's contract is mostly about what it must REFUSE to say: a stale or
// unknowable comparison must never be dressed up as "in sync".
describe('makeSyncBand (w6 G3)', () => {
const NOW = 1_700_000_000_000
const FRESH = NOW - 60_000 // a minute ago
const STALE = NOW - 26 * 60 * 60 * 1000 // 26 hours ago
function band(
sync: import('../src/types.js').SyncState | undefined,
opts: { dirtyCount?: number } = {},
): HTMLElement | null {
return makeSyncBand(sync, { nowMs: NOW, ...opts })
}
it('renders nothing for a non-git project', () => {
expect(band(undefined)).toBeNull()
})
it('shows the single green in-sync state only when even with a fresh fetch', () => {
const b = band({ upstream: 'origin/main', ahead: 0, behind: 0, lastFetchMs: FRESH })!
expect(b.querySelector('.proj-sync-ok')).not.toBeNull()
expect(b.querySelector('.proj-sync-stale')).toBeNull()
})
it('refuses to call a stale comparison in sync', () => {
const b = band({ upstream: 'origin/main', ahead: 0, behind: 0, lastFetchMs: STALE })!
expect(b.querySelector('.proj-sync-ok')).toBeNull()
expect(b.querySelector('.proj-sync-stale')).not.toBeNull()
})
it('treats a never-fetched repo as stale, not as in sync', () => {
const b = band({ upstream: 'origin/main', ahead: 0, behind: 0 })!
expect(b.querySelector('.proj-sync-ok')).toBeNull()
expect(b.querySelector('.proj-sync-stale')).not.toBeNull()
})
it('never flags ahead as stale — it does not depend on a fetch', () => {
const b = band({ upstream: 'origin/main', ahead: 9, behind: 0, lastFetchMs: STALE })!
const ahead = b.querySelector('.proj-sync-ahead')!
expect(ahead.textContent).toContain('9')
expect(ahead.classList.contains('proj-sync-stale')).toBe(false)
expect(b.querySelector('.proj-sync-behind')!.classList.contains('proj-sync-stale')).toBe(true)
})
it('says "no upstream" instead of inventing counts, and is not green', () => {
const b = band({ ahead: undefined, behind: undefined, lastFetchMs: FRESH })!
expect(b.querySelector('.proj-sync-noupstream')).not.toBeNull()
expect(b.querySelector('.proj-sync-ahead')).toBeNull()
expect(b.querySelector('.proj-sync-behind')).toBeNull()
expect(b.querySelector('.proj-sync-ok')).toBeNull()
})
it('flags a detached HEAD, drops the counts and disables fetching', () => {
const b = band({ detached: true })!
expect(b.querySelector('.proj-sync-detached')).not.toBeNull()
expect(b.querySelector('.proj-sync-ahead')).toBeNull()
expect(b.querySelector('.proj-sync-ok')).toBeNull()
const btn = b.querySelector('.proj-sync-fetch') as HTMLButtonElement
expect(btn.disabled).toBe(true)
})
it('names the upstream so the arrows are not floating numbers', () => {
const b = band({ upstream: 'origin/develop', ahead: 9, behind: 0, lastFetchMs: FRESH })!
expect(b.textContent).toContain('origin/develop')
})
// The design puts the dirty count on the title line, not in the band — the
// band is about the remote, the working tree is not. Asserted at the header
// level in the renderProjectDetail block below.
it('keeps the working-tree count out of the band', () => {
const b = band({ upstream: 'origin/main', ahead: 0, behind: 0, lastFetchMs: FRESH })!
expect(b.querySelector('.proj-sync-dirty')).toBeNull()
})
// ── design fidelity: the captions and footnotes are load-bearing ──────────
it('labels every cell, so a number is never shown without saying what it is', () => {
const b = band({ upstream: 'origin/develop', ahead: 3, behind: 0, lastFetchMs: STALE })!
const captions = Array.from(b.querySelectorAll('.proj-sync-k')).map((n) =>
(n.textContent ?? '').toLowerCase(),
)
expect(captions).toContain('upstream')
expect(captions).toContain('to push')
expect(captions).toContain('to pull')
})
it('spells out WHY a stale zero cannot be trusted, not just that it is stale', () => {
const b = band({ upstream: 'origin/develop', ahead: 3, behind: 0, lastFetchMs: STALE })!
const bad = b.querySelector('.proj-sync-sub-bad')
expect(bad).not.toBeNull()
expect(bad!.textContent).toMatch(/cannot be trusted/i)
expect(bad!.textContent).toMatch(/fetched/i)
})
it('explains a missing upstream in words as well as colour', () => {
const b = band({ lastFetchMs: FRESH })!
expect(b.querySelector('.proj-sync-sub')!.textContent).toMatch(/tracks nothing/i)
})
it('renders the counts at display size', () => {
const b = band({ upstream: 'origin/develop', ahead: 3, behind: 0, lastFetchMs: FRESH })!
expect(b.querySelector('.proj-sync-ahead')!.classList.contains('proj-sync-big')).toBe(true)
expect(b.querySelector('.proj-sync-behind')!.classList.contains('proj-sync-big')).toBe(true)
})
})
/* ── w6/G5 worktree panel ───────────────────────────────────────────────────── */
describe('makeWorktreeRow — per-worktree state (w6 G5)', () => {
const wt = (over: Partial<import('../src/types.js').WorktreeInfo> = {}) => ({
path: '/p/repo',
isMain: true,
isCurrent: true,
branch: 'develop',
...over,
})
it('shows nothing extra for a worktree whose state was not probed', () => {
const row = makeWorktreeRow(wt({ isCurrent: false, isMain: false }))
expect(row.querySelector('.proj-sync-chip')).toBeNull()
})
it('says "no upstream" for a fresh worktree branch instead of looking settled', () => {
const row = makeWorktreeRow(wt(), undefined, { sync: { ahead: undefined } })
expect(row.querySelector('.proj-sync-noupstream')).not.toBeNull()
expect(row.querySelector('.proj-sync-ahead')).toBeNull()
})
it('shows the unpushed count for a tracking worktree', () => {
const row = makeWorktreeRow(wt(), undefined, {
sync: { upstream: 'origin/develop', ahead: 9, behind: 0 },
dirtyCount: 3,
})
expect(row.querySelector('.proj-sync-ahead')!.textContent).toContain('9')
expect(row.querySelector('.proj-sync-dirty')!.textContent).toContain('3')
})
it('omits the dirty chip on a clean worktree', () => {
const row = makeWorktreeRow(wt(), undefined, {
sync: { upstream: 'origin/develop', ahead: 0, behind: 0 },
dirtyCount: 0,
})
expect(row.querySelector('.proj-sync-dirty')).toBeNull()
})
})
/* ── w6/G7 session attribution across worktrees ─────────────────────────────── */
// `.claude/worktrees/<name>` lives INSIDE the main checkout, so this is exactly
// the case naive prefix matching double-counts.
describe('countSessionsByWorktree (w6 G7)', () => {
const MAIN = '/Users/x/repo'
const WT = '/Users/x/repo/.claude/worktrees/feature'
const wts = [
{ path: MAIN, isMain: true, isCurrent: true, branch: 'develop' },
{ path: WT, isMain: false, isCurrent: false, branch: 'worktree-feature' },
]
function sess(id: string, cwd: string | undefined, exited = false) {
return { id, status: 'idle' as const, clientCount: 1, createdAt: 0, exited, ...(cwd !== undefined ? { cwd } : {}) }
}
it('attributes a nested worktree session to the worktree, not to the parent', () => {
const counts = countSessionsByWorktree([sess('a', `${WT}/src`)], wts)
expect(counts.get(WT)).toBe(1)
expect(counts.get(MAIN)).toBe(0)
})
it('counts a session in the main checkout against the main worktree', () => {
const counts = countSessionsByWorktree([sess('a', `${MAIN}/src`)], wts)
expect(counts.get(MAIN)).toBe(1)
expect(counts.get(WT)).toBe(0)
})
it('ignores exited sessions and sessions with no cwd', () => {
const counts = countSessionsByWorktree(
[sess('a', `${MAIN}/src`, true), sess('b', undefined)],
wts,
)
expect(counts.get(MAIN)).toBe(0)
})
it('does not treat a sibling with a shared prefix as inside', () => {
const counts = countSessionsByWorktree([sess('a', '/Users/x/repo-old/src')], wts)
expect(counts.get(MAIN)).toBe(0)
})
it('gives every worktree an entry so a row can render 0 without a lookup miss', () => {
const counts = countSessionsByWorktree([], wts)
expect(counts.get(MAIN)).toBe(0)
expect(counts.get(WT)).toBe(0)
})
})
describe('makeWorktreeRow — session chip (w6 G7)', () => {
const wt = { path: '/p/repo', isMain: true, isCurrent: true, branch: 'develop' }
it('shows the running-session count', () => {
const row = makeWorktreeRow(wt, undefined, { sessionCount: 2 })
expect(row.querySelector('.proj-wt-sessions')!.textContent).toContain('2')
})
it('omits the chip when nothing runs there', () => {
const row = makeWorktreeRow(wt, undefined, { sessionCount: 0 })
expect(row.querySelector('.proj-wt-sessions')).toBeNull()
})
})
/* ── design fidelity: header + worktree card ───────────────────────────────── */
describe('renderProjectDetail — dirty count on the title line (w6)', () => {
const hooks = () => ({ onOpenProject: vi.fn(), onEnterSession: vi.fn(), onFanout: vi.fn() })
const cbs = () => ({ onBack: vi.fn(), onKill: vi.fn() })
function detail(over: Partial<import('../src/types.js').ProjectDetail> = {}) {
return {
name: 'repo',
path: '/p/repo',
isGit: true,
branch: 'develop',
worktrees: [],
sessions: [],
hasClaudeMd: false,
...over,
} as import('../src/types.js').ProjectDetail
}
it('shows the count beside the branch, not a bare dot', () => {
const root = renderProjectDetail(detail({ dirty: true, dirtyCount: 3 }), hooks(), cbs())
const chip = root.querySelector('.proj-detail-head .proj-sync-dirty')
expect(chip).not.toBeNull()
expect(chip!.textContent).toContain('3')
expect(root.querySelector('.proj-dirty')).toBeNull()
})
it('shows nothing for a clean tree', () => {
const root = renderProjectDetail(detail({ dirty: false, dirtyCount: 0 }), hooks(), cbs())
expect(root.querySelector('.proj-sync-dirty')).toBeNull()
expect(root.querySelector('.proj-dirty')).toBeNull()
})
it('falls back to the bare dot when the server sent no count', () => {
const root = renderProjectDetail(detail({ dirty: true }), hooks(), cbs())
expect(root.querySelector('.proj-dirty')).not.toBeNull()
})
})
describe('makeWorktreeRow — two-line card (w6 design)', () => {
const wt = { path: '/p/repo', isMain: true, isCurrent: true, branch: 'develop' }
it('puts the path on its own line so it never competes with the chips', () => {
const row = makeWorktreeRow(wt, undefined, { sync: { upstream: 'o/d', ahead: 9 } })
const main = row.querySelector('.proj-wt-main')!
expect(main.querySelector('.proj-wt-top .proj-wt-branch')).not.toBeNull()
expect(main.querySelector('.proj-wt-path')!.textContent).toBe('/p/repo')
// The path must NOT sit inside the chip row.
expect(row.querySelector('.proj-wt-top .proj-wt-path')).toBeNull()
})
it('offers Open when a handler is wired, and calls it with the worktree', () => {
const onOpen = vi.fn()
const row = makeWorktreeRow(wt, { onRemove: vi.fn(), onOpen })
const btn = row.querySelector('.proj-wt-open') as HTMLButtonElement
expect(btn).not.toBeNull()
btn.click()
expect(onOpen).toHaveBeenCalledWith(wt)
})
it('renders no Open button when no handler is wired', () => {
expect(makeWorktreeRow(wt, { onRemove: vi.fn() }).querySelector('.proj-wt-open')).toBeNull()
})
it('marks the current worktree so it reads as "you are here"', () => {
expect(makeWorktreeRow(wt).classList.contains('proj-wt-row-current')).toBe(true)
expect(
makeWorktreeRow({ ...wt, isCurrent: false }).classList.contains('proj-wt-row-current'),
).toBe(false)
})
})