// @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 { 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 = {}) { 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 { return makeProject({ name, path: `/repos/${name}`, ...over }) } describe('groupProjects', () => { const noFavs = new Set() 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 = {}) => ({ 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/` 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 = {}) { 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) }) })