Files
Codeman/test/mocks/mock-session.ts
T
arkonandClaude Fable 5 68310619a7 feat(session,mobile): auto-resume on usage limit + mobile view fixes
Auto-resume on usage limit ("token pause" control, opt-in checkbox at the
top of the Respawn tab, off by default):
- usage-limit-patterns.ts (new, pure): detects all Claude Code limit
  messages (1.0.x-2.1.x eras incl. "5-hour limit reached - resets 8pm",
  "You've hit your limit - resets 1:40pm (TZ)", weekly date forms, raw
  "usage limit reached|<epoch>") and parses the reset time. Conservative:
  no parseable future reset time, no action.
- SessionAutoOps: arms a timer at reset+2min, sends Esc (dismisses the
  rate-limit dialog) + "continue"; dedups footer redraws, retries every
  5min on stale times, cancels when Claude starts working, persists and
  re-arms across Codeman restarts (SessionState.autoResumeEnabled/At).
- Respawn guard: cycles are blocked while limit-paused so /clear cannot
  wipe the paused conversation (respawnBlocked reason 'usage_limit').
- POST /api/sessions/:id/auto-resume; SSE session:limitPauseScheduled/
  limitResume/limitResumeCancelled; toasts + status line in the modal.
- Respawn tab tidied: single-row prompt fields, merged behavior row.

Mobile fixes (0.9.8 regressions, user-reported):
- Resize arbitration is now activity-based: a desktop sizing claim only
  blocks phone resizes while the desktop typed within 90s
  (Session.DESKTOP_CLAIM_IDLE_MS). Idle desktop -> phone takes the pane;
  next desktop keystroke re-asserts the desktop layout server-side
  (noteDesktopActivity via ws-routes input). Phones re-send dims every
  30s (visible tab only, skipped while the keyboard is open) so attaching
  under a hot claim self-corrects. Fixes the desktop-width-stream-in-
  narrow-xterm soup (mid-word wraps, tmux dot fill, Ink overdraw).
- Cross-device reflows (takeover/re-assert) emit a debounced needsRefresh
  so all clients reload the buffer instead of stacking ghost Ink frames.
- Keyboard accessory/toolbar lift restored: measure keyboardOffset
  against window.innerHeight (layout viewport), not the shrunken .app -
  on iOS the offset computed to 0, leaving both bars hidden behind the
  OS keyboard with a dead gap above.
- Removed the mobile header utility ("three dots") toggle entirely;
  the headerRight tray stays collapsed on small viewports.

Tests: usage-limit-patterns (36), session-auto-resume (21), resize
arbitration (+6), session routes (+4), respawn guard (+2); MockSession
auto-resume/sizing stubs; mobile tabs test updated for toggle removal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:41:34 +02:00

327 lines
8.6 KiB
TypeScript

/**
* Shared MockSession for tests that need terminal simulation.
* Used by respawn, route, and subagent tests.
*/
import { EventEmitter } from 'node:events';
import { vi } from 'vitest';
/**
* Enhanced mock session for testing RespawnController.
* Extends the existing MockSession pattern with additional utilities.
*/
export class MockSession extends EventEmitter {
id: string;
workingDir: string = '/tmp/test-workdir';
status: 'idle' | 'working' = 'idle';
pid: number = 12345;
isWorking: boolean = false;
private _activeChildProcesses: { pid: number; command: string }[] = [];
ralphTracker: null = null;
writeBuffer: string[] = [];
terminalBuffer: string = '';
private _muxName: string | null = null;
constructor(id: string = 'mock-session-id') {
super();
this.id = id;
this._muxName = `codeman-test-${id.slice(0, 8)}`;
}
/** Direct PTY write (used by session.write()) */
write(data: string): void {
this.writeBuffer.push(data);
}
/** Write via mux (used by respawn controller) */
async writeViaMux(data: string): Promise<boolean> {
this.writeBuffer.push(data);
return true;
}
/** Get the last written data */
get lastWrite(): string | undefined {
return this.writeBuffer[this.writeBuffer.length - 1];
}
/** Clear the write buffer */
clearWriteBuffer(): void {
this.writeBuffer = [];
}
/** Check if a specific command was written */
hasWritten(pattern: string | RegExp): boolean {
return this.writeBuffer.some((data) => (typeof pattern === 'string' ? data.includes(pattern) : pattern.test(data)));
}
// ========== Terminal Output Simulation ==========
/** Simulate raw terminal output */
simulateTerminalOutput(data: string): void {
this.terminalBuffer += data;
this.emit('terminal', data);
}
/** Simulate prompt appearing (legacy fallback signal) */
simulatePrompt(): void {
this.simulateTerminalOutput('\u276f ');
this.status = 'idle';
this.emit('idle');
}
/** Simulate ready state with definitive indicator (legacy) */
simulateReady(): void {
this.simulateTerminalOutput('\u21b5 send');
this.status = 'idle';
this.emit('idle');
}
/**
* Simulate completion message (primary idle detection in Claude Code 2024+).
* This triggers the multi-layer detection flow.
*/
simulateCompletionMessage(duration: string = '2m 46s'): void {
this.simulateTerminalOutput(`\u273b Worked for ${duration}`);
this.status = 'idle';
}
/** Simulate working state with spinner */
simulateWorking(text: string = 'Thinking'): void {
this.simulateTerminalOutput(`${text}... \u280b`);
this.status = 'working';
this.emit('working');
}
/** Simulate /clear completion */
simulateClearComplete(): void {
this.simulateTerminalOutput('conversation cleared');
setTimeout(() => this.simulateCompletionMessage(), 50);
}
/** Simulate /init completion */
simulateInitComplete(): void {
this.simulateTerminalOutput('Analyzing CLAUDE.md...');
setTimeout(() => this.simulateCompletionMessage(), 100);
}
/**
* Simulate plan mode approval prompt.
* This triggers auto-accept detection.
*/
simulatePlanModePrompt(): void {
this.simulateTerminalOutput(
'Would you like to proceed with this plan?\n' + '\u276f 1. Yes\n' + ' 2. No\n' + ' 3. Type your own\n'
);
}
/**
* Simulate elicitation dialog (AskUserQuestion).
* This should block auto-accept.
*/
simulateElicitationDialog(): void {
this.simulateTerminalOutput('What would you like to name the new file?\n' + '> ');
}
/** Simulate token count display */
simulateTokenCount(tokens: number | string): void {
const formatted =
typeof tokens === 'number' ? (tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens)) : tokens;
this.simulateTerminalOutput(`${formatted} tokens used`);
}
/** Simulate ANSI escape codes */
simulateAnsiOutput(text: string, color: 'green' | 'red' | 'blue' = 'green'): void {
const codes: Record<string, string> = {
green: '\x1b[32m',
red: '\x1b[31m',
blue: '\x1b[34m',
};
this.simulateTerminalOutput(`${codes[color]}${text}\x1b[0m`);
}
/** Clear terminal buffer */
clearTerminalBuffer(): void {
this.terminalBuffer = '';
}
// ========== Session Lifecycle ==========
/** Simulate session closing */
close(): void {
this.emit('exit', 0);
this.removeAllListeners();
}
/** Get mux name (for mux-based operations) */
get muxName(): string | null {
return this._muxName;
}
/** Check for active child processes (mock returns configurable list) */
getActiveChildProcesses(): { pid: number; command: string }[] {
return this._activeChildProcesses;
}
/** Set active child processes for testing */
setActiveChildProcesses(processes: { pid: number; command: string }[]): void {
this._activeChildProcesses = processes;
}
// ========== Route-test conveniences ==========
/** Session display name */
name: string = 'test-session';
/** Session color tag */
color: string = 'default';
/** CLI mode */
mode: string = 'claude';
/** Text output buffer (stripped of ANSI) */
textOutput: string = '';
/** Structured messages */
messages: unknown[] = [];
/** Error buffer */
errorBuffer: string = '';
/** Ralph loop state */
ralphLoopState: Record<string, unknown> | null = null;
/** Ralph todo items */
ralphTodos: unknown[] = [];
/** Ralph todo statistics */
ralphTodoStats: Record<string, unknown> = {};
/** Currently active tools */
activeTools: unknown[] = [];
/** Token tracking */
inputTokens: number = 0;
outputTokens: number = 0;
totalCost: number = 0;
/** Terminal buffer byte length */
get terminalBufferLength(): number {
return this.terminalBuffer.length;
}
/** Return a state-like object for route handlers */
toState(): Record<string, unknown> {
return {
id: this.id,
workingDir: this.workingDir,
status: this.status,
name: this.name,
color: this.color,
mode: this.mode,
muxName: this._muxName,
};
}
/** Auto-resume on usage limit (token pause control) */
autoResumeEnabled: boolean = false;
autoResumeAt: number | null = null;
isLimitPaused: boolean = false;
setAutoResume = vi.fn((enabled: boolean) => {
this.autoResumeEnabled = enabled;
if (!enabled) this.autoResumeAt = null;
});
/** Check if session is busy */
isBusy = vi.fn(() => false);
/** Set session color */
setColor = vi.fn((c: string) => {
this.color = c;
});
/** Stub for sendInput */
sendInput = vi.fn();
/** Stub for resize */
resize = vi.fn();
/** Stubs for the desktop sizing claims used by resize arbitration */
claimDesktopSizing = vi.fn();
releaseDesktopSizing = vi.fn();
noteDesktopActivity = vi.fn();
/** Stub for runPrompt */
runPrompt = vi.fn(async () => {});
/** Stub for startInteractive */
startInteractive = vi.fn(async () => {});
/** Stub for startShell */
startShell = vi.fn(async () => {});
/** Stub for compact */
compact = vi.fn();
/** Stub for getTextOutput */
getTextOutput = vi.fn(() => '');
/** Stub for getMessages */
getMessages = vi.fn(() => []);
}
/**
* Generate realistic terminal output for testing.
* Must match the patterns used in MockSession's simulate* methods.
*/
export const terminalOutputs = {
/** Standard completion message */
completion(duration: string = '2m 46s'): string {
return `\n\u273b Worked for ${duration}\n 123.4k tokens used\n`;
},
/** Working spinner output */
working(activity: string = 'Thinking'): string {
return `${activity}... \u280b`;
},
/** Plan mode prompt */
planMode(question: string = 'Would you like to proceed?'): string {
return [`\n${question}\n`, '\u276f 1. Yes\n', ' 2. No\n', ' 3. Type your own\n'].join('');
},
/** Prompt character */
prompt(): string {
return '\n\u276f ';
},
/** Token count display */
tokens(count: number): string {
const formatted =
count >= 1000000
? `${(count / 1000000).toFixed(1)}M`
: count >= 1000
? `${(count / 1000).toFixed(1)}k`
: String(count);
return ` ${formatted} tokens\n`;
},
/** Large output for buffer testing */
largeOutput(sizeKb: number = 100): string {
const baseText = 'Lorem ipsum dolor sit amet. '.repeat(100);
const repetitions = Math.ceil((sizeKb * 1024) / baseText.length);
return baseText.repeat(repetitions).slice(0, sizeKb * 1024);
},
/** ANSI colored output */
ansiColored(text: string): string {
return `\x1b[32m${text}\x1b[0m`;
},
};
/**
* Convenience factory.
*/
export function createMockSession(id?: string): MockSession {
return new MockSession(id);
}