// @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 })) // Dynamic import AFTER mock declaration so the factory is in place. const { filterProjects, sortProjects, getFavs, saveFavs, toggleFav, normalizeProject, makeProjectCard } = 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) }) }) /* ── 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('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') }) })