The low-severity leftovers from the panel audit, plus the tooling trap that made reviewing this work harder than it needed to be. .gitattributes (new) — `public/projects.ts diff` That file embeds literal NUL bytes on purpose: the namespace sentinels '\0active' / '\0other' are prefixed so they cannot collide with a real `First.Second` key. git therefore classified it as binary and printed "Binary files differ" for every diff, and `git diff --stat` reported "Bin 66815 -> 68701 bytes" instead of line counts — the file was effectively unreviewable. The `diff` attribute makes git diff it as text without setting `text`, so no line endings are normalised and the bytes on disk are untouched. (Same trap in the shell: grep prints only "Binary file ... matches", which reads as a no-match. The comment in the file says to use grep -a.) Detached HEAD now shows its short sha The Head cell held the "detached HEAD" chip and nothing else: it said what was wrong and nothing about where you were. SyncState gains an optional `head`, populated from the .git/HEAD text readSyncState ALREADY reads for the detached test — so this costs no extra I/O and no extra git call. Guarded by a hex regex and rendered as textContent. One geometry for the ghost button role The design defines two button roles. The outline one had drifted to three different sizes (.proj-wt-open at 11.5px/4px 12px/6px, .proj-diff-toggle at 12.5px/7px 15px/7px) and both used full-strength --text instead of the muted label. All four outline buttons are now declared once, together, at the design's geometry; the duplicate .proj-diff-toggle block further down the file is gone, since a second block at equal specificity is exactly how the role drifted in the first place. Header branch chip joins the chip language .proj-branch was the only chip with no border and no mono face, and 1px tighter all round, so the detail header's branch did not match the branch chips in the worktree list right below it. "Changes" heading over the View Diff toggle Every other block on the page is introduced by a section heading. Without one the toggle hung 4px off the bottom of the sync band and read as part of it. Tests: 2167 unit (4 new — detached sha present/absent on both the server and the panel) and 27 integration, twice.
1034 lines
41 KiB
TypeScript
1034 lines
41 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)
|
|
})
|
|
|
|
it('carries staleness on the chip, never by recolouring the count', () => {
|
|
const b = band({ upstream: 'origin/main', ahead: 9, behind: 2, lastFetchMs: STALE })!
|
|
// Being behind is information, not an error. Painting the largest glyph in
|
|
// the band warn-red made every un-fetched repo look broken.
|
|
for (const big of b.querySelectorAll('.proj-sync-big')) {
|
|
expect(big.classList.contains('proj-sync-stale')).toBe(false)
|
|
}
|
|
expect(b.querySelector('.proj-sync-chip.proj-sync-stale')!.textContent).toBe('unverified')
|
|
})
|
|
|
|
it('does not paint a zero in the accent — accent means work is waiting', () => {
|
|
const b = band({ upstream: 'origin/main', ahead: 0, behind: 0, lastFetchMs: FRESH })!
|
|
expect(b.querySelector('.proj-sync-ahead')).toBeNull()
|
|
expect(b.querySelector('.proj-sync-behind')).toBeNull()
|
|
expect(b.querySelectorAll('.proj-sync-zero')).toHaveLength(2)
|
|
})
|
|
|
|
it('refuses green when a count is unknown rather than treating it as zero', () => {
|
|
// `undefined` is not 0 — it means the count could not be computed. Folding
|
|
// the two together let a repo whose counts failed to resolve claim it was
|
|
// in sync, which is the one thing the design forbids.
|
|
const b = band({ upstream: 'origin/main', ahead: undefined, behind: undefined, lastFetchMs: FRESH })!
|
|
expect(b.querySelector('.proj-sync-ok')).toBeNull()
|
|
expect(b.querySelector('.proj-sync-chip.proj-sync-stale')).not.toBeNull()
|
|
expect(b.textContent).toContain('↑ —')
|
|
expect(b.textContent).toContain('↓ —')
|
|
})
|
|
|
|
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('shows the short sha on a detached HEAD — it is the only identity left', () => {
|
|
const b = band({ detached: true, head: 'deadbee' })!
|
|
expect(b.querySelector('.proj-sync-upstream')!.textContent).toBe('deadbee')
|
|
expect(b.querySelector('.proj-sync-detached')!.textContent).toBe('detached HEAD')
|
|
})
|
|
|
|
it('still renders the detached chip when the sha could not be read', () => {
|
|
const b = band({ detached: true })!
|
|
expect(b.querySelector('.proj-sync-upstream')).toBeNull()
|
|
expect(b.querySelector('.proj-sync-detached')).not.toBeNull()
|
|
})
|
|
|
|
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)
|
|
// behind is 0, so it takes the neutral zero role — still at display size.
|
|
expect(b.querySelector('.proj-sync-zero')!.classList.contains('proj-sync-big')).toBe(true)
|
|
expect(b.querySelectorAll('.proj-sync-big')).toHaveLength(2)
|
|
})
|
|
})
|
|
|
|
/* ── 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)
|
|
})
|
|
})
|