mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
SessionMode gains 'grok', a first-class backend alongside Claude Code,
shell, OpenCode, Codex, Gemini, Antigravity and Pi: its own PTY, tmux
session, charcoal tab identity ('gk' badge), welcome button, run-mode
entry, cron agentType, Docker and remote-SSH command defaults, and
clone-repo Brain option. Flag surface verified live against grok 1.0.5.
Grok mixes two existing shapes and the wiring follows from that:
- Codex-shaped on permissions: the bypass switch is GrokConfig.alwaysApprove
(--always-approve, grok's bypassPermissions mode; config-level deny rules
still apply on top). The Run button sends it true, like runAntigravity(),
and clampExternalCliBypassForOwner() puts grok in the only-if-sent branch:
a bare grok spawn is grok's own ask-mode default, which is already safe,
so only a sent config needs the flag forced off. Cron needs nothing for
the same reason.
- OpenCode-shaped on rendering: grok is a fullscreen alternate-screen TUI
with mouse support, so it stays OUT of isAltScreenStripMode() and lands
on the narrow tmux-attach strip and the 'buffer' local-echo fallthrough
(unmeasured against an authenticated composer; documented fallback is the
'off' branch).
- Pi-shaped on resolution: 'grok' has npm squatters (@vibe-kit/grok-cli
also installs a grok bin), so grok-cli-resolver.ts version-probes every
candidate (grok --version, killSignal SIGKILL, VITEST-gated) and
GET /api/grok/status surfaces path AND version; GROK_VERSION_REGEX is
shared with the dependency registry so doctor and run mode cannot drift.
Env allowlist gains GROK_* plus the XAI_* vendor namespace (XAI_API_KEY is
grok's documented headless auth var), the same narrow-vendor reasoning as
GOOGLE_* for gemini. Resume is id-regexed on purpose: grok's own --resume
also matches session titles, which are arbitrary user strings that must
never reach the bash -c spawn line.
Docker: grok is not on npm, so the agent image installs it in its own step
(xAI's installer has no --dir override; the binary is copied to
/usr/local/bin and root's ~/.grok dropped in the same layer), and
credentials are seeded per-file (auth.json, config.toml, pager.toml; the
dir also holds sessions/, memory/ and the ~160MB binary). Remote SSH routes
through the login-shell wrapper like the other agent CLIs.
Verified end to end on an isolated CODEMAN_INSTANCE with grok 1.0.5
installed: /api/grok/status resolves and reports the probed version,
quick-start spawns a pane whose command line ends in 'grok
--always-approve', the real TUI renders (OAuth device screen on an
unauthenticated box), and grokConfig round-trips through state.json.
Docs: docs/grok-integration.md (user guide) + docs/grok-integration-plan.md
(decisions, verification record, follow-ups).
Tests: test/grok-mode.test.ts, test/grok-cli-resolver.test.ts, plus
extended clamp/system-routes/render-index-html/run-mode-ui/mobile-overview/
local-echo-gating coverage. npm test (the CI gate) green: 5910 tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1195 lines
43 KiB
TypeScript
1195 lines
43 KiB
TypeScript
/**
|
|
* @fileoverview Tests for system route handlers.
|
|
*
|
|
* Uses app.inject() — no real HTTP ports needed.
|
|
* Port: N/A (app.inject doesn't open ports)
|
|
*
|
|
* Mocks: fs (promises + sync), subagentWatcher, imageWatcher,
|
|
* session-lifecycle-log, and opencode-cli-resolver singletons.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
|
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
|
|
|
|
// ── Mocks ──────────────────────────────────────────────────────────
|
|
|
|
vi.mock('node:fs/promises', () => ({
|
|
default: {
|
|
readFile: vi.fn(async () => '{}'),
|
|
writeFile: vi.fn(async () => undefined),
|
|
},
|
|
}));
|
|
|
|
vi.mock('node:fs', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('node:fs')>();
|
|
return {
|
|
...actual,
|
|
existsSync: vi.fn(() => true),
|
|
mkdirSync: vi.fn(),
|
|
readdirSync: vi.fn(() => []),
|
|
};
|
|
});
|
|
|
|
vi.mock('../../src/subagent-watcher.js', () => ({
|
|
subagentWatcher: {
|
|
getSubagents: vi.fn(() => []),
|
|
getRecentSubagents: vi.fn(() => []),
|
|
getSubagentsForSession: vi.fn(() => []),
|
|
getSubagent: vi.fn(() => null),
|
|
getTranscript: vi.fn(async () => []),
|
|
formatTranscript: vi.fn(() => ''),
|
|
killSubagent: vi.fn(async () => false),
|
|
cleanupNow: vi.fn(() => 0),
|
|
clearAll: vi.fn(() => 0),
|
|
getStats: vi.fn(() => ({
|
|
totalAgents: 0,
|
|
activeAgents: 0,
|
|
fileDebouncerCount: 0,
|
|
dirWatcherCount: 0,
|
|
idleTimerCount: 0,
|
|
})),
|
|
isRunning: vi.fn(() => true),
|
|
start: vi.fn(),
|
|
stop: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../src/image-watcher.js', () => ({
|
|
imageWatcher: {
|
|
isRunning: vi.fn(() => false),
|
|
start: vi.fn(),
|
|
stop: vi.fn(),
|
|
watchSession: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../src/session-lifecycle-log.js', () => ({
|
|
getLifecycleLog: vi.fn(() => ({
|
|
log: vi.fn(),
|
|
query: vi.fn(async () => []),
|
|
})),
|
|
}));
|
|
|
|
vi.mock('../../src/utils/opencode-cli-resolver.js', () => ({
|
|
isOpenCodeAvailable: vi.fn(() => false),
|
|
resolveOpenCodeDir: vi.fn(() => null),
|
|
}));
|
|
|
|
vi.mock('../../src/utils/gemini-cli-resolver.js', () => ({
|
|
isGeminiAvailable: vi.fn(() => false),
|
|
resolveGeminiDir: vi.fn(() => null),
|
|
}));
|
|
|
|
vi.mock('../../src/utils/antigravity-cli-resolver.js', () => ({
|
|
isAntigravityAvailable: vi.fn(() => false),
|
|
resolveAntigravityDir: vi.fn(() => null),
|
|
}));
|
|
|
|
vi.mock('../../src/utils/pi-cli-resolver.js', () => ({
|
|
isPiAvailable: vi.fn(() => false),
|
|
resolvePiDir: vi.fn(() => null),
|
|
getPiCliVersion: vi.fn(() => null),
|
|
}));
|
|
|
|
vi.mock('../../src/utils/grok-cli-resolver.js', () => ({
|
|
isGrokAvailable: vi.fn(() => false),
|
|
resolveGrokDir: vi.fn(() => null),
|
|
getGrokCliVersion: vi.fn(() => null),
|
|
}));
|
|
|
|
import fs from 'node:fs/promises';
|
|
import { existsSync, readdirSync } from 'node:fs';
|
|
import { subagentWatcher } from '../../src/subagent-watcher.js';
|
|
import { getLifecycleLog } from '../../src/session-lifecycle-log.js';
|
|
import { isOpenCodeAvailable, resolveOpenCodeDir } from '../../src/utils/opencode-cli-resolver.js';
|
|
import { isGeminiAvailable, resolveGeminiDir } from '../../src/utils/gemini-cli-resolver.js';
|
|
import { isAntigravityAvailable, resolveAntigravityDir } from '../../src/utils/antigravity-cli-resolver.js';
|
|
import { isPiAvailable, resolvePiDir, getPiCliVersion } from '../../src/utils/pi-cli-resolver.js';
|
|
import { isGrokAvailable, resolveGrokDir, getGrokCliVersion } from '../../src/utils/grok-cli-resolver.js';
|
|
|
|
const mockedReadFile = vi.mocked(fs.readFile);
|
|
const mockedWriteFile = vi.mocked(fs.writeFile);
|
|
const mockedExistsSync = vi.mocked(existsSync);
|
|
const mockedReaddirSync = vi.mocked(readdirSync);
|
|
const mockedSubagentWatcher = vi.mocked(subagentWatcher);
|
|
const mockedGetLifecycleLog = vi.mocked(getLifecycleLog);
|
|
const mockedIsOpenCodeAvailable = vi.mocked(isOpenCodeAvailable);
|
|
const mockedResolveOpenCodeDir = vi.mocked(resolveOpenCodeDir);
|
|
const mockedIsGeminiAvailable = vi.mocked(isGeminiAvailable);
|
|
const mockedResolveGeminiDir = vi.mocked(resolveGeminiDir);
|
|
const mockedIsAntigravityAvailable = vi.mocked(isAntigravityAvailable);
|
|
const mockedResolveAntigravityDir = vi.mocked(resolveAntigravityDir);
|
|
const mockedIsPiAvailable = vi.mocked(isPiAvailable);
|
|
const mockedResolvePiDir = vi.mocked(resolvePiDir);
|
|
const mockedGetPiCliVersion = vi.mocked(getPiCliVersion);
|
|
const mockedIsGrokAvailable = vi.mocked(isGrokAvailable);
|
|
const mockedResolveGrokDir = vi.mocked(resolveGrokDir);
|
|
const mockedGetGrokCliVersion = vi.mocked(getGrokCliVersion);
|
|
|
|
describe('system-routes', () => {
|
|
let harness: RouteTestHarness;
|
|
|
|
beforeEach(async () => {
|
|
harness = await createRouteTestHarness(registerSystemRoutes);
|
|
vi.clearAllMocks();
|
|
|
|
// Re-apply defaults after clearAllMocks
|
|
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
|
mockedWriteFile.mockResolvedValue(undefined);
|
|
mockedExistsSync.mockReturnValue(true);
|
|
mockedReaddirSync.mockReturnValue([]);
|
|
mockedSubagentWatcher.getSubagents.mockReturnValue([]);
|
|
mockedSubagentWatcher.getStats.mockReturnValue({
|
|
totalAgents: 0,
|
|
activeAgents: 0,
|
|
fileDebouncerCount: 0,
|
|
dirWatcherCount: 0,
|
|
idleTimerCount: 0,
|
|
} as never);
|
|
mockedGetLifecycleLog.mockReturnValue({
|
|
log: vi.fn(),
|
|
query: vi.fn(async () => []),
|
|
} as never);
|
|
mockedIsOpenCodeAvailable.mockReturnValue(false);
|
|
mockedResolveOpenCodeDir.mockReturnValue(null);
|
|
mockedIsGeminiAvailable.mockReturnValue(false);
|
|
mockedResolveGeminiDir.mockReturnValue(null);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await harness.app.close();
|
|
});
|
|
|
|
// ========== GET /api/status ==========
|
|
|
|
describe('GET /api/status', () => {
|
|
it('returns server status', async () => {
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/status' });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(harness.ctx.getLightState).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ========== GET /api/config ==========
|
|
|
|
describe('GET /api/config', () => {
|
|
it('returns config', async () => {
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/config' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.config).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ========== PUT /api/config ==========
|
|
|
|
describe('PUT /api/config', () => {
|
|
it('updates config with valid payload', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'PUT',
|
|
url: '/api/config',
|
|
payload: { maxConcurrentSessions: 10 },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.config).toBeDefined();
|
|
expect(harness.ctx.store.setConfig).toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects unknown config fields (strict schema)', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'PUT',
|
|
url: '/api/config',
|
|
payload: { unknownField: 'invalid' },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ========== GET /api/stats ==========
|
|
|
|
describe('GET /api/stats', () => {
|
|
it('returns aggregate stats', async () => {
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/stats' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.stats).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ========== GET /api/token-stats ==========
|
|
|
|
describe('GET /api/token-stats', () => {
|
|
it('returns daily and aggregate token stats', async () => {
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/token-stats' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.daily).toBeDefined();
|
|
expect(body.totals).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ========== GET /api/away-digest ==========
|
|
|
|
describe('GET /api/away-digest', () => {
|
|
it('returns a classified digest from lifecycle, active sessions, and token stats', async () => {
|
|
const lifecycleQuery = vi.fn(async () => [
|
|
{
|
|
ts: Date.now() - 1000,
|
|
event: 'exit',
|
|
sessionId: harness.ctx._sessionId,
|
|
name: 'Route Session',
|
|
exitCode: 7,
|
|
},
|
|
]);
|
|
mockedGetLifecycleLog.mockReturnValue({
|
|
log: vi.fn(),
|
|
query: lifecycleQuery,
|
|
} as never);
|
|
harness.ctx._session.name = 'Route Session';
|
|
harness.ctx._session.status = 'working';
|
|
harness.ctx.store.getDailyStats.mockReturnValue([
|
|
{
|
|
// LOCAL date (not toISOString/UTC): away-digest's dayOverlapsRange parses
|
|
// the date as local midnight, so a UTC date near the local-midnight boundary
|
|
// (e.g. running at 01:xx CEST = prior-day UTC) would fall outside the 1h
|
|
// window and make this assertion TZ/hour-flaky.
|
|
date: new Date().toLocaleDateString('en-CA'),
|
|
inputTokens: 100,
|
|
outputTokens: 200,
|
|
estimatedCost: 0.02,
|
|
sessions: 1,
|
|
},
|
|
]);
|
|
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/away-digest?range=1h' });
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(true);
|
|
expect(body.digest.sections.needsAttention[0]).toMatchObject({
|
|
sessionId: harness.ctx._sessionId,
|
|
source: 'lifecycle',
|
|
});
|
|
expect(body.digest.sections.stillRunning[0]).toMatchObject({
|
|
sessionId: harness.ctx._sessionId,
|
|
source: 'status',
|
|
});
|
|
expect(body.digest.totals).toMatchObject({
|
|
activeSessions: 1,
|
|
needsAttention: 1,
|
|
inputTokens: 100,
|
|
outputTokens: 200,
|
|
tokenWindowPrecision: 'day',
|
|
});
|
|
expect(lifecycleQuery).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
limit: 1000,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('rejects invalid ranges', async () => {
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/away-digest?range=forever' });
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(false);
|
|
expect(body.message ?? body.error).toMatch(/range/i);
|
|
});
|
|
});
|
|
|
|
// ========== GET /api/debug/memory ==========
|
|
|
|
describe('GET /api/debug/memory', () => {
|
|
it('returns memory and map usage info', async () => {
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/debug/memory' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.memory).toBeDefined();
|
|
expect(body.memory.rssMB).toBeGreaterThan(0);
|
|
expect(body.mapSizes).toBeDefined();
|
|
expect(body.uptime).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ========== GET /api/system/stats ==========
|
|
|
|
describe('GET /api/system/stats', () => {
|
|
it('returns CPU and memory stats', async () => {
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/system/stats' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body).toHaveProperty('cpu');
|
|
expect(body).toHaveProperty('memory');
|
|
});
|
|
});
|
|
|
|
// ========== POST /api/cleanup-state ==========
|
|
|
|
describe('POST /api/cleanup-state', () => {
|
|
it('cleans up stale session state', async () => {
|
|
const res = await harness.app.inject({ method: 'POST', url: '/api/cleanup-state' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.cleanedSessions).toBe(0);
|
|
expect(harness.ctx.store.cleanupStaleSessions).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ========== GET /api/subagents ==========
|
|
|
|
describe('GET /api/subagents', () => {
|
|
it('returns subagent list', async () => {
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/subagents' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(true);
|
|
expect(body.data).toBeDefined();
|
|
});
|
|
|
|
it('filters by minutes when query param provided', async () => {
|
|
mockedSubagentWatcher.getRecentSubagents.mockReturnValue([]);
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/subagents?minutes=30' });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(mockedSubagentWatcher.getRecentSubagents).toHaveBeenCalledWith(30);
|
|
});
|
|
});
|
|
|
|
// ========== POST /api/auth/revoke ==========
|
|
|
|
describe('POST /api/auth/revoke', () => {
|
|
it('returns success even without auth sessions', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'POST',
|
|
url: '/api/auth/revoke',
|
|
payload: {},
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body).toEqual({});
|
|
});
|
|
|
|
it('revokes a specific session token', async () => {
|
|
const authSessions = new Map();
|
|
authSessions.set('tok-123', { ip: '1.2.3.4', ua: 'test', createdAt: Date.now(), method: 'basic' });
|
|
harness.ctx.authSessions = authSessions;
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'POST',
|
|
url: '/api/auth/revoke',
|
|
payload: { sessionToken: 'tok-123' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(authSessions.has('tok-123')).toBe(false);
|
|
});
|
|
|
|
it('clears all sessions when no token specified', async () => {
|
|
const authSessions = new Map();
|
|
authSessions.set('a', { ip: '1', ua: '', createdAt: 0, method: 'basic' });
|
|
authSessions.set('b', { ip: '2', ua: '', createdAt: 0, method: 'qr' });
|
|
harness.ctx.authSessions = authSessions;
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'POST',
|
|
url: '/api/auth/revoke',
|
|
payload: {},
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(authSessions.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ========== GET /api/settings ==========
|
|
|
|
describe('GET /api/settings', () => {
|
|
it('returns empty object when settings file does not exist', async () => {
|
|
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
|
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/settings' });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(JSON.parse(res.body)).toEqual({});
|
|
});
|
|
|
|
it('returns parsed settings when file exists', async () => {
|
|
const settings = { subagentTrackingEnabled: true, showSystemStats: false };
|
|
mockedReadFile.mockResolvedValue(JSON.stringify(settings) as never);
|
|
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/settings' });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(JSON.parse(res.body)).toEqual(settings);
|
|
});
|
|
});
|
|
|
|
// ========== PUT /api/settings ==========
|
|
|
|
describe('PUT /api/settings', () => {
|
|
it('saves valid settings', async () => {
|
|
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'PUT',
|
|
url: '/api/settings',
|
|
payload: { showSystemStats: true, subagentTrackingEnabled: false },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(mockedWriteFile).toHaveBeenCalled();
|
|
});
|
|
|
|
it('merges with existing settings', async () => {
|
|
mockedReadFile.mockResolvedValue(JSON.stringify({ showCost: true }) as never);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'PUT',
|
|
url: '/api/settings',
|
|
payload: { showTokenCount: false },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(JSON.parse(res.body)).toEqual({});
|
|
|
|
// Verify writeFile was called with merged content
|
|
const writtenContent = JSON.parse(mockedWriteFile.mock.calls[0][1] as string);
|
|
expect(writtenContent.showCost).toBe(true);
|
|
expect(writtenContent.showTokenCount).toBe(false);
|
|
});
|
|
|
|
it('rejects unknown settings fields (strict schema)', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'PUT',
|
|
url: '/api/settings',
|
|
payload: { unknownField: 'bad' },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(false);
|
|
});
|
|
|
|
it('saves lastUsedCase as partial update without overwriting other settings', async () => {
|
|
mockedReadFile.mockResolvedValue(
|
|
JSON.stringify({ showCost: true, showTokenCount: false, subagentTrackingEnabled: true }) as never
|
|
);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'PUT',
|
|
url: '/api/settings',
|
|
payload: { lastUsedCase: 'my-test-case' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(JSON.parse(res.body)).toEqual({});
|
|
|
|
const writtenContent = JSON.parse(mockedWriteFile.mock.calls[0][1] as string);
|
|
expect(writtenContent.lastUsedCase).toBe('my-test-case');
|
|
expect(writtenContent.showCost).toBe(true);
|
|
expect(writtenContent.showTokenCount).toBe(false);
|
|
expect(writtenContent.subagentTrackingEnabled).toBe(true);
|
|
});
|
|
|
|
it('rejects settings with modelConfig (strict schema prevents full-object PUT)', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'PUT',
|
|
url: '/api/settings',
|
|
payload: { lastUsedCase: 'test', modelConfig: { model: 'something' } },
|
|
});
|
|
// Fastify rejects unknown fields at schema validation level (400) before handler runs
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it('rejects non-object body', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'PUT',
|
|
url: '/api/settings',
|
|
payload: 'not-an-object',
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
// Fastify will reject non-object JSON before it reaches the handler
|
|
expect(res.statusCode).not.toBe(200);
|
|
});
|
|
});
|
|
|
|
// ========== GET /api/subagent-window-states ==========
|
|
|
|
describe('GET /api/subagent-window-states', () => {
|
|
it('returns default when file does not exist', async () => {
|
|
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
|
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/subagent-window-states' });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(JSON.parse(res.body)).toEqual({ minimized: {}, open: [] });
|
|
});
|
|
|
|
it('returns persisted window states', async () => {
|
|
const states = { minimized: { 'agent-1': true }, open: ['agent-2'] };
|
|
mockedReadFile.mockResolvedValue(JSON.stringify(states) as never);
|
|
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/subagent-window-states' });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(JSON.parse(res.body)).toEqual(states);
|
|
});
|
|
});
|
|
|
|
// ========== PUT /api/subagent-window-states ==========
|
|
|
|
describe('PUT /api/subagent-window-states', () => {
|
|
it('saves valid window states', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'PUT',
|
|
url: '/api/subagent-window-states',
|
|
payload: { minimized: { 'agent-1': true }, open: ['agent-2'] },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(JSON.parse(res.body)).toEqual({});
|
|
expect(mockedWriteFile).toHaveBeenCalled();
|
|
});
|
|
|
|
it('accepts empty state', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'PUT',
|
|
url: '/api/subagent-window-states',
|
|
payload: { minimized: {}, open: [] },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(JSON.parse(res.body)).toEqual({});
|
|
});
|
|
|
|
it('rejects invalid minimized values', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'PUT',
|
|
url: '/api/subagent-window-states',
|
|
payload: { minimized: { 'agent-1': 'not-a-boolean' } },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ========== GET /api/subagent-parents ==========
|
|
|
|
describe('GET /api/subagent-parents', () => {
|
|
it('returns empty object when file does not exist', async () => {
|
|
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
|
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/subagent-parents' });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(JSON.parse(res.body)).toEqual({});
|
|
});
|
|
|
|
it('returns persisted parent map', async () => {
|
|
const parents = { 'agent-1': 'session-abc', 'agent-2': 'session-xyz' };
|
|
mockedReadFile.mockResolvedValue(JSON.stringify(parents) as never);
|
|
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/subagent-parents' });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(JSON.parse(res.body)).toEqual(parents);
|
|
});
|
|
});
|
|
|
|
// ========== PUT /api/subagent-parents ==========
|
|
|
|
describe('PUT /api/subagent-parents', () => {
|
|
it('saves valid parent map', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'PUT',
|
|
url: '/api/subagent-parents',
|
|
payload: { 'agent-1': 'session-abc' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(JSON.parse(res.body)).toEqual({});
|
|
expect(mockedWriteFile).toHaveBeenCalled();
|
|
});
|
|
|
|
it('accepts empty parent map', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'PUT',
|
|
url: '/api/subagent-parents',
|
|
payload: {},
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(JSON.parse(res.body)).toEqual({});
|
|
});
|
|
|
|
it('rejects non-string values in parent map', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'PUT',
|
|
url: '/api/subagent-parents',
|
|
payload: { 'agent-1': 123 },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ========== GET /api/screenshots ==========
|
|
|
|
describe('GET /api/screenshots', () => {
|
|
it('returns empty list when directory does not exist', async () => {
|
|
mockedExistsSync.mockReturnValue(false);
|
|
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/screenshots' });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(JSON.parse(res.body)).toEqual({ files: [] });
|
|
});
|
|
|
|
it('returns image files sorted in reverse order', async () => {
|
|
mockedExistsSync.mockReturnValue(true);
|
|
mockedReaddirSync.mockReturnValue([
|
|
'screenshot_2026-01-01_00-00-00.png',
|
|
'screenshot_2026-01-02_00-00-00.png',
|
|
'not-an-image.txt',
|
|
'screenshot_2026-01-03_00-00-00.jpg',
|
|
] as never);
|
|
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/screenshots' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.files).toHaveLength(3); // excludes .txt
|
|
// Sorted reverse: 03, 02, 01
|
|
expect(body.files[0].name).toBe('screenshot_2026-01-03_00-00-00.jpg');
|
|
expect(body.files[1].name).toBe('screenshot_2026-01-02_00-00-00.png');
|
|
expect(body.files[2].name).toBe('screenshot_2026-01-01_00-00-00.png');
|
|
});
|
|
});
|
|
|
|
// ========== GET /api/screenshots/:name ==========
|
|
|
|
describe('GET /api/screenshots/:name', () => {
|
|
it('rejects path traversal attempts', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: '/api/screenshots/..%2F..%2Fetc%2Fpasswd',
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it('returns 404 when screenshot does not exist', async () => {
|
|
mockedExistsSync.mockReturnValue(false);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: '/api/screenshots/nonexistent.png',
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it('serves existing screenshot with correct content type', async () => {
|
|
mockedExistsSync.mockReturnValue(true);
|
|
mockedReadFile.mockResolvedValue(Buffer.from('fake-png-data') as never);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: '/api/screenshots/test-image.png',
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.headers['content-type']).toBe('image/png');
|
|
});
|
|
|
|
it('serves jpeg with correct content type', async () => {
|
|
mockedExistsSync.mockReturnValue(true);
|
|
mockedReadFile.mockResolvedValue(Buffer.from('fake-jpg-data') as never);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: '/api/screenshots/photo.jpg',
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.headers['content-type']).toBe('image/jpeg');
|
|
});
|
|
});
|
|
|
|
// ========== POST /api/screenshots ==========
|
|
|
|
describe('POST /api/screenshots', () => {
|
|
it('rejects non-multipart content type', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'POST',
|
|
url: '/api/screenshots',
|
|
payload: { file: 'data' },
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(false);
|
|
expect(body.error).toContain('multipart');
|
|
});
|
|
});
|
|
|
|
// ========== GET /api/session-lifecycle ==========
|
|
|
|
describe('GET /api/session-lifecycle', () => {
|
|
it('returns lifecycle entries', async () => {
|
|
const mockEntries = [{ event: 'session_created', sessionId: 'test-1', timestamp: Date.now() }];
|
|
mockedGetLifecycleLog.mockReturnValue({
|
|
log: vi.fn(),
|
|
query: vi.fn(async () => mockEntries),
|
|
} as never);
|
|
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/session-lifecycle' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.entries).toEqual(mockEntries);
|
|
});
|
|
|
|
it('passes filter query params to lifecycle log', async () => {
|
|
const queryFn = vi.fn(async () => []);
|
|
mockedGetLifecycleLog.mockReturnValue({
|
|
log: vi.fn(),
|
|
query: queryFn,
|
|
} as never);
|
|
|
|
await harness.app.inject({
|
|
method: 'GET',
|
|
url: '/api/session-lifecycle?sessionId=s1&event=session_created&since=1000&limit=50',
|
|
});
|
|
|
|
expect(queryFn).toHaveBeenCalledWith({
|
|
sessionId: 's1',
|
|
event: 'session_created',
|
|
since: 1000,
|
|
limit: 50,
|
|
});
|
|
});
|
|
|
|
it('caps limit at 1000', async () => {
|
|
const queryFn = vi.fn(async () => []);
|
|
mockedGetLifecycleLog.mockReturnValue({
|
|
log: vi.fn(),
|
|
query: queryFn,
|
|
} as never);
|
|
|
|
await harness.app.inject({
|
|
method: 'GET',
|
|
url: '/api/session-lifecycle?limit=5000',
|
|
});
|
|
|
|
expect(queryFn).toHaveBeenCalledWith(expect.objectContaining({ limit: 1000 }));
|
|
});
|
|
|
|
it('defaults limit to 200 when not specified', async () => {
|
|
const queryFn = vi.fn(async () => []);
|
|
mockedGetLifecycleLog.mockReturnValue({
|
|
log: vi.fn(),
|
|
query: queryFn,
|
|
} as never);
|
|
|
|
await harness.app.inject({ method: 'GET', url: '/api/session-lifecycle' });
|
|
|
|
expect(queryFn).toHaveBeenCalledWith(expect.objectContaining({ limit: 200 }));
|
|
});
|
|
});
|
|
|
|
// ========== GET /api/opencode/status ==========
|
|
|
|
describe('GET /api/opencode/status', () => {
|
|
it('returns unavailable when opencode is not installed', async () => {
|
|
mockedIsOpenCodeAvailable.mockReturnValue(false);
|
|
mockedResolveOpenCodeDir.mockReturnValue(null);
|
|
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/opencode/status' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.available).toBe(false);
|
|
expect(body.path).toBeNull();
|
|
});
|
|
|
|
it('returns available with path when opencode is installed', async () => {
|
|
mockedIsOpenCodeAvailable.mockReturnValue(true);
|
|
mockedResolveOpenCodeDir.mockReturnValue('/usr/local/bin');
|
|
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/opencode/status' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.available).toBe(true);
|
|
expect(body.path).toBe('/usr/local/bin');
|
|
});
|
|
});
|
|
|
|
// ========== GET /api/gemini/status ==========
|
|
|
|
describe('GET /api/gemini/status', () => {
|
|
it('returns unavailable when gemini is not installed', async () => {
|
|
mockedIsGeminiAvailable.mockReturnValue(false);
|
|
mockedResolveGeminiDir.mockReturnValue(null);
|
|
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/gemini/status' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.available).toBe(false);
|
|
expect(body.path).toBeNull();
|
|
});
|
|
|
|
it('returns available with path when gemini is installed', async () => {
|
|
mockedIsGeminiAvailable.mockReturnValue(true);
|
|
mockedResolveGeminiDir.mockReturnValue('/usr/local/bin');
|
|
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/gemini/status' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.available).toBe(true);
|
|
expect(body.path).toBe('/usr/local/bin');
|
|
});
|
|
});
|
|
|
|
// ========== GET /api/antigravity/status ==========
|
|
|
|
describe('GET /api/antigravity/status', () => {
|
|
it('returns unavailable when agy is not installed', async () => {
|
|
mockedIsAntigravityAvailable.mockReturnValue(false);
|
|
mockedResolveAntigravityDir.mockReturnValue(null);
|
|
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/antigravity/status' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.available).toBe(false);
|
|
expect(body.path).toBeNull();
|
|
});
|
|
|
|
it('returns available with path when agy is installed', async () => {
|
|
mockedIsAntigravityAvailable.mockReturnValue(true);
|
|
mockedResolveAntigravityDir.mockReturnValue('/home/user/.local/bin');
|
|
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/antigravity/status' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.available).toBe(true);
|
|
expect(body.path).toBe('/home/user/.local/bin');
|
|
});
|
|
});
|
|
|
|
// ========== GET /api/pi/status ==========
|
|
|
|
describe('GET /api/pi/status', () => {
|
|
it('returns unavailable when pi is not installed', async () => {
|
|
mockedIsPiAvailable.mockReturnValue(false);
|
|
mockedResolvePiDir.mockReturnValue(null);
|
|
mockedGetPiCliVersion.mockReturnValue(null);
|
|
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/pi/status' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.available).toBe(false);
|
|
expect(body.path).toBeNull();
|
|
expect(body.version).toBeNull();
|
|
});
|
|
|
|
it('returns available with path AND version when pi is installed', async () => {
|
|
// `version` is pi-specific: `pi` is a generic binary name, so the resolver
|
|
// version-probes it and this endpoint is where a misresolution shows up.
|
|
mockedIsPiAvailable.mockReturnValue(true);
|
|
mockedResolvePiDir.mockReturnValue('/home/user/.local/bin');
|
|
mockedGetPiCliVersion.mockReturnValue('0.84.1');
|
|
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/pi/status' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.available).toBe(true);
|
|
expect(body.path).toBe('/home/user/.local/bin');
|
|
expect(body.version).toBe('0.84.1');
|
|
});
|
|
});
|
|
|
|
// ========== GET /api/grok/status ==========
|
|
|
|
describe('GET /api/grok/status', () => {
|
|
it('returns unavailable when grok is not installed', async () => {
|
|
mockedIsGrokAvailable.mockReturnValue(false);
|
|
mockedResolveGrokDir.mockReturnValue(null);
|
|
mockedGetGrokCliVersion.mockReturnValue(null);
|
|
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/grok/status' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.available).toBe(false);
|
|
expect(body.path).toBeNull();
|
|
expect(body.version).toBeNull();
|
|
});
|
|
|
|
it('returns available with path AND version when grok is installed', async () => {
|
|
// `version` matters for the same reason as pi: `grok` has known squatters,
|
|
// so this endpoint is where a misresolution shows up.
|
|
mockedIsGrokAvailable.mockReturnValue(true);
|
|
mockedResolveGrokDir.mockReturnValue('/home/user/.grok/bin');
|
|
mockedGetGrokCliVersion.mockReturnValue('1.0.5');
|
|
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/grok/status' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.available).toBe(true);
|
|
expect(body.path).toBe('/home/user/.grok/bin');
|
|
expect(body.version).toBe('1.0.5');
|
|
});
|
|
});
|
|
|
|
// ========== GET /api/execution/model-config ==========
|
|
|
|
describe('GET /api/execution/model-config', () => {
|
|
it('returns empty data when settings file does not exist', async () => {
|
|
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
|
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/execution/model-config' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(true);
|
|
expect(body.data).toEqual({});
|
|
});
|
|
|
|
it('returns model config from settings', async () => {
|
|
const settings = { modelConfig: { model: 'claude-3', temperature: 0.7 } };
|
|
mockedReadFile.mockResolvedValue(JSON.stringify(settings) as never);
|
|
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/execution/model-config' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(true);
|
|
expect(body.data).toEqual(settings.modelConfig);
|
|
});
|
|
|
|
it('returns empty data when settings has no modelConfig', async () => {
|
|
mockedReadFile.mockResolvedValue(JSON.stringify({ showCost: true }) as never);
|
|
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/execution/model-config' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(true);
|
|
expect(body.data).toEqual({});
|
|
});
|
|
});
|
|
|
|
// ========== PUT /api/execution/model-config ==========
|
|
|
|
describe('PUT /api/execution/model-config', () => {
|
|
it('saves valid model config', async () => {
|
|
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'PUT',
|
|
url: '/api/execution/model-config',
|
|
payload: { model: 'claude-3', temperature: 0.5 },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(JSON.parse(res.body)).toEqual({});
|
|
expect(mockedWriteFile).toHaveBeenCalled();
|
|
|
|
// Verify the written content contains modelConfig
|
|
const writtenContent = JSON.parse(mockedWriteFile.mock.calls[0][1] as string);
|
|
expect(writtenContent.modelConfig).toEqual({ model: 'claude-3', temperature: 0.5 });
|
|
});
|
|
|
|
it('preserves existing settings when updating model config', async () => {
|
|
mockedReadFile.mockResolvedValue(JSON.stringify({ showCost: true, other: 'value' }) as never);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'PUT',
|
|
url: '/api/execution/model-config',
|
|
payload: { model: 'new-model' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(JSON.parse(res.body)).toEqual({});
|
|
|
|
const writtenContent = JSON.parse(mockedWriteFile.mock.calls[0][1] as string);
|
|
expect(writtenContent.showCost).toBe(true);
|
|
expect(writtenContent.other).toBe('value');
|
|
expect(writtenContent.modelConfig).toEqual({ model: 'new-model' });
|
|
});
|
|
});
|
|
|
|
// ========== GET /api/subagents/:agentId ==========
|
|
|
|
describe('GET /api/subagents/:agentId', () => {
|
|
it('returns 200 with error for unknown agent', async () => {
|
|
mockedSubagentWatcher.getSubagent.mockReturnValue(null);
|
|
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/subagents/unknown-agent' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(false);
|
|
expect(body.error).toContain('not found');
|
|
});
|
|
|
|
it('returns agent info for known agent', async () => {
|
|
const agentInfo = { agentId: 'agent-1', status: 'active', pid: 1234 };
|
|
mockedSubagentWatcher.getSubagent.mockReturnValue(agentInfo as never);
|
|
|
|
const res = await harness.app.inject({ method: 'GET', url: '/api/subagents/agent-1' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(true);
|
|
expect(body.data).toEqual(agentInfo);
|
|
});
|
|
});
|
|
|
|
// ========== GET /api/subagents/:agentId/transcript ==========
|
|
|
|
describe('GET /api/subagents/:agentId/transcript', () => {
|
|
it('returns raw transcript by default', async () => {
|
|
const transcript = [{ role: 'assistant', content: 'hello' }];
|
|
mockedSubagentWatcher.getTranscript.mockResolvedValue(transcript as never);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: '/api/subagents/agent-1/transcript',
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(true);
|
|
expect(body.data).toEqual(transcript);
|
|
});
|
|
|
|
it('returns formatted transcript when format=formatted', async () => {
|
|
const transcript = [{ role: 'assistant', content: 'hello' }];
|
|
mockedSubagentWatcher.getTranscript.mockResolvedValue(transcript as never);
|
|
mockedSubagentWatcher.formatTranscript.mockReturnValue('## Formatted\nhello');
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: '/api/subagents/agent-1/transcript?format=formatted',
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(true);
|
|
expect(body.data.formatted).toBe('## Formatted\nhello');
|
|
expect(body.data.entryCount).toBe(1);
|
|
});
|
|
|
|
it('passes limit parameter', async () => {
|
|
mockedSubagentWatcher.getTranscript.mockResolvedValue([] as never);
|
|
|
|
await harness.app.inject({
|
|
method: 'GET',
|
|
url: '/api/subagents/agent-1/transcript?limit=10',
|
|
});
|
|
expect(mockedSubagentWatcher.getTranscript).toHaveBeenCalledWith('agent-1', 10);
|
|
});
|
|
});
|
|
|
|
// ========== DELETE /api/subagents/:agentId ==========
|
|
|
|
describe('DELETE /api/subagents/:agentId', () => {
|
|
it('returns error for unknown agent', async () => {
|
|
mockedSubagentWatcher.getSubagent.mockReturnValue(null);
|
|
|
|
const res = await harness.app.inject({ method: 'DELETE', url: '/api/subagents/unknown' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(false);
|
|
expect(body.error).toContain('not found');
|
|
});
|
|
|
|
it('kills a known agent successfully', async () => {
|
|
mockedSubagentWatcher.getSubagent.mockReturnValue({ agentId: 'agent-1' } as never);
|
|
mockedSubagentWatcher.killSubagent.mockResolvedValue(true);
|
|
|
|
const res = await harness.app.inject({ method: 'DELETE', url: '/api/subagents/agent-1' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(true);
|
|
expect(body.data.status).toBe('killed');
|
|
});
|
|
|
|
it('returns error when kill fails', async () => {
|
|
mockedSubagentWatcher.getSubagent.mockReturnValue({ agentId: 'agent-1' } as never);
|
|
mockedSubagentWatcher.killSubagent.mockResolvedValue(false);
|
|
|
|
const res = await harness.app.inject({ method: 'DELETE', url: '/api/subagents/agent-1' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(false);
|
|
expect(body.error).toContain('already completed');
|
|
});
|
|
});
|
|
|
|
// ========== POST /api/subagents/cleanup ==========
|
|
|
|
describe('POST /api/subagents/cleanup', () => {
|
|
it('triggers cleanup and returns count', async () => {
|
|
mockedSubagentWatcher.cleanupNow.mockReturnValue(3);
|
|
mockedSubagentWatcher.getSubagents.mockReturnValue([{ agentId: 'a' }, { agentId: 'b' }] as never);
|
|
|
|
const res = await harness.app.inject({ method: 'POST', url: '/api/subagents/cleanup' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(true);
|
|
expect(body.data.removed).toBe(3);
|
|
expect(body.data.remaining).toBe(2);
|
|
});
|
|
});
|
|
|
|
// ========== DELETE /api/subagents ==========
|
|
|
|
describe('DELETE /api/subagents', () => {
|
|
it('clears all subagents', async () => {
|
|
mockedSubagentWatcher.clearAll.mockReturnValue(5);
|
|
|
|
const res = await harness.app.inject({ method: 'DELETE', url: '/api/subagents' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(true);
|
|
expect(body.data.cleared).toBe(5);
|
|
});
|
|
});
|
|
|
|
// ========== GET /api/sessions/:id/subagents ==========
|
|
|
|
describe('GET /api/sessions/:id/subagents', () => {
|
|
it('returns 404 for unknown session', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: '/api/sessions/nonexistent/subagents',
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it('returns subagents for valid session', async () => {
|
|
mockedSubagentWatcher.getSubagentsForSession.mockReturnValue([{ agentId: 'sub-1', status: 'active' }] as never);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/subagents`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(true);
|
|
expect(body.data).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
// ========== POST /api/tunnel/qr/regenerate ==========
|
|
|
|
describe('POST /api/tunnel/qr/regenerate', () => {
|
|
it('calls regenerateQrToken on tunnel manager', async () => {
|
|
// Set up a mock tunnel manager
|
|
harness.ctx.tunnelManager = {
|
|
regenerateQrToken: vi.fn(),
|
|
getStatus: vi.fn(() => ({ running: false })),
|
|
getUrl: vi.fn(() => null),
|
|
getQrSvg: vi.fn(),
|
|
consumeToken: vi.fn(),
|
|
isRunning: vi.fn(() => false),
|
|
start: vi.fn(),
|
|
stop: vi.fn(),
|
|
} as never;
|
|
|
|
// Re-create the harness since tunnelManager was null originally
|
|
await harness.app.close();
|
|
harness = await createRouteTestHarness(registerSystemRoutes);
|
|
harness.ctx.tunnelManager = {
|
|
regenerateQrToken: vi.fn(),
|
|
getStatus: vi.fn(() => ({ running: false })),
|
|
getUrl: vi.fn(() => null),
|
|
getQrSvg: vi.fn(),
|
|
consumeToken: vi.fn(),
|
|
isRunning: vi.fn(() => false),
|
|
start: vi.fn(),
|
|
stop: vi.fn(),
|
|
} as never;
|
|
|
|
const res = await harness.app.inject({ method: 'POST', url: '/api/tunnel/qr/regenerate' });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(JSON.parse(res.body)).toEqual({});
|
|
});
|
|
});
|
|
});
|