An audit of the project-detail panel against docs/mockups/project-detail-git.html turned up 59 differences. The ones that changed what you see: Controls that had class names but no rules, so they rendered as raw OS widgets: - .proj-wt-form had no layout at all — the branch input and Create Worktree button sat flush together, and the button jumped to its own line the moment a validation error appeared between them. - .proj-wt-error / .proj-wt-actions-error had no rule, so a validation failure rendered as 16px near-white body text, indistinguishable from content. - .proj-wt-remove (the worktree ✕) and .proj-wt-prune had no rule either. - .proj-fanout-mode never reset `appearance`, so WebKit kept its own light dropdown — the one control in the panel that stayed a native grey widget. - --danger was never defined, so every validation message fell back to an off-palette red. Cascade bugs — a later rule at equal specificity was silently winning: - .proj-wt-row-current's amber wash and border were completely dead. "You are here" rendered identically to every other row. - .proj-wt-branch inherited the 16px user-agent default in the UI sans, making a branch name the largest text on the page. Now 13.5px mono. Layout: - .proj-syncband dropped flex-wrap. Wrapping produced ragged half-empty rows between 721px and 1000px — at 900px the Fetch button dropped alone onto a second row. One cell now absorbs the shrink so the band stays a single row down to the 720px column break. - Commit rows are a 4-column grid, not flex, and the ↑ mark cell is rendered on every row instead of only unpushed ones. Previously no two lines agreed on where the sha started, so the unpushed group did not read as a group. - Pushed commit subjects step down to --text-dim, which is what makes the upstream boundary mean anything. Honesty of the sync band — the design's two hard rules: - Green "✓ in sync" was reachable when ahead/behind were UNKNOWN, because `?? 0` folded undefined into zero. Unknown counts now render "↑ —" / "↓ —" and are marked unverified. Green requires real zeros and a fresh fetch. - A stale ↓ painted the digit warning-red. Since a missing FETCH_HEAD is the default state, the largest glyph in the band was almost always red and the panel read as "this repo is broken". The doubt now sits on the `unverified` chip and the footnote, never on the number. Also: semantic tokens (--sunk/--warn/--ok/--dirty/--mono-font) replacing hex literals, one chip language across the panel, section headings as tracked caps with the count as a separate lighter element, and a ≤720px rule that drops the age column — placed after the base rule, since a media query adds no specificity. Verified in a real browser at 1280/1000/900/780/721/600/420px: the sync band holds one row down to 721px and stacks below it, commit columns align on every row, and nothing overflows or scrolls the page horizontally.
1022 lines
41 KiB
TypeScript
1022 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('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)
|
|
})
|
|
})
|