mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 23:49:41 +02:00
fix: bulletproof test safety — IS_TEST_MODE guards prevent tests from killing real tmux sessions
Added IS_TEST_MODE (process.env.VITEST) guards to every method in TmuxManager and ScreenManager that touches real tmux/screen sessions. Tests can never create, kill, discover, or send input to real sessions. Removed broken E2E test suite entirely. Rewrote test/setup.ts from 459 lines to minimal cleanup. Rewrote tmux-related tests to verify test-mode safety behavior. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+17
-4
@@ -20,6 +20,19 @@ const TERMINAL_TAIL_SIZE = 256 * 1024; // 256KB tail for initial load
|
||||
const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
|
||||
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling
|
||||
|
||||
// Z-index base values for layered floating windows
|
||||
const ZINDEX_SUBAGENT_BASE = 1000;
|
||||
const ZINDEX_PLAN_SUBAGENT_BASE = 1100;
|
||||
const ZINDEX_LOG_VIEWER_BASE = 2000;
|
||||
const ZINDEX_IMAGE_POPUP_BASE = 3000;
|
||||
|
||||
// Subagent/floating window layout
|
||||
const WINDOW_INITIAL_TOP_PX = 120;
|
||||
const WINDOW_CASCADE_OFFSET_PX = 30;
|
||||
const WINDOW_MIN_WIDTH_PX = 200;
|
||||
const WINDOW_MIN_HEIGHT_PX = 200;
|
||||
const WINDOW_DEFAULT_WIDTH_PX = 300;
|
||||
|
||||
// DEC mode 2026 - Synchronized Output
|
||||
// Wrap terminal writes with these markers to prevent partial-frame flicker.
|
||||
// Terminal buffers all output between markers and renders atomically.
|
||||
@@ -1136,7 +1149,7 @@ class ClaudemanApp {
|
||||
this.activeSubagentId = null; // Currently selected subagent for detail view
|
||||
this.subagentPanelVisible = false;
|
||||
this.subagentWindows = new Map(); // Map<agentId, { element, position }>
|
||||
this.subagentWindowZIndex = 1000;
|
||||
this.subagentWindowZIndex = ZINDEX_SUBAGENT_BASE;
|
||||
this.minimizedSubagents = new Map(); // Map<sessionId, Set<agentId>> - minimized to tab
|
||||
this._subagentHideTimeout = null; // Timeout for hover-based dropdown hide
|
||||
|
||||
@@ -1150,7 +1163,7 @@ class ClaudemanApp {
|
||||
|
||||
// Plan subagent windows (visible agents during plan generation)
|
||||
this.planSubagents = new Map(); // Map<agentId, { type, model, status, startTime, element, relativePos }>
|
||||
this.planSubagentWindowZIndex = 1100;
|
||||
this.planSubagentWindowZIndex = ZINDEX_PLAN_SUBAGENT_BASE;
|
||||
this.planGenerationStopped = false; // Flag to ignore SSE events after Stop
|
||||
this.planAgentsMinimized = false; // Whether agent windows are minimized to tab
|
||||
|
||||
@@ -1162,13 +1175,13 @@ class ClaudemanApp {
|
||||
// Project Insights tracking (active Bash tools with clickable file paths)
|
||||
this.projectInsights = new Map(); // Map<sessionId, ActiveBashTool[]>
|
||||
this.logViewerWindows = new Map(); // Map<windowId, { element, eventSource, filePath }>
|
||||
this.logViewerWindowZIndex = 2000;
|
||||
this.logViewerWindowZIndex = ZINDEX_LOG_VIEWER_BASE;
|
||||
this.projectInsightsPanelVisible = false;
|
||||
this.currentSessionWorkingDir = null; // Track current session's working dir for path normalization
|
||||
|
||||
// Image popup windows (auto-open for detected screenshots/images)
|
||||
this.imagePopups = new Map(); // Map<imageId, { element, sessionId, filePath }>
|
||||
this.imagePopupZIndex = 3000;
|
||||
this.imagePopupZIndex = ZINDEX_IMAGE_POPUP_BASE;
|
||||
|
||||
// Tab alert states: Map<sessionId, 'action' | 'idle'>
|
||||
this.tabAlerts = new Map();
|
||||
|
||||
+24
-45
@@ -305,6 +305,9 @@ interface SessionListenerRefs {
|
||||
}
|
||||
|
||||
export class WebServer extends EventEmitter {
|
||||
/** Cached CPU count — doesn't change at runtime */
|
||||
private static readonly CPU_COUNT = cpus().length;
|
||||
|
||||
private app: FastifyInstance;
|
||||
private sessions: Map<string, Session> = new Map();
|
||||
private respawnControllers: Map<string, RespawnController> = new Map();
|
||||
@@ -315,6 +318,8 @@ export class WebServer extends EventEmitter {
|
||||
private sessionListenerRefs: Map<string, SessionListenerRefs> = new Map();
|
||||
private scheduledRuns: Map<string, ScheduledRun> = new Map();
|
||||
private sseClients: Set<FastifyReply> = new Set();
|
||||
/** Clients with backpressure — skip writes until 'drain' fires */
|
||||
private backpressuredClients: Set<FastifyReply> = new Set();
|
||||
private store = getStore();
|
||||
private port: number;
|
||||
private https: boolean;
|
||||
@@ -385,22 +390,13 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
this.mux = createMultiplexer();
|
||||
|
||||
// Set up mux event listeners (event names kept for SSE backward compat)
|
||||
this.mux.on('screenCreated', (screen) => {
|
||||
this.broadcast('screen:created', screen);
|
||||
});
|
||||
// Set up mux event listeners (SSE event names kept as screen:* for frontend compat)
|
||||
this.mux.on('sessionCreated', (session) => {
|
||||
this.broadcast('screen:created', session);
|
||||
});
|
||||
this.mux.on('screenKilled', (data) => {
|
||||
this.broadcast('screen:killed', data);
|
||||
});
|
||||
this.mux.on('sessionKilled', (data) => {
|
||||
this.broadcast('screen:killed', data);
|
||||
});
|
||||
this.mux.on('screenDied', (data) => {
|
||||
this.broadcast('screen:died', data);
|
||||
});
|
||||
this.mux.on('sessionDied', (data) => {
|
||||
this.broadcast('screen:died', data);
|
||||
});
|
||||
@@ -518,11 +514,12 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
req.raw.on('close', () => {
|
||||
this.sseClients.delete(reply);
|
||||
this.backpressuredClients.delete(reply);
|
||||
});
|
||||
});
|
||||
|
||||
// API Routes
|
||||
this.app.get('/api/status', async () => this.getFullState());
|
||||
this.app.get('/api/status', async () => this.getLightState());
|
||||
|
||||
// Cleanup stale sessions from state file
|
||||
this.app.post('/api/cleanup-state', async () => {
|
||||
@@ -3484,7 +3481,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
|
||||
// CPU load average (1 min) as percentage (rough approximation)
|
||||
const load = loadavg()[0];
|
||||
const cpuCount = cpus().length;
|
||||
const cpuCount = WebServer.CPU_COUNT;
|
||||
const cpuPercent = Math.min(100, Math.round((load / cpuCount) * 100));
|
||||
|
||||
return {
|
||||
@@ -4314,10 +4311,6 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
};
|
||||
}
|
||||
|
||||
private getSessionsState() {
|
||||
return Array.from(this.sessions.values()).map(s => this.getSessionStateWithRespawn(s));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get lightweight session state for SSE init - excludes full terminal buffers
|
||||
* to prevent browser freezes on SSE reconnect. Full buffers are fetched
|
||||
@@ -4370,34 +4363,6 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
return this.store.cleanupStaleSessions(activeSessionIds);
|
||||
}
|
||||
|
||||
private getFullState() {
|
||||
// Build respawn status map
|
||||
const respawnStatus: Record<string, ReturnType<RespawnController['getStatus']>> = {};
|
||||
for (const [sessionId, controller] of this.respawnControllers) {
|
||||
respawnStatus[sessionId] = controller.getStatus();
|
||||
}
|
||||
|
||||
// Build active sessions token map for aggregate calculation
|
||||
const activeSessionTokens: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> = {};
|
||||
for (const [sessionId, session] of this.sessions) {
|
||||
activeSessionTokens[sessionId] = {
|
||||
inputTokens: session.inputTokens,
|
||||
outputTokens: session.outputTokens,
|
||||
totalCost: session.totalCost,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
version: APP_VERSION,
|
||||
sessions: this.getSessionsState(),
|
||||
scheduledRuns: Array.from(this.scheduledRuns.values()),
|
||||
respawnStatus,
|
||||
globalStats: this.store.getAggregateStats(activeSessionTokens),
|
||||
subagents: subagentWatcher.getRecentSubagents(15), // Last 15 min - filter out stale agents from previous runs
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get lightweight state for SSE init - excludes full terminal buffers
|
||||
* to prevent browser freezes. Terminal buffers are fetched on-demand.
|
||||
@@ -4437,11 +4402,23 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
}
|
||||
|
||||
// Optimized: send pre-formatted SSE message to a client
|
||||
// Returns false if client is backpressured or dead
|
||||
private sendSSEPreformatted(reply: FastifyReply, message: string): void {
|
||||
// Skip backpressured clients to prevent unbounded memory growth
|
||||
if (this.backpressuredClients.has(reply)) return;
|
||||
|
||||
try {
|
||||
reply.raw.write(message);
|
||||
const ok = reply.raw.write(message);
|
||||
if (!ok) {
|
||||
// Buffer is full — mark as backpressured, resume on drain
|
||||
this.backpressuredClients.add(reply);
|
||||
reply.raw.once('drain', () => {
|
||||
this.backpressuredClients.delete(reply);
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
this.sseClients.delete(reply);
|
||||
this.backpressuredClients.delete(reply);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4655,6 +4632,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
// Remove dead clients
|
||||
for (const client of deadClients) {
|
||||
this.sseClients.delete(client);
|
||||
this.backpressuredClients.delete(client);
|
||||
}
|
||||
|
||||
if (deadClients.length > 0) {
|
||||
@@ -4948,6 +4926,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
}
|
||||
}
|
||||
this.sseClients.clear();
|
||||
this.backpressuredClients.clear();
|
||||
|
||||
// Clear batch timers
|
||||
if (this.terminalBatchTimer) {
|
||||
|
||||
Reference in New Issue
Block a user