mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
A second review of the previous commit found that its own repair for the session leak introduced three defects, all from reaching for cleanupSession() to undo a half-built session. That function is the user-initiated delete, not an undo. It banked the session's historical token and cost totals into the lifetime figures, and a reboot never runs cleanup, so those totals had never been counted before; every failed rebuild added them again. It saw the pin that had just been restored and demoted the record to `stopped`, which this pass reads as the durable marker of a deliberate kill, so a pinned session whose rebuild failed became permanently unrestorable. And it recursively removed `.claude-images` from the working directory, which belongs to the workspace rather than to the session, so a failed rebuild destroyed the pasted images of any other live session in that repo. discardPartiallyBuiltSession() now undoes only what the construction did: the map entry, the tab-layout slot, the listeners and any pane the launch created before throwing. The persisted record, the lifetime totals, the Ralph state and the workspace's files are left alone. Re-applying the persisted state also splits in two, which removes the first two defects at the root rather than only at the call site. The half that shapes the pane, the custom-model environment and the nice priority, still runs before the spawn. The half that is the session's own history now runs after it, so a session whose pane never started carries no totals and no pin for anything downstream to misread. The rest of that review. The multi-user workspace confinement re-check read the requesting user's grant, and returns true for an admin, so the case its own comment described was the one it missed; it now resolves the entry owner's grant through isWorkingDirAllowedForUsername, the way cron does. A forbidden workspace goes back on offer, matching both the registry's stated contract and the API reference. The client re-reads the plan after a restore instead of blanking the banner, so entries the server put back stay reachable, and a 409 now says a restore is already running rather than reporting a failure. A dismiss arriving mid-restore wins, through a generation counter the route carries across its take. The re-application also restores the tab colour, the image-watcher flag and the original pinnedAt, via a new Session.restorePin that does not re-stamp the pin time. The phone breakpoint gains min-width: 0, without which a nowrap flex item never shrinks and the buttons still overflow, and it folds into the existing phone block. Ralph's loop configuration still does not survive a restore, because toState() reads it off a live tracker and there is no way to keep it without arming the loop. The method now says so rather than leaving it implied. Tests. The capacity test could not fail on the property it existed for: it filled the board past the cap before the loop, so a single pre-loop check would have passed it. It now leaves one seat, so only a per-iteration check restores exactly one entry. New tests cover the ordering around the spawn, a throw before the loop returning the whole plan and releasing the flight, the dismiss-during-restore race, and that the failure path calls the narrow discard rather than the delete. The shared mock context gains the port method it was missing, which is what made the first run of these tests fail for the wrong reason. Refs #411 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
183 lines
6.7 KiB
TypeScript
183 lines
6.7 KiB
TypeScript
/**
|
|
* Mock context for route handler testing.
|
|
*
|
|
* Satisfies ALL port interfaces (SessionPort, EventPort, RespawnPort,
|
|
* ConfigPort, InfraPort, AuthPort) so any route module can be tested.
|
|
* Override specific methods in individual tests as needed.
|
|
*
|
|
* Uses app.inject() — no real HTTP ports needed.
|
|
*/
|
|
import { vi } from 'vitest';
|
|
import { MockSession, createMockSession } from './mock-session.js';
|
|
import { resolveTerminalHistoryConfig } from '../../src/config/terminal-history.js';
|
|
|
|
/**
|
|
* Creates a mock context that satisfies all port interfaces.
|
|
* Pre-populated with one session for convenience.
|
|
*/
|
|
export function createMockRouteContext(options?: {
|
|
sessionId?: string;
|
|
agentSkillEnabled?: boolean;
|
|
claudeVoiceEnabled?: boolean;
|
|
workspaceHooksEnabled?: boolean;
|
|
}) {
|
|
const sessionId = options?.sessionId ?? 'test-session-1';
|
|
const session = createMockSession(sessionId);
|
|
const sessions = new Map<string, MockSession>();
|
|
sessions.set(sessionId, session);
|
|
|
|
// Stateful backing for the global tab order (COD-131) so route tests can
|
|
// assert that setSessionOrder() actually persists what the handler computed.
|
|
let sessionOrder: string[] = [];
|
|
|
|
return {
|
|
// -- SessionPort --
|
|
sessions,
|
|
addSession: vi.fn(async (s: MockSession) => {
|
|
sessions.set(s.id, s);
|
|
}),
|
|
tabLayouts: {
|
|
get: vi.fn(),
|
|
put: vi.fn(),
|
|
putLegacyOrder: vi.fn(async (_actor: unknown, order: readonly string[]) => ({
|
|
order: [...order],
|
|
changedOwnerOrders: {},
|
|
globalOrder: [...order],
|
|
globalChanged: false,
|
|
})),
|
|
sessionCreated: vi.fn(),
|
|
webviewCreated: vi.fn(),
|
|
sessionsRemoved: vi.fn(async () => {}),
|
|
webviewDeleted: vi.fn(async () => {}),
|
|
markRestorationComplete: vi.fn(),
|
|
markRestorationFailed: vi.fn(),
|
|
markRestorationSkipped: vi.fn(),
|
|
assertDeletionReady: vi.fn(),
|
|
runSessionDeletion: vi.fn(async (_removed, action) => action()),
|
|
runStaleSessionCleanup: vi.fn(async (_activeIds, action) => action(new Set())),
|
|
reconcileAfterRestoration: vi.fn(async () => {}),
|
|
},
|
|
cleanupSession: vi.fn(async () => {}),
|
|
setupSessionListeners: vi.fn(async () => {}),
|
|
persistSessionState: vi.fn(),
|
|
persistSessionStateNow: vi.fn(),
|
|
reapplyPersistedSessionState: vi.fn(async () => {}),
|
|
discardPartiallyBuiltSession: vi.fn(async (id: string) => {
|
|
sessions.delete(id);
|
|
}),
|
|
getSessionStateWithRespawn: vi.fn((s: MockSession) => s.toState()),
|
|
|
|
// -- EventPort --
|
|
broadcast: vi.fn(),
|
|
sendPushNotifications: vi.fn(),
|
|
batchTerminalData: vi.fn(),
|
|
broadcastSessionStateDebounced: vi.fn(),
|
|
batchTaskUpdate: vi.fn(),
|
|
getSseClientCount: vi.fn(() => 0),
|
|
|
|
// -- RespawnPort --
|
|
respawnControllers: new Map(),
|
|
respawnTimers: new Map(),
|
|
setupRespawnListeners: vi.fn(),
|
|
setupTimedRespawn: vi.fn(),
|
|
restoreRespawnController: vi.fn(),
|
|
saveRespawnConfig: vi.fn(),
|
|
|
|
// -- ConfigPort --
|
|
store: {
|
|
getConfig: vi.fn(() => ({ ralphEnabled: false, maxConcurrentSessions: 5 })),
|
|
getSessions: vi.fn(() => ({})),
|
|
getSession: vi.fn(),
|
|
setSession: vi.fn(),
|
|
removeSession: vi.fn(),
|
|
demoteOrRemoveSession: vi.fn(() => 'removed' as const),
|
|
getSettings: vi.fn(() => ({})),
|
|
setSettings: vi.fn(),
|
|
getRalphLoopState: vi.fn(() => ({})),
|
|
setRalphLoopState: vi.fn(),
|
|
getTasks: vi.fn(() => ({})),
|
|
save: vi.fn(),
|
|
load: vi.fn(),
|
|
incrementSessionsCreated: vi.fn(),
|
|
setConfig: vi.fn(),
|
|
getSessionOrder: vi.fn(() => sessionOrder),
|
|
setSessionOrder: vi.fn((order: string[]) => {
|
|
sessionOrder = order;
|
|
}),
|
|
getAggregateStats: vi.fn(() => ({ totalInputTokens: 0, totalOutputTokens: 0, totalCost: 0 })),
|
|
getGlobalStats: vi.fn(() => ({ sessionsCreated: 0 })),
|
|
getDailyStats: vi.fn(() => []),
|
|
cleanupStaleSessions: vi.fn(() => ({ count: 0, cleaned: [] })),
|
|
cleanupSessionsByIds: vi.fn(() => ({ count: 0, cleaned: [] })),
|
|
},
|
|
port: 3000,
|
|
https: false,
|
|
testMode: true,
|
|
serverStartTime: Date.now(),
|
|
getGlobalNiceConfig: vi.fn(async () => undefined),
|
|
getModelConfig: vi.fn(async () => null),
|
|
getClaudeModeConfig: vi.fn(async () => ({})),
|
|
getTerminalHistoryConfig: vi.fn(async () => resolveTerminalHistoryConfig({})),
|
|
// Default OFF mirrors the shipped setting, so existing tests never touch a
|
|
// case's .claude/skills. Overridable per test because the create-time
|
|
// injection call sites are otherwise unreachable from a route test.
|
|
getAgentSkillEnabled: vi.fn(async () => options?.agentSkillEnabled ?? false),
|
|
// Default OFF mirrors the shipped setting: no test opens a voice relay by accident.
|
|
getClaudeVoiceEnabled: vi.fn(async () => options?.claudeVoiceEnabled ?? false),
|
|
// Default ON mirrors the shipped setting, so a route test sees what a user sees.
|
|
// Writes land in the test's temp working dir, never in a real repo.
|
|
getWorkspaceHooksEnabled: vi.fn(async () => options?.workspaceHooksEnabled ?? true),
|
|
getDefaultClaudeMdPath: vi.fn(async () => undefined),
|
|
getLightState: vi.fn(() => ({ sessions: [], status: 'ok' })),
|
|
getLightSessionsState: vi.fn(() => {
|
|
const result: Record<string, unknown>[] = [];
|
|
for (const s of sessions.values()) {
|
|
result.push(s.toState());
|
|
}
|
|
return result;
|
|
}),
|
|
startTranscriptWatcher: vi.fn(),
|
|
stopTranscriptWatcher: vi.fn(),
|
|
getTranscriptPath: vi.fn(() => null),
|
|
getReadMyMindModel: vi.fn(async () => 'claude-opus-4-5-20251101'),
|
|
|
|
// -- InfraPort --
|
|
mux: {
|
|
muxSocket: 'codeman',
|
|
createSession: vi.fn(),
|
|
killSession: vi.fn(),
|
|
listSessions: vi.fn(() => []),
|
|
getStats: vi.fn(() => ({})),
|
|
updateSessionName: vi.fn(() => true),
|
|
getSession: vi.fn(() => null),
|
|
clearRespawnConfig: vi.fn(),
|
|
updateRespawnConfig: vi.fn(),
|
|
setHistoryLimit: vi.fn(async () => {}),
|
|
startStatsCollection: vi.fn(),
|
|
},
|
|
runSummaryTrackers: new Map(),
|
|
activePlanOrchestrators: new Map(),
|
|
scheduledRuns: new Map(),
|
|
teamWatcher: { getTeams: vi.fn(() => []), getTeamTasks: vi.fn(() => []), hasActiveTeammates: vi.fn(() => false) },
|
|
tunnelManager: null,
|
|
pushStore: null,
|
|
startScheduledRun: vi.fn(),
|
|
stopScheduledRun: vi.fn(),
|
|
|
|
// -- AuthPort --
|
|
authSessions: null,
|
|
qrAuthFailures: null,
|
|
// https already declared above in ConfigPort (shared property)
|
|
|
|
// -- OrchestratorPort --
|
|
orchestratorLoop: null,
|
|
initOrchestratorLoop: vi.fn(),
|
|
|
|
// Convenience accessors (not part of any port interface)
|
|
_session: session,
|
|
_sessionId: sessionId,
|
|
};
|
|
}
|
|
|
|
export type MockRouteContext = ReturnType<typeof createMockRouteContext>;
|