Files
Codeman/test/routes/system-routes-settings-partial-put.test.ts
T
timkjrandClaude Sonnet 5 d5b75af628 fix(statusline): sticky telemetry collection, footer print-through, EOF fix
Responds to Ark0N's review round on the ephemeral-CLI-flag statusline
injection rework:

- Rebase-detail fixes: registry-gated telemetry eligibility via
  getCli(mode)?.capabilities.statusLineTelemetry instead of a hardcoded
  mode === 'claude' check, using the capability flag master's CLI-registry
  refactor already declares for exactly this purpose.

- Design question settled: sticky (a). Rather than persisting the toggle
  as a new field and threading it through every session-creation path
  (cron, Ralph Loop API, quick-start), eliminated the per-session field
  entirely. readPlanUsageTelemetryEnabled() (hooks-config.ts) reads the
  existing showPlanUsageLimits setting fresh from settings.json at every
  claude create/respawn (TmuxManager.createSession/respawnPane) - no
  per-session state to survive a restart, and it applies uniformly to
  every creation path for free, since they all flow through the same
  TmuxManager methods.

  This required fixing a real bug found along the way: showPlanUsageLimits
  was not actually round-tripping through settings.json on save -
  settings-ui.js explicitly excluded it from the PUT body as a pure
  per-device display key. It now flows through normally (both true and
  false); the load-side per-device merge behavior is unchanged.

  Removed entirely as a result: the statusLineTelemetry field from
  CreateSessionSchema/SettingsUpdateSchema, CreateSessionOptions/
  RespawnPaneOptions, Session._statusLineTelemetry (this is what makes
  the restart-persistence bug moot rather than patched), and the
  frontend send sites.

- Footer print-through restored: the no-user-statusline branch of the
  exporter script now runs the telemetry POST in the foreground so its
  own stdout becomes the in-terminal footer, falling back to a plain
  "codeman" marker only on curl failure.

- Background-subshell EOF fix: the wrap-a-real-statusline branch closes
  stdin too, not just stdout/stderr (`>/dev/null 2>&1 </dev/null &`) -
  the un-redirected subshell process itself, not curl, was what held a
  reader-to-EOF's pipe open for however long curl took to finish. Added
  curl --max-time 5 so a hung (not just refused) Codeman cannot wedge
  the render.

Tests: real-shell-execution tests for the footer/EOF fixes (fake curl
stand-in on PATH, real sh subprocess spawns, real elapsed-time
measurements - verified non-vacuous against a hand-reconstructed
old-style script), unit tests for readPlanUsageTelemetryEnabled.
Adapted two existing tests whose payloads referenced the removed field.
Fixed during independent code review: a stray indentation break and a
test exercising the wrong (legacy) exporter code path.

Docs synced: CLAUDE.md, docs/usage-limits-display-plan.md (old
disk-based section marked superseded, kept for history),
docs/architecture-invariants.md.

Full suite green: 352 files, 6780 passed, 12 skipped, 0 failed.
tsc/lint/format:check/frontend-syntax all clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 20:37:29 -05:00

147 lines
5.8 KiB
TypeScript

/**
* @fileoverview PUT /api/settings must not reset service state on a PARTIAL body.
*
* The three service toggles (subagent watcher, workflow-run watcher, image
* watcher) used to read the RAW REQUEST BODY with `??` defaults, so any key the
* caller omitted was treated as "apply the default". A body of just
* `{showPlanUsageLimits:true}` therefore STARTED the subagent watcher (`?? true`)
* and STOPPED the workflow + image watchers (`?? false`), silently undoing the
* persisted config. Nothing triggered it in practice only because every shipped
* client sends a full settings payload rebuilt from the DOM.
*
* They now resolve from `merged` (existing settings.json + incoming), so a PUT
* reconciles services to the effective stored state. These tests pin that:
* omitted keys preserve state, explicit keys still take effect.
*
* Uses app.inject() — no real HTTP ports needed. Port: N/A.
*/
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';
// vi.mock factories are hoisted above module-level consts, so the stubs and the
// persisted-settings fixture have to be built inside vi.hoisted().
const { EXISTING_SETTINGS, subagentWatcher, imageWatcher, workflowRunWatcher } = vi.hoisted(() => {
/** Watcher stub whose isRunning() reflects its persisted state. */
const makeWatcher = (running: boolean) => {
let isOn = running;
return {
isRunning: vi.fn(() => isOn),
start: vi.fn(() => {
isOn = true;
}),
stop: vi.fn(() => {
isOn = false;
}),
getStats: vi.fn(() => ({})),
watchSession: vi.fn(),
getRecentRunSummaries: vi.fn(() => []),
// The stubs are module singletons (vi.mock needs them hoisted), so a
// start()/stop() in one test would otherwise carry into the next and make
// its "not called" assertion pass vacuously — isRunning() already matches
// the expected end state, so toggleService short-circuits.
__resetRunning: () => {
isOn = running;
},
};
};
return {
// Persisted settings.json for these tests: two watchers ON, subagent tracking OFF.
EXISTING_SETTINGS: { subagentTrackingEnabled: false, imageWatcherEnabled: true, showUltracodeAgents: true },
subagentWatcher: makeWatcher(false),
imageWatcher: makeWatcher(true),
workflowRunWatcher: makeWatcher(true),
};
});
vi.mock('node:fs/promises', () => ({
default: {
readFile: vi.fn(async () => JSON.stringify(EXISTING_SETTINGS)),
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 }));
vi.mock('../../src/image-watcher.js', () => ({ imageWatcher }));
vi.mock('../../src/workflow-run-watcher.js', () => ({ workflowRunWatcher }));
describe('PUT /api/settings — partial body must not reset service toggles', () => {
let harness: RouteTestHarness;
beforeEach(async () => {
harness = await createRouteTestHarness(registerSystemRoutes);
for (const w of [subagentWatcher, imageWatcher, workflowRunWatcher]) {
w.start.mockClear();
w.stop.mockClear();
w.__resetRunning(); // running state, not just call records — see makeWatcher
}
});
afterEach(async () => {
await harness.app.close();
});
it('leaves all three watchers alone when the body omits their keys', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
// Minimal single-key body: the exact shape that used to flip all three watchers.
payload: { showPlanUsageLimits: true },
});
expect(res.statusCode).toBe(200);
// Persisted OFF and omitted — must NOT be started by the `?? true` default.
expect(subagentWatcher.start).not.toHaveBeenCalled();
// Persisted ON and omitted — must NOT be stopped by the `?? false` defaults.
expect(imageWatcher.stop).not.toHaveBeenCalled();
expect(workflowRunWatcher.stop).not.toHaveBeenCalled();
});
it('still starts a watcher when the body explicitly enables it', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { subagentTrackingEnabled: true },
});
expect(res.statusCode).toBe(200);
expect(subagentWatcher.start).toHaveBeenCalledTimes(1);
// Unrelated watchers stay untouched.
expect(imageWatcher.stop).not.toHaveBeenCalled();
expect(workflowRunWatcher.stop).not.toHaveBeenCalled();
});
it('still stops a watcher when the body explicitly disables it', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { imageWatcherEnabled: false },
});
expect(res.statusCode).toBe(200);
expect(imageWatcher.stop).toHaveBeenCalledTimes(1);
expect(subagentWatcher.start).not.toHaveBeenCalled();
expect(workflowRunWatcher.stop).not.toHaveBeenCalled();
});
it('keeps the workflow watcher running when only one of its two keys is sent', async () => {
// Either showUltracodeAgents OR ultracodeFloatingWindows keeps it alive, and
// the OR must be evaluated over merged state, not over this partial body.
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { ultracodeFloatingWindows: false },
});
expect(res.statusCode).toBe(200);
// showUltracodeAgents is still true in settings.json, so it stays up.
expect(workflowRunWatcher.stop).not.toHaveBeenCalled();
});
});