Replace each card's "+ start claude here" with a row of three brand-logo launcher buttons (logo + caption, touch-friendly, tooltips): - Claude (coral burst) → new terminal tab running `claude` - Codex (OpenAI mark) → new terminal tab running `codex` - VS Code (blue ribbon) → opens the repo in VS Code ON THE HOST VS Code: new guarded POST /open-in-editor runs `<EDITOR_CMD> <path>` (default `code`) on the host via execFile (no shell); path validated as an existing absolute dir; Origin guard (CSRF) like the DELETE routes. EDITOR_CMD config added. Active-session highlight (user request): when a project has a running Claude session (Claude-hook status != unknown — plain shells/codex stay unknown), the Claude logo gets a coral ring + tint, with a count badge when more than one. New: public/icons.ts (simple-icons SVGs, fill=currentColor), src/http/editor.ts. onOpenProject gains an optional cmd; makeProjectCard exported for tests. Tests +16 (431 total): editor validation/launch, EDITOR_CMD config, launcher row (3 buttons, claude/codex commands), and the active-Claude highlight + badge. Verified: coverage >=80x4, build OK, both typechecks clean; in-browser the 3 logos render with brand colours, and POST /open-in-editor actually launched VS Code on the host (403 no-Origin / 400 bad-path / 204 valid-dir all correct).
356 lines
14 KiB
TypeScript
356 lines
14 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 }))
|
|
|
|
// 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> = {}): 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')
|
|
})
|
|
})
|