Implements the fixes from docs/REVIEW_REPORT.md (4-agent parallel review). typecheck clean; 341 tests pass (16 files, +113); build:web ok; coverage thresholds (80%) enforced in vitest.config.ts. Critical: - multi-device approval race: release held approval only when the last client detaches (closing one mirror no longer cancels another's prompt) - unbounded session creation (DoS): Config.maxSessions cap (env MAX_SESSIONS), enforced in manager via the existing M4 exit(-1) path - signal-handler leak: named SIGINT/SIGTERM/uncaughtException refs removed in close() - terminal-session initialInput timer tracked + cleared on dispose - tabs.addEntry null-as-cast type hole removed (build session before entry) Should-fix: - security-headers middleware + Origin/CSRF guard on DELETE /live-sessions[/:id] - history.ts converted to fs/promises (async /sessions handler) - removed dead clientDims map + blur protocol message end-to-end - per-connection WS message rate limit (Config.maxMsgsPerSec) - /sessions behavior kept; documented as accepted LAN risk (TECH_DOC §7) Tests: - new tmux / preview-grid / terminal-session (jsdom) / tabs (jsdom) suites - extended history/config/manager/integration coverage incl. regressions Hygiene: - parsePositiveInt -> parseNonNegativeInt; ALLOWED_ORIGINS scheme validation - log-injection sanitize; isLoopback handles 127.0.0.0/8 + IPv4-mapped - operational constants moved into Config - extracted public/preview-grid.ts (DRY launcher/manage) - doc sweeps: ARCHITECTURE §8 runtime-handle exception, stale comments
124 lines
5.2 KiB
TypeScript
124 lines
5.2 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
// ── mock node:fs/promises + node:os ──────────────────────────────────────────
|
|
// Hoisted by vitest; history.ts binds to these mocks.
|
|
const mockReaddir = vi.fn()
|
|
const mockStat = vi.fn()
|
|
const mockOpen = vi.fn()
|
|
const mockHomedir = vi.fn(() => '/home/tester')
|
|
|
|
vi.mock('node:fs/promises', () => ({
|
|
default: {
|
|
readdir: (...a: unknown[]) => mockReaddir(...a),
|
|
stat: (...a: unknown[]) => mockStat(...a),
|
|
open: (...a: unknown[]) => mockOpen(...a),
|
|
},
|
|
}))
|
|
|
|
vi.mock('node:os', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('node:os')>()
|
|
return { ...actual, default: { ...actual.default, homedir: () => mockHomedir() } }
|
|
})
|
|
|
|
const { parseSessionMeta, listSessions } = await import('../src/http/history.js')
|
|
|
|
// ── parseSessionMeta (pure) ───────────────────────────────────────────────────
|
|
describe('parseSessionMeta', () => {
|
|
it('extracts cwd and the first user prompt (string content)', () => {
|
|
const jsonl = [
|
|
JSON.stringify({ type: 'last-prompt', sessionId: 'x' }),
|
|
JSON.stringify({ type: 'attachment', cwd: '/Users/me/proj', sessionId: 'x' }),
|
|
JSON.stringify({ type: 'user', message: { role: 'user', content: 'Add monitoring please' } }),
|
|
].join('\n')
|
|
expect(parseSessionMeta(jsonl)).toEqual({ cwd: '/Users/me/proj', preview: 'Add monitoring please' })
|
|
})
|
|
|
|
it('reads the first text block when content is an array', () => {
|
|
const jsonl = [
|
|
JSON.stringify({ type: 'user', cwd: '/p', message: { role: 'user', content: [{ type: 'text', text: 'hello there' }] } }),
|
|
].join('\n')
|
|
expect(parseSessionMeta(jsonl)).toEqual({ cwd: '/p', preview: 'hello there' })
|
|
})
|
|
|
|
it('collapses whitespace and truncates long previews to 120 chars', () => {
|
|
const long = 'a'.repeat(200)
|
|
const jsonl = JSON.stringify({ type: 'user', cwd: '/p', message: { role: 'user', content: `line1\n line2 ${long}` } })
|
|
const { preview } = parseSessionMeta(jsonl)
|
|
expect(preview.length).toBe(120)
|
|
expect(preview.startsWith('line1 line2 ')).toBe(true)
|
|
})
|
|
|
|
it('returns nulls/empty when there is no cwd or user message', () => {
|
|
expect(parseSessionMeta('')).toEqual({ cwd: null, preview: '' })
|
|
expect(parseSessionMeta('not json\n{"type":"summary"}')).toEqual({ cwd: null, preview: '' })
|
|
})
|
|
})
|
|
|
|
// ── listSessions (fs traversal, async) ────────────────────────────────────────
|
|
describe('listSessions', () => {
|
|
function mockFile(text: string) {
|
|
const buf = Buffer.from(text, 'utf8')
|
|
return {
|
|
read: vi.fn(async (b: Buffer, off: number, len: number) => {
|
|
const n = buf.copy(b, off, 0, Math.min(len, buf.length))
|
|
return { bytesRead: n }
|
|
}),
|
|
close: vi.fn(async () => undefined),
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mockReaddir.mockReset()
|
|
mockStat.mockReset()
|
|
mockOpen.mockReset()
|
|
mockHomedir.mockReturnValue('/home/tester')
|
|
})
|
|
|
|
it('returns [] when the projects root is missing', async () => {
|
|
mockReaddir.mockRejectedValueOnce(new Error('ENOENT'))
|
|
expect(await listSessions()).toEqual([])
|
|
})
|
|
|
|
it('lists .jsonl sessions newest-first with parsed cwd/preview/project', async () => {
|
|
mockReaddir
|
|
.mockResolvedValueOnce(['projA']) // root
|
|
.mockResolvedValueOnce(['old.jsonl', 'new.jsonl', 'ignore.txt']) // projA
|
|
mockStat.mockImplementation(async (p: string) => ({
|
|
mtimeMs: p.endsWith('new.jsonl') ? 2000 : 1000,
|
|
}))
|
|
mockOpen.mockImplementation(async (p: string) =>
|
|
p.endsWith('new.jsonl')
|
|
? mockFile(JSON.stringify({ type: 'user', cwd: '/work/newdir', message: { role: 'user', content: 'newest task' } }))
|
|
: mockFile(JSON.stringify({ type: 'user', cwd: '/work/olddir', message: { role: 'user', content: 'older task' } })),
|
|
)
|
|
|
|
const out = await listSessions()
|
|
expect(out.map((s) => s.id)).toEqual(['new', 'old']) // newest first
|
|
expect(out[0]).toMatchObject({ id: 'new', cwd: '/work/newdir', project: 'newdir', preview: 'newest task' })
|
|
// .txt is skipped
|
|
expect(out).toHaveLength(2)
|
|
})
|
|
|
|
it('skips a project subdir that cannot be read', async () => {
|
|
mockReaddir
|
|
.mockResolvedValueOnce(['good', 'bad'])
|
|
.mockResolvedValueOnce(['s.jsonl']) // good
|
|
.mockRejectedValueOnce(new Error('EACCES')) // bad
|
|
mockStat.mockResolvedValue({ mtimeMs: 1000 })
|
|
mockOpen.mockResolvedValue(mockFile(JSON.stringify({ type: 'user', cwd: '/g', message: { role: 'user', content: 'hi' } })))
|
|
|
|
const out = await listSessions()
|
|
expect(out).toHaveLength(1)
|
|
expect(out[0]!.id).toBe('s')
|
|
})
|
|
|
|
it('respects the limit', async () => {
|
|
mockReaddir.mockResolvedValueOnce(['p']).mockResolvedValueOnce(['a.jsonl', 'b.jsonl', 'c.jsonl'])
|
|
mockStat.mockImplementation(async (p: string) => ({ mtimeMs: p.charCodeAt(p.length - 7) }))
|
|
mockOpen.mockResolvedValue(mockFile(JSON.stringify({ type: 'user', cwd: '/p', message: { role: 'user', content: 'x' } })))
|
|
|
|
const out = await listSessions(2)
|
|
expect(out).toHaveLength(2)
|
|
})
|
|
})
|