feat(v0.6): Project Manager — repo discovery + Projects panel + tab wiring
Home screen gains a Sessions↔Projects segmented control. The Projects view discovers the host's git repos and, on click, opens a tab named after the repo, spawned in the repo dir, auto-running `claude` (1:N sessions per project). Backend (P2/P4): - src/http/projects.ts — parseGitHead + buildProjects(cfg, liveSessions): depth-capped BFS for .git (skips node_modules/dotdirs/symlinks, stops descending at a repo), .git/HEAD branch parse, rate-limited `git status` dirty check (execFile, no shell, 2s timeout, concurrency 8), merge of ~/.claude/projects cwds (reuses history.listSessions), cwd-prefix session merge (fresh each call), TTL discovery cache with in-flight dedup. - src/server.ts — read-only GET /projects (no Origin guard, []-fallback). Frontend (P5/P6): - public/projects.ts — mountProjects: 1:N cards (branch chip, dirty dot, session rows, "+ start claude here"), live search, localStorage favourites; pure helpers extracted for unit tests. - public/tabs.ts — openProject (#n suffix for dup names, sends 'claude\r'), countOpenWithTitlePrefix, Sessions↔Projects home toggle (updateHomeView). - public/style.css — Projects panel + .home-seg control styles. Config (P3, hardened): PROJECT_ROOTS/SCAN_DEPTH/SCAN_TTL/DIRTY_CHECK already added; this commit adds fail-fast rejection of relative PROJECT_ROOTS. Review hardening (4 confirmed HIGH + boundary validation): - carriage-return fix so claude auto-executes (A3); seg-control z-order; parallelised history merge; concurrent-cache-miss dedup. - normalizeProject guards the /projects response; getFavs filters non-strings. Tests: +57 (398 total). Backend src/http/projects.ts 93.4%, config.ts 98%. Verified: both typechecks clean, full suite green, build:web OK, coverage ≥80×4, real-browser smoke (83 repos, branch/dirty correct, click→claude tab).
This commit is contained in:
@@ -384,3 +384,57 @@ describe('loadConfig — returned object is frozen', () => {
|
||||
expect(Object.isFrozen(cfg.allowedOrigins)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ── v0.6 project discovery config ───────────────────────────────────────────────
|
||||
describe('loadConfig — project discovery (v0.6)', () => {
|
||||
beforeEach(() => {
|
||||
mockNetworkInterfaces.mockReturnValue({})
|
||||
mockHomedir.mockReturnValue('/home/testuser')
|
||||
})
|
||||
|
||||
it('defaults: projectRoots=[homeDir], depth 4, ttl 10000, dirtyCheck true', () => {
|
||||
const cfg = loadConfig({})
|
||||
expect(cfg.projectRoots).toEqual(['/home/testuser'])
|
||||
expect(cfg.projectScanDepth).toBe(4)
|
||||
expect(cfg.projectScanTtlMs).toBe(10_000)
|
||||
expect(cfg.projectDirtyCheck).toBe(true)
|
||||
expect(Object.isFrozen(cfg.projectRoots)).toBe(true)
|
||||
})
|
||||
|
||||
it('PROJECT_ROOTS: comma-separated absolute paths, trimmed', () => {
|
||||
const cfg = loadConfig({ PROJECT_ROOTS: '/a, /b/c ,/d' })
|
||||
expect(cfg.projectRoots).toEqual(['/a', '/b/c', '/d'])
|
||||
})
|
||||
|
||||
it('PROJECT_ROOTS: expands ~ and ~/sub to homeDir', () => {
|
||||
const cfg = loadConfig({ PROJECT_ROOTS: '~,~/code' })
|
||||
expect(cfg.projectRoots).toEqual(['/home/testuser', '/home/testuser/code'])
|
||||
})
|
||||
|
||||
it('PROJECT_ROOTS: empty / whitespace falls back to [homeDir]', () => {
|
||||
expect(loadConfig({ PROJECT_ROOTS: '' }).projectRoots).toEqual(['/home/testuser'])
|
||||
expect(loadConfig({ PROJECT_ROOTS: ' ' }).projectRoots).toEqual(['/home/testuser'])
|
||||
})
|
||||
|
||||
it('PROJECT_ROOTS: a relative path fails fast with a clear error', () => {
|
||||
expect(() => loadConfig({ PROJECT_ROOTS: 'relative/dir' })).toThrow(/absolute path/)
|
||||
expect(() => loadConfig({ PROJECT_ROOTS: '/ok,./bad' })).toThrow(/PROJECT_ROOTS/)
|
||||
})
|
||||
|
||||
it('PROJECT_SCAN_DEPTH / PROJECT_SCAN_TTL override and validate', () => {
|
||||
const cfg = loadConfig({ PROJECT_SCAN_DEPTH: '2', PROJECT_SCAN_TTL: '0' })
|
||||
expect(cfg.projectScanDepth).toBe(2)
|
||||
expect(cfg.projectScanTtlMs).toBe(0)
|
||||
expect(() => loadConfig({ PROJECT_SCAN_DEPTH: 'deep' })).toThrow(/PROJECT_SCAN_DEPTH/)
|
||||
expect(() => loadConfig({ PROJECT_SCAN_TTL: '-1' })).toThrow(/PROJECT_SCAN_TTL/)
|
||||
})
|
||||
|
||||
it('PROJECT_DIRTY_CHECK: parses on/off forms, defaults true', () => {
|
||||
expect(loadConfig({ PROJECT_DIRTY_CHECK: '0' }).projectDirtyCheck).toBe(false)
|
||||
expect(loadConfig({ PROJECT_DIRTY_CHECK: 'off' }).projectDirtyCheck).toBe(false)
|
||||
expect(loadConfig({ PROJECT_DIRTY_CHECK: 'false' }).projectDirtyCheck).toBe(false)
|
||||
expect(loadConfig({ PROJECT_DIRTY_CHECK: 'on' }).projectDirtyCheck).toBe(true)
|
||||
expect(loadConfig({ PROJECT_DIRTY_CHECK: '1' }).projectDirtyCheck).toBe(true)
|
||||
expect(loadConfig({ PROJECT_DIRTY_CHECK: 'garbage' }).projectDirtyCheck).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
144
test/integration/projects-endpoint.test.ts
Normal file
144
test/integration/projects-endpoint.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Integration test for GET /projects (P4 — v0.6 Project Manager).
|
||||
*
|
||||
* Starts a real HTTP server against a temp directory containing one fake git
|
||||
* repo, then asserts that GET /projects returns HTTP 200 and a JSON array
|
||||
* containing that repo with name + isGit:true.
|
||||
*
|
||||
* Config overrides used for speed and determinism:
|
||||
* - PROJECT_ROOTS → a mkdtemp temp dir (one fake git repo inside)
|
||||
* - PROJECT_DIRTY_CHECK='0' → skip `git status` subprocess calls
|
||||
* - PROJECT_SCAN_TTL='0' → no caching between test runs
|
||||
* - USE_TMUX='0' → no tmux (not needed)
|
||||
* - ALLOWED_ORIGINS → loopback only
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises'
|
||||
import net from 'node:net'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { loadConfig } from '../../src/config.js'
|
||||
import { startServer } from '../../src/server.js'
|
||||
import { _clearProjectCache } from '../../src/http/projects.js'
|
||||
import type { ProjectInfo } from '../../src/types.js'
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Pick a free port on 127.0.0.1 by briefly binding to port 0. */
|
||||
function getFreePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const srv = net.createServer()
|
||||
srv.listen(0, '127.0.0.1', () => {
|
||||
const addr = srv.address()
|
||||
if (addr === null || typeof addr === 'string') {
|
||||
srv.close()
|
||||
reject(new Error('unexpected address type'))
|
||||
return
|
||||
}
|
||||
const port = addr.port
|
||||
srv.close(() => resolve(port))
|
||||
})
|
||||
srv.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
/** Create a minimal fake git repo at `dir/<repoName>/`. Returns the repo path. */
|
||||
async function makeFakeGitRepo(parentDir: string, repoName: string): Promise<string> {
|
||||
const repoPath = path.join(parentDir, repoName)
|
||||
const gitDir = path.join(repoPath, '.git')
|
||||
await fs.mkdir(gitDir, { recursive: true })
|
||||
// Write a .git/HEAD so branch parsing works
|
||||
await fs.writeFile(path.join(gitDir, 'HEAD'), 'ref: refs/heads/main\n', 'utf8')
|
||||
return repoPath
|
||||
}
|
||||
|
||||
// ── test suite ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /projects — integration', () => {
|
||||
let port: number
|
||||
let tmpRoot: string
|
||||
let repoPath: string
|
||||
let serverHandle: { close(): Promise<void> }
|
||||
|
||||
beforeAll(async () => {
|
||||
// Ensure a clean discovery cache from any sibling tests.
|
||||
_clearProjectCache()
|
||||
|
||||
port = await getFreePort()
|
||||
|
||||
// Create a temp root with one fake git repo inside.
|
||||
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-projects-test-'))
|
||||
repoPath = await makeFakeGitRepo(tmpRoot, 'fake-repo')
|
||||
|
||||
const cfg = loadConfig({
|
||||
PORT: String(port),
|
||||
BIND_HOST: '127.0.0.1',
|
||||
SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
|
||||
ALLOWED_ORIGINS: `http://127.0.0.1:${port}`,
|
||||
USE_TMUX: '0',
|
||||
IDLE_TTL: '86400',
|
||||
// Project-discovery overrides
|
||||
PROJECT_ROOTS: tmpRoot,
|
||||
PROJECT_DIRTY_CHECK: '0', // skip git-status subprocess
|
||||
PROJECT_SCAN_TTL: '0', // no caching; every call is fresh
|
||||
PROJECT_SCAN_DEPTH: '2', // shallow — temp dir is only 1 level deep
|
||||
})
|
||||
|
||||
serverHandle = startServer(cfg)
|
||||
// Give the server time to bind before firing requests.
|
||||
await new Promise<void>((r) => setTimeout(r, 100))
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await serverHandle.close()
|
||||
// Remove the temp dir (best-effort — test runner cleans /tmp anyway).
|
||||
await fs.rm(tmpRoot, { recursive: true, force: true })
|
||||
_clearProjectCache()
|
||||
})
|
||||
|
||||
it('returns HTTP 200 with a JSON array', async () => {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/projects`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as unknown
|
||||
expect(Array.isArray(body)).toBe(true)
|
||||
})
|
||||
|
||||
it('includes the fake repo with correct name and isGit:true', async () => {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/projects`)
|
||||
expect(res.status).toBe(200)
|
||||
const projects = (await res.json()) as ProjectInfo[]
|
||||
|
||||
const found = projects.find((p) => p.path === repoPath)
|
||||
expect(found).toBeDefined()
|
||||
expect(found!.name).toBe('fake-repo')
|
||||
expect(found!.isGit).toBe(true)
|
||||
})
|
||||
|
||||
it('includes the branch parsed from .git/HEAD', async () => {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/projects`)
|
||||
const projects = (await res.json()) as ProjectInfo[]
|
||||
const found = projects.find((p) => p.path === repoPath)
|
||||
expect(found).toBeDefined()
|
||||
expect(found!.branch).toBe('main')
|
||||
})
|
||||
|
||||
it('returns an empty sessions array for a repo with no live sessions', async () => {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/projects`)
|
||||
const projects = (await res.json()) as ProjectInfo[]
|
||||
const found = projects.find((p) => p.path === repoPath)
|
||||
expect(found).toBeDefined()
|
||||
expect(Array.isArray(found!.sessions)).toBe(true)
|
||||
expect(found!.sessions).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('responds even if buildProjects throws (graceful fallback to [])', async () => {
|
||||
// The server catches errors from buildProjects and returns [].
|
||||
// This is tested implicitly above — the server stays up across all sub-tests.
|
||||
// (A deeper unit test of the error path is in test/projects.test.ts.)
|
||||
const res = await fetch(`http://127.0.0.1:${port}/projects`)
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
})
|
||||
273
test/projects-panel.test.ts
Normal file
273
test/projects-panel.test.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
// @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 } = 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)
|
||||
})
|
||||
})
|
||||
349
test/projects.test.ts
Normal file
349
test/projects.test.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import fs from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import type { Config, LiveSessionInfo } from '../src/types.js'
|
||||
import type { HistorySession } from '../src/http/history.js'
|
||||
|
||||
// ── mock history.listSessions so the real ~/.claude/projects never pollutes ──────
|
||||
// (vitest hoists vi.mock; the factory may only reference vars prefixed "mock".)
|
||||
const mockListSessions = vi.fn(async (): Promise<HistorySession[]> => [])
|
||||
vi.mock('../src/http/history.js', () => ({
|
||||
listSessions: (...a: unknown[]) => mockListSessions(...a),
|
||||
}))
|
||||
|
||||
const { parseGitHead, buildProjects, _clearProjectCache } = await import('../src/http/projects.js')
|
||||
|
||||
const execFileP = promisify(execFile)
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeCfg(overrides: Partial<Config>): Config {
|
||||
return {
|
||||
port: 3000,
|
||||
bindHost: '0.0.0.0',
|
||||
shellPath: '/bin/zsh',
|
||||
homeDir: '/home/tester',
|
||||
idleTtlMs: 1000,
|
||||
scrollbackBytes: 1024,
|
||||
maxPayloadBytes: 1024,
|
||||
wsPath: '/term',
|
||||
maxSessions: 50,
|
||||
maxMsgsPerSec: 2000,
|
||||
permTimeoutMs: 1000,
|
||||
reapIntervalMs: 1000,
|
||||
previewBytes: 1024,
|
||||
useTmux: false,
|
||||
allowedOrigins: [],
|
||||
projectRoots: [],
|
||||
projectScanDepth: 4,
|
||||
projectScanTtlMs: 0, // default OFF so each test re-scans deterministically
|
||||
projectDirtyCheck: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeLive(over: { id: string; cwd: string | null } & Partial<LiveSessionInfo>): LiveSessionInfo {
|
||||
return {
|
||||
id: over.id,
|
||||
createdAt: over.createdAt ?? 1000,
|
||||
clientCount: over.clientCount ?? 1,
|
||||
status: over.status ?? 'unknown',
|
||||
exited: over.exited ?? false,
|
||||
cwd: over.cwd,
|
||||
cols: over.cols ?? 80,
|
||||
rows: over.rows ?? 24,
|
||||
}
|
||||
}
|
||||
|
||||
async function makeRepo(dir: string, branch: string): Promise<void> {
|
||||
await fs.mkdir(path.join(dir, '.git'), { recursive: true })
|
||||
await fs.writeFile(path.join(dir, '.git', 'HEAD'), `ref: refs/heads/${branch}\n`)
|
||||
}
|
||||
|
||||
async function gitAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await execFileP('git', ['--version'])
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
let tmp: string
|
||||
|
||||
beforeEach(async () => {
|
||||
_clearProjectCache()
|
||||
mockListSessions.mockReset()
|
||||
mockListSessions.mockResolvedValue([])
|
||||
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'projtest-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
_clearProjectCache()
|
||||
await fs.rm(tmp, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// ── parseGitHead (pure) ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('parseGitHead', () => {
|
||||
it('extracts a simple branch name', () => {
|
||||
expect(parseGitHead('ref: refs/heads/main\n')).toBe('main')
|
||||
})
|
||||
|
||||
it('extracts a branch name with slashes', () => {
|
||||
expect(parseGitHead('ref: refs/heads/feature/x\n')).toBe('feature/x')
|
||||
})
|
||||
|
||||
it('returns null for a detached HEAD (bare 40-hex sha)', () => {
|
||||
expect(parseGitHead('a'.repeat(40) + '\n')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for empty / junk / non-heads refs', () => {
|
||||
expect(parseGitHead('')).toBeNull()
|
||||
expect(parseGitHead('garbage\n')).toBeNull()
|
||||
expect(parseGitHead('ref: refs/tags/v1\n')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ── buildProjects: discovery (real temp dir) ─────────────────────────────────────
|
||||
|
||||
describe('buildProjects — discovery', () => {
|
||||
it('finds git repos with correct names/branches, skips node_modules, respects depth', async () => {
|
||||
await makeRepo(path.join(tmp, 'repoA'), 'main') // depth 1
|
||||
await makeRepo(path.join(tmp, 'sub', 'repoB'), 'dev') // depth 2
|
||||
await makeRepo(path.join(tmp, 'sub', 'deep', 'repoC'), 'x') // depth 3 -> excluded
|
||||
await makeRepo(path.join(tmp, 'node_modules', 'pkg'), 'y') // node_modules -> skipped
|
||||
|
||||
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
|
||||
const out = await buildProjects(cfg, [])
|
||||
const byName = new Map(out.map((p) => [p.name, p]))
|
||||
|
||||
expect([...byName.keys()].sort()).toEqual(['repoA', 'repoB'])
|
||||
expect(byName.get('repoA')).toMatchObject({
|
||||
isGit: true,
|
||||
branch: 'main',
|
||||
path: path.join(tmp, 'repoA'),
|
||||
sessions: [],
|
||||
})
|
||||
expect(byName.get('repoB')!.branch).toBe('dev')
|
||||
expect(byName.has('repoC')).toBe(false) // too deep
|
||||
expect(byName.has('pkg')).toBe(false) // node_modules
|
||||
})
|
||||
|
||||
it('records a root that is itself a git repo and does not descend into it', async () => {
|
||||
await makeRepo(tmp, 'main')
|
||||
await makeRepo(path.join(tmp, 'nested'), 'dev') // inside a repo -> not descended
|
||||
|
||||
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 4, projectDirtyCheck: false })
|
||||
const out = await buildProjects(cfg, [])
|
||||
|
||||
expect(out.map((p) => p.path)).toEqual([tmp])
|
||||
expect(out[0]!.branch).toBe('main')
|
||||
})
|
||||
|
||||
it('leaves branch undefined when .git/HEAD is missing/unreadable', async () => {
|
||||
await fs.mkdir(path.join(tmp, 'bare', '.git'), { recursive: true }) // .git dir, no HEAD
|
||||
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
|
||||
const out = await buildProjects(cfg, [])
|
||||
const bare = out.find((p) => p.name === 'bare')!
|
||||
expect(bare.isGit).toBe(true)
|
||||
expect(bare.branch).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not follow symlinks', async () => {
|
||||
const other = await fs.mkdtemp(path.join(os.tmpdir(), 'projtest-link-'))
|
||||
try {
|
||||
await makeRepo(path.join(other, 'repoS'), 'main')
|
||||
try {
|
||||
await fs.symlink(other, path.join(tmp, 'linked'), 'dir')
|
||||
} catch {
|
||||
return // symlinks not permitted in this environment
|
||||
}
|
||||
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 4, projectDirtyCheck: false })
|
||||
const out = await buildProjects(cfg, [])
|
||||
expect(out.some((p) => p.name === 'repoS')).toBe(false)
|
||||
} finally {
|
||||
await fs.rm(other, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('returns [] for an unreadable / missing root (never throws)', async () => {
|
||||
const cfg = makeCfg({
|
||||
projectRoots: [path.join(tmp, 'does-not-exist')],
|
||||
projectScanDepth: 2,
|
||||
projectDirtyCheck: false,
|
||||
})
|
||||
await expect(buildProjects(cfg, [])).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ── buildProjects: live-session merge ────────────────────────────────────────────
|
||||
|
||||
describe('buildProjects — session merge', () => {
|
||||
it('merges sessions into the owning project by cwd === path or under path', async () => {
|
||||
await makeRepo(path.join(tmp, 'repoA'), 'main')
|
||||
const repoPath = path.join(tmp, 'repoA')
|
||||
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
|
||||
|
||||
const live: LiveSessionInfo[] = [
|
||||
makeLive({ id: 's-exact', cwd: repoPath, status: 'working', clientCount: 2, createdAt: 111 }),
|
||||
makeLive({ id: 's-under', cwd: path.join(repoPath, 'src', 'http'), status: 'idle' }),
|
||||
makeLive({ id: 's-other', cwd: path.join(tmp, 'elsewhere') }),
|
||||
makeLive({ id: 's-null', cwd: null }),
|
||||
]
|
||||
|
||||
const out = await buildProjects(cfg, live)
|
||||
const repoA = out.find((p) => p.name === 'repoA')!
|
||||
|
||||
expect(repoA.sessions.map((s) => s.id).sort()).toEqual(['s-exact', 's-under'])
|
||||
expect(repoA.sessions.find((s) => s.id === 's-exact')).toEqual({
|
||||
id: 's-exact',
|
||||
title: 'repoA',
|
||||
status: 'working',
|
||||
clientCount: 2,
|
||||
createdAt: 111,
|
||||
exited: false,
|
||||
})
|
||||
expect(repoA.sessions.find((s) => s.id === 's-under')!.title).toBe('http')
|
||||
})
|
||||
|
||||
it('re-merges live sessions FRESH on every call (discovery is cached)', async () => {
|
||||
await makeRepo(path.join(tmp, 'repoA'), 'main')
|
||||
const repoPath = path.join(tmp, 'repoA')
|
||||
const cfg = makeCfg({
|
||||
projectRoots: [tmp],
|
||||
projectScanDepth: 2,
|
||||
projectDirtyCheck: false,
|
||||
projectScanTtlMs: 60_000, // keep discovery cached across calls
|
||||
})
|
||||
|
||||
const first = await buildProjects(cfg, [])
|
||||
expect(first.find((p) => p.name === 'repoA')!.sessions).toEqual([])
|
||||
|
||||
const second = await buildProjects(cfg, [makeLive({ id: 's1', cwd: repoPath })])
|
||||
expect(second.find((p) => p.name === 'repoA')!.sessions.map((s) => s.id)).toEqual(['s1'])
|
||||
|
||||
const third = await buildProjects(cfg, [])
|
||||
expect(third.find((p) => p.name === 'repoA')!.sessions).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ── buildProjects: history merge + sorting ───────────────────────────────────────
|
||||
|
||||
describe('buildProjects — history merge & sorting', () => {
|
||||
it('adds history-only cwds as projects and sets lastActiveMs on discovered repos', async () => {
|
||||
await makeRepo(path.join(tmp, 'repoA'), 'main')
|
||||
const repoPath = path.join(tmp, 'repoA')
|
||||
const histDir = await fs.mkdtemp(path.join(os.tmpdir(), 'projtest-hist-'))
|
||||
try {
|
||||
mockListSessions.mockResolvedValue([
|
||||
{ id: 'h1', cwd: repoPath, project: 'repoA', mtimeMs: 5000, preview: '' },
|
||||
{ id: 'h2', cwd: repoPath, project: 'repoA', mtimeMs: 9000, preview: '' },
|
||||
{ id: 'h3', cwd: histDir, project: path.basename(histDir), mtimeMs: 3000, preview: '' },
|
||||
{ id: 'h4', cwd: '', project: 'unknown', mtimeMs: 1, preview: '' },
|
||||
])
|
||||
|
||||
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
|
||||
const out = await buildProjects(cfg, [])
|
||||
|
||||
const repoA = out.find((p) => p.name === 'repoA')!
|
||||
expect(repoA.lastActiveMs).toBe(9000) // newest of h1/h2
|
||||
|
||||
const hist = out.find((p) => p.path === histDir)!
|
||||
expect(hist).toBeDefined()
|
||||
expect(hist.isGit).toBe(false)
|
||||
expect(hist.lastActiveMs).toBe(3000)
|
||||
|
||||
expect(out.some((p) => p.path === '')).toBe(false) // empty cwd ignored
|
||||
expect(out[0]!.name).toBe('repoA') // 9000 > 3000
|
||||
} finally {
|
||||
await fs.rm(histDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('sorts by lastActiveMs desc, undefined last, name asc tiebreak', async () => {
|
||||
await makeRepo(path.join(tmp, 'zzz'), 'main') // no history -> undefined
|
||||
await makeRepo(path.join(tmp, 'aaa'), 'main') // no history -> undefined
|
||||
await makeRepo(path.join(tmp, 'mmm'), 'main') // has history -> first
|
||||
mockListSessions.mockResolvedValue([
|
||||
{ id: 'h', cwd: path.join(tmp, 'mmm'), project: 'mmm', mtimeMs: 1000, preview: '' },
|
||||
])
|
||||
|
||||
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
|
||||
const out = await buildProjects(cfg, [])
|
||||
|
||||
expect(out.map((p) => p.name)).toEqual(['mmm', 'aaa', 'zzz'])
|
||||
})
|
||||
})
|
||||
|
||||
// ── buildProjects: in-flight promise deduplication (Finding 4) ─────────────────────
|
||||
|
||||
describe('buildProjects — concurrent cache-miss deduplication', () => {
|
||||
it('two concurrent cold-cache calls return consistent results (no corrupt state)', async () => {
|
||||
await makeRepo(path.join(tmp, 'repoA'), 'main')
|
||||
await makeRepo(path.join(tmp, 'repoB'), 'dev')
|
||||
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
|
||||
|
||||
// Both calls start concurrently while cache is cold.
|
||||
const [a, b] = await Promise.all([buildProjects(cfg, []), buildProjects(cfg, [])])
|
||||
|
||||
const aNames = a.map((p) => p.name).sort()
|
||||
const bNames = b.map((p) => p.name).sort()
|
||||
expect(aNames).toEqual(['repoA', 'repoB'])
|
||||
expect(bNames).toEqual(aNames)
|
||||
})
|
||||
|
||||
it('second concurrent call gets the same result as the first (in-flight reuse)', async () => {
|
||||
await makeRepo(path.join(tmp, 'repoA'), 'main')
|
||||
const cfg = makeCfg({
|
||||
projectRoots: [tmp],
|
||||
projectScanDepth: 2,
|
||||
projectDirtyCheck: false,
|
||||
projectScanTtlMs: 60_000,
|
||||
})
|
||||
|
||||
// Start first call, immediately start second — second should share the in-flight promise.
|
||||
const first = buildProjects(cfg, [])
|
||||
const second = buildProjects(cfg, []) // cache still cold at this synchronous point
|
||||
const [r1, r2] = await Promise.all([first, second])
|
||||
expect(r1.map((p) => p.name)).toContain('repoA')
|
||||
expect(r2.map((p) => p.name)).toEqual(r1.map((p) => p.name))
|
||||
})
|
||||
})
|
||||
|
||||
// ── buildProjects: dirty check (real git, when available) ─────────────────────────
|
||||
|
||||
describe('buildProjects — dirty check', () => {
|
||||
it('computes dirty via git status only when projectDirtyCheck is true', async () => {
|
||||
if (!(await gitAvailable())) return // git not installed -> skip
|
||||
const repo = path.join(tmp, 'gitrepo')
|
||||
await fs.mkdir(repo, { recursive: true })
|
||||
await execFileP('git', ['init', '-q'], { cwd: repo })
|
||||
await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd: repo })
|
||||
await execFileP('git', ['config', 'user.name', 'tester'], { cwd: repo })
|
||||
|
||||
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: true })
|
||||
|
||||
// clean repo -> dirty false
|
||||
let out = await buildProjects(cfg, [])
|
||||
const clean = out.find((p) => p.name === 'gitrepo')!
|
||||
expect(clean.dirty).toBe(false)
|
||||
expect(clean.branch).toBeTruthy()
|
||||
|
||||
// untracked file -> dirty true
|
||||
await fs.writeFile(path.join(repo, 'a.txt'), 'hi')
|
||||
_clearProjectCache()
|
||||
out = await buildProjects(cfg, [])
|
||||
expect(out.find((p) => p.name === 'gitrepo')!.dirty).toBe(true)
|
||||
})
|
||||
|
||||
it('never spawns git (dirty undefined) when projectDirtyCheck is false', async () => {
|
||||
await makeRepo(path.join(tmp, 'repoA'), 'main')
|
||||
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
|
||||
const out = await buildProjects(cfg, [])
|
||||
expect(out.find((p) => p.name === 'repoA')!.dirty).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -2,10 +2,10 @@
|
||||
/**
|
||||
* test/tabs.test.ts — frontend TabApp unit tests.
|
||||
*
|
||||
* Mocks TerminalSession + the launcher so we test TabApp's tab lifecycle in
|
||||
* isolation: the v0.5 "close last tab → launcher" invariant and that addEntry
|
||||
* builds a real (non-null) session before pushing the entry (Phase 1#5 — the
|
||||
* `null as unknown as TerminalSession` hole is gone).
|
||||
* Mocks TerminalSession + the launcher + projects panel so we test TabApp's
|
||||
* tab lifecycle in isolation: the v0.5 "close last tab → launcher" invariant,
|
||||
* that addEntry builds a real (non-null) session before pushing the entry
|
||||
* (Phase 1#5), and the v0.6 openProject / home segmented-control wiring (P6).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
@@ -19,6 +19,8 @@ class FakeTerminalSession {
|
||||
claudeStatus = 'unknown'
|
||||
cwd: string | null = null
|
||||
pendingApproval = false
|
||||
/** Captured so tests can assert initialInput ends with \r (Finding 1). */
|
||||
initialInput: string | undefined = undefined
|
||||
connect = vi.fn()
|
||||
dispose = vi.fn()
|
||||
show = vi.fn()
|
||||
@@ -41,13 +43,16 @@ class FakeTerminalSession {
|
||||
static instances: FakeTerminalSession[] = []
|
||||
constructor(opts: {
|
||||
sessionId: string | null
|
||||
initialInput?: string
|
||||
onClaudeStatus?: (s: string, d?: string) => void
|
||||
onStatus?: (s: string) => void
|
||||
onActivity?: () => void
|
||||
onTitle?: (t: string) => void
|
||||
[key: string]: unknown
|
||||
}) {
|
||||
constructed += 1
|
||||
this.id = opts.sessionId
|
||||
this.initialInput = opts.initialInput
|
||||
this.el = document.createElement('div')
|
||||
this.cbs = opts
|
||||
FakeTerminalSession.instances.push(this)
|
||||
@@ -66,6 +71,16 @@ const mountLauncher = vi.fn(() => ({
|
||||
}))
|
||||
vi.mock('../public/launcher.js', () => ({ mountLauncher }))
|
||||
|
||||
// ── Mock the projects panel (records visibility) ──────────────────────────────
|
||||
const projectsVisible = { value: false }
|
||||
const mountProjects = vi.fn(() => ({
|
||||
setVisible: (v: boolean) => {
|
||||
projectsVisible.value = v
|
||||
},
|
||||
refresh: vi.fn(),
|
||||
}))
|
||||
vi.mock('../public/projects.js', () => ({ mountProjects }))
|
||||
|
||||
// settings.js is light but imports nothing heavy; let it load for real.
|
||||
|
||||
const { TabApp } = await import('../public/tabs.js')
|
||||
@@ -81,6 +96,7 @@ beforeEach(() => {
|
||||
constructed = 0
|
||||
FakeTerminalSession.instances = []
|
||||
launcherVisible.value = false
|
||||
projectsVisible.value = false
|
||||
document.body.replaceChildren()
|
||||
localStorage.clear()
|
||||
})
|
||||
@@ -201,6 +217,22 @@ describe('TabApp — multi-tab lifecycle', () => {
|
||||
expect(app.snapshot()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('newTabForResume initialInput ends with \\r so the shell auto-executes it (Finding 1)', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTabForResume('/work', '33333333-3333-4333-8333-333333333333')
|
||||
const session = FakeTerminalSession.instances[0]!
|
||||
expect(session.initialInput).toBe('claude --resume 33333333-3333-4333-8333-333333333333\r')
|
||||
})
|
||||
|
||||
it('openProject default initialInput is "claude\\r" so the shell auto-executes it (A3/Finding 1)', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.openProject('/path/to/myrepo', 'myrepo')
|
||||
const session = FakeTerminalSession.instances[0]!
|
||||
expect(session.initialInput).toBe('claude\r')
|
||||
})
|
||||
|
||||
it('snapshot reflects per-tab connection + claude status fields', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
@@ -346,3 +378,88 @@ describe('TabApp — DOM interactions on the tab bar', () => {
|
||||
expect(app.snapshot()).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TabApp — P6 openProject + home segmented control', () => {
|
||||
it('openProject creates a tab with the repo name as its displayed title', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.openProject('/path/to/myrepo', 'myrepo')
|
||||
expect(app.snapshot()).toHaveLength(1)
|
||||
expect(app.snapshot()[0]!.title).toBe('myrepo')
|
||||
})
|
||||
|
||||
it('a second openProject with the same name gets a #2 suffix', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.openProject('/path/to/myrepo', 'myrepo')
|
||||
app.openProject('/path/to/myrepo', 'myrepo')
|
||||
const snap = app.snapshot()
|
||||
expect(snap).toHaveLength(2)
|
||||
expect(snap[0]!.title).toBe('myrepo')
|
||||
expect(snap[1]!.title).toBe('myrepo #2')
|
||||
})
|
||||
|
||||
it('countOpenWithTitlePrefix increments correctly for three tabs with the same base name', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.openProject('/p', 'proj')
|
||||
app.openProject('/p', 'proj')
|
||||
app.openProject('/p', 'proj')
|
||||
const snap = app.snapshot()
|
||||
expect(snap[0]!.title).toBe('proj')
|
||||
expect(snap[1]!.title).toBe('proj #2')
|
||||
expect(snap[2]!.title).toBe('proj #3')
|
||||
})
|
||||
|
||||
it('openProject hides the segmented control and both panels (tab open)', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.openProject('/p/repo', 'repo')
|
||||
const seg = paneHost.querySelector('.home-seg') as HTMLElement
|
||||
expect(seg).not.toBeNull()
|
||||
expect(seg.style.display).toBe('none')
|
||||
expect(launcherVisible.value).toBe(false)
|
||||
expect(projectsVisible.value).toBe(false)
|
||||
})
|
||||
|
||||
it('home segmented control shows both panels depending on selected view', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
new TabApp(paneHost, tabBar)
|
||||
|
||||
// Initially: sessions view → launcher visible, projects hidden.
|
||||
expect(launcherVisible.value).toBe(true)
|
||||
expect(projectsVisible.value).toBe(false)
|
||||
|
||||
// Click the "Projects" button (second .home-seg-btn).
|
||||
const projBtn = paneHost.querySelectorAll('.home-seg-btn')[1] as HTMLButtonElement
|
||||
projBtn.click()
|
||||
expect(launcherVisible.value).toBe(false)
|
||||
expect(projectsVisible.value).toBe(true)
|
||||
|
||||
// Click the "Sessions" button (first .home-seg-btn).
|
||||
const sessBtn = paneHost.querySelectorAll('.home-seg-btn')[0] as HTMLButtonElement
|
||||
sessBtn.click()
|
||||
expect(launcherVisible.value).toBe(true)
|
||||
expect(projectsVisible.value).toBe(false)
|
||||
})
|
||||
|
||||
it('closing the last tab shows the segmented control and restores home view', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
const seg = paneHost.querySelector('.home-seg') as HTMLElement
|
||||
expect(seg.style.display).toBe('none') // hidden while tab open
|
||||
|
||||
app.closeTab(0)
|
||||
expect(seg.style.display).not.toBe('none') // back to home
|
||||
expect(launcherVisible.value).toBe(true) // sessions view by default
|
||||
})
|
||||
|
||||
it('openProject with an empty repoPath still opens a tab', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
expect(() => app.openProject('', 'bare-repo')).not.toThrow()
|
||||
expect(app.snapshot()).toHaveLength(1)
|
||||
expect(app.snapshot()[0]!.title).toBe('bare-repo')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user