a81d376322
- Use `ps -o stat=` to check process foreground state directly, fixing macOS where tmux pane_current_command reports "zsh" instead of the child process. Falls back to pane_current_command safely. - Fix print mode retry off-by-one (maxRetries=5 only gave 4 retries) - Fix sendKeys failure leaving state unupdated (tight error loop) - Validate customPatterns config entries (non-string/invalid regex crashed monitor) - Parse "resets in: N hours" format (was detected but not time-parsed) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
115 lines
5.6 KiB
JavaScript
115 lines
5.6 KiB
JavaScript
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createMonitorState, processOneTick } from '../src/monitor.js';
|
|
import { DEFAULT_CONFIG } from '../src/config.js';
|
|
|
|
function mockTmux(paneContent = '', paneCommand = 'node', claudeForeground = true) {
|
|
const t = {
|
|
_sent: [],
|
|
capturePane: async () => paneContent,
|
|
getPaneCommand: async () => paneCommand,
|
|
sendKeys: async (_p, text) => { t._sent.push(text); },
|
|
isClaudeForeground: async () => claudeForeground,
|
|
};
|
|
return t;
|
|
}
|
|
|
|
describe('processOneTick', () => {
|
|
it('returns monitoring when no rate limit', async () => {
|
|
const t = mockTmux('Normal output');
|
|
const s = createMonitorState();
|
|
assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => true), 'monitoring');
|
|
assert.equal(t._sent.length, 0);
|
|
});
|
|
it('enters waiting on rate limit', async () => {
|
|
const t = mockTmux('5-hour limit reached - resets 3pm (UTC)');
|
|
const s = createMonitorState();
|
|
assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => true), 'waiting');
|
|
assert.ok(s.waitUntil > Date.now());
|
|
});
|
|
it('exits when PID dead', async () => {
|
|
const t = mockTmux('5-hour limit reached - resets 3pm (UTC)');
|
|
const s = createMonitorState();
|
|
s.waitUntil = Date.now() - 1000; s.status = 'waiting';
|
|
assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => false), 'exit');
|
|
});
|
|
it('sends retry when wait expired and rate limit visible', async () => {
|
|
const t = mockTmux('5-hour limit reached - resets 3pm (UTC)');
|
|
const s = createMonitorState();
|
|
s.waitUntil = Date.now() - 1000; s.status = 'waiting';
|
|
assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => true), 'retried');
|
|
assert.equal(t._sent.length, 1);
|
|
assert.equal(s.attempts, 1);
|
|
// Should stay in 'waiting' with a cooldown to let Claude process
|
|
assert.equal(s.status, 'waiting');
|
|
assert.ok(s.waitUntil > Date.now());
|
|
});
|
|
it('detects multi-line TUI rate limit', async () => {
|
|
const t = mockTmux('⚠ You\'ve hit your limit\n· resets 3pm (UTC)');
|
|
const s = createMonitorState();
|
|
assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => true), 'waiting');
|
|
assert.ok(s.waitUntil > Date.now());
|
|
});
|
|
it('retries when Claude process is in foreground (fixes macOS zsh issue)', async () => {
|
|
const t = mockTmux('5-hour limit reached - resets 3pm (UTC)', 'zsh', true);
|
|
const s = createMonitorState();
|
|
s.waitUntil = Date.now() - 1000; s.status = 'waiting';
|
|
assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => true), 'retried');
|
|
assert.equal(t._sent.length, 1);
|
|
});
|
|
it('falls back to pane_current_command when process state is false', async () => {
|
|
const t = mockTmux('5-hour limit reached - resets 3pm (UTC)', 'vim', false);
|
|
const s = createMonitorState();
|
|
s.waitUntil = Date.now() - 1000; s.status = 'waiting';
|
|
assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => true), 'skipped-not-claude');
|
|
assert.equal(t._sent.length, 0);
|
|
assert.equal(s._lastForeground, 'vim');
|
|
});
|
|
it('falls back to pane_current_command when process state is null', async () => {
|
|
const t = mockTmux('5-hour limit reached - resets 3pm (UTC)', 'vim', null);
|
|
const s = createMonitorState();
|
|
s.waitUntil = Date.now() - 1000; s.status = 'waiting';
|
|
assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => true), 'skipped-not-claude');
|
|
assert.equal(t._sent.length, 0);
|
|
assert.equal(s._lastForeground, 'vim');
|
|
});
|
|
it('accepts custom foregroundCommands in fallback path', async () => {
|
|
const t = mockTmux('5-hour limit reached - resets 3pm (UTC)', 'my-claude-wrapper', null);
|
|
const s = createMonitorState();
|
|
s.waitUntil = Date.now() - 1000; s.status = 'waiting';
|
|
const config = { ...DEFAULT_CONFIG, foregroundCommands: ['my-claude-wrapper'] };
|
|
assert.equal(await processOneTick(s, t, '%0', config, () => true), 'retried');
|
|
assert.equal(t._sent.length, 1);
|
|
});
|
|
it('matches npx in fallback path', async () => {
|
|
const t = mockTmux('5-hour limit reached - resets 3pm (UTC)', 'npx', null);
|
|
const s = createMonitorState();
|
|
s.waitUntil = Date.now() - 1000; s.status = 'waiting';
|
|
assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => true), 'retried');
|
|
});
|
|
it('resets counter when rate limit disappears', async () => {
|
|
const t = mockTmux('Claude is working normally');
|
|
const s = createMonitorState();
|
|
s.waitUntil = Date.now() - 1000; s.status = 'waiting'; s.attempts = 2;
|
|
assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => true), 'user-continued');
|
|
assert.equal(s.attempts, 0);
|
|
});
|
|
it('stops retrying after max attempts and stays in waiting', async () => {
|
|
const t = mockTmux('5-hour limit reached - resets 3pm (UTC)');
|
|
const s = createMonitorState();
|
|
s.waitUntil = Date.now() - 1000; s.status = 'waiting'; s.attempts = 5;
|
|
assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => true), 'max-retries');
|
|
// Should stay in 'waiting' to avoid re-detection loop
|
|
assert.equal(s.status, 'waiting');
|
|
assert.ok(s.waitUntil > Date.now());
|
|
});
|
|
it('resets from max-retries when rate limit clears', async () => {
|
|
const t = mockTmux('Claude is working normally');
|
|
const s = createMonitorState();
|
|
s.waitUntil = Date.now() - 1000; s.status = 'waiting'; s.attempts = 10;
|
|
// Rate limit cleared → should detect user-continued before max-retries check
|
|
assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => true), 'user-continued');
|
|
assert.equal(s.attempts, 0);
|
|
});
|
|
});
|