mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 22:49:41 +02:00
Merge pull request #361 from timkjr/fix/statusline-injection-opt-out
fix(statusline): inject plan-usage telemetry via ephemeral CLI flag, never disk
This commit is contained in:
+279
-2
@@ -6,17 +6,33 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
import { closeSync, existsSync, openSync, readFileSync, writeFileSync, mkdirSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import {
|
||||
chmodSync,
|
||||
closeSync,
|
||||
existsSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
mkdirSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
statSync,
|
||||
} from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
|
||||
import { tmpdir, homedir } from 'node:os';
|
||||
import { spawn } from 'node:child_process';
|
||||
import {
|
||||
applyStatusLineConfig,
|
||||
ensureCodemanHooks,
|
||||
findEffectiveUserStatusLineCommand,
|
||||
generateBackgroundWakeScript,
|
||||
generateHooksConfig,
|
||||
generateStatusLineCommand,
|
||||
generateSubagentStopGuardScript,
|
||||
readPlanUsageTelemetryEnabled,
|
||||
refreshStaleCodemanHooks,
|
||||
resolveStatusLineCliCommand,
|
||||
settingsWriteBlocker,
|
||||
stripCaseEnvKeys,
|
||||
updateCaseEnvVars,
|
||||
@@ -1305,3 +1321,264 @@ describe('Hook Config Generation - Extended', () => {
|
||||
expect(stopHooks[0].hooks[0].command).toContain('stop');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readPlanUsageTelemetryEnabled', () => {
|
||||
const backup = existsSync(SETTINGS_PATH) ? readFileSync(SETTINGS_PATH, 'utf-8') : null;
|
||||
|
||||
afterEach(() => {
|
||||
if (backup !== null) {
|
||||
writeFileSync(SETTINGS_PATH, backup);
|
||||
} else {
|
||||
rmSync(SETTINGS_PATH, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('reads true fresh from the persisted showPlanUsageLimits setting', async () => {
|
||||
mkdirSync(join(SETTINGS_PATH, '..'), { recursive: true });
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify({ showPlanUsageLimits: true }));
|
||||
expect(await readPlanUsageTelemetryEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('reads false when the setting is explicitly false', async () => {
|
||||
mkdirSync(join(SETTINGS_PATH, '..'), { recursive: true });
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify({ showPlanUsageLimits: false }));
|
||||
expect(await readPlanUsageTelemetryEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults to false when the setting is absent or the file is missing', async () => {
|
||||
rmSync(SETTINGS_PATH, { force: true });
|
||||
expect(await readPlanUsageTelemetryEnabled()).toBe(false);
|
||||
|
||||
mkdirSync(join(SETTINGS_PATH, '..'), { recursive: true });
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify({ someOtherSetting: true }));
|
||||
expect(await readPlanUsageTelemetryEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('never caches — a change on disk is visible on the very next call', async () => {
|
||||
mkdirSync(join(SETTINGS_PATH, '..'), { recursive: true });
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify({ showPlanUsageLimits: false }));
|
||||
expect(await readPlanUsageTelemetryEnabled()).toBe(false);
|
||||
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify({ showPlanUsageLimits: true }));
|
||||
expect(await readPlanUsageTelemetryEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveStatusLineCliCommand', () => {
|
||||
const testDir = join(tmpdir(), 'codeman-statusline-cli-test-' + Date.now());
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns undefined when telemetry was not requested', async () => {
|
||||
expect(await resolveStatusLineCliCommand(testDir, false)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns a bare exporter SCRIPT PATH (never the inline command) when requested', async () => {
|
||||
// A bare path has no `$`, quotes, or pipes for any intermediate shell
|
||||
// layer to mangle — see ensureStatusLineExporterScript's doc comment for
|
||||
// the real bug this guards against.
|
||||
const cmd = await resolveStatusLineCliCommand(testDir, true);
|
||||
expect(cmd).toBeDefined();
|
||||
expect(cmd).not.toContain('$');
|
||||
expect(cmd).not.toContain("'");
|
||||
expect(cmd).toMatch(/^\/.*statusline-exporter\.sh$/);
|
||||
expect(existsSync(cmd!)).toBe(true);
|
||||
const stat = statSync(cmd!);
|
||||
expect(stat.mode & 0o111).not.toBe(0); // executable
|
||||
expect(readFileSync(cmd!, 'utf-8')).toContain('CODEMAN_STATUSLINE_EXPORTER_V');
|
||||
});
|
||||
|
||||
it('never overrides a real, hand-authored statusLine', async () => {
|
||||
const claudeDir = join(testDir, '.claude');
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(claudeDir, 'settings.local.json'),
|
||||
JSON.stringify({ statusLine: { type: 'command', command: 'echo my-own-prompt' } }, null, 2)
|
||||
);
|
||||
|
||||
expect(await resolveStatusLineCliCommand(testDir, true)).toBeUndefined();
|
||||
|
||||
// The user's own config is untouched — this is a read-only decision, not a write.
|
||||
const parsed = JSON.parse(readFileSync(join(claudeDir, 'settings.local.json'), 'utf-8'));
|
||||
expect(parsed.statusLine.command).toBe('echo my-own-prompt');
|
||||
});
|
||||
|
||||
it('self-heals: strips a legacy disk-written exporter from an older Codeman build', async () => {
|
||||
// Simulate a workspace touched by the pre-fix applyStatusLineConfig(dir, true).
|
||||
await applyStatusLineConfig(testDir, true);
|
||||
const settingsPath = join(testDir, '.claude', 'settings.local.json');
|
||||
expect(JSON.parse(readFileSync(settingsPath, 'utf-8')).statusLine).toBeDefined();
|
||||
|
||||
const cmd = await resolveStatusLineCliCommand(testDir, true);
|
||||
|
||||
// Cleaned off disk...
|
||||
expect(JSON.parse(readFileSync(settingsPath, 'utf-8')).statusLine).toBeUndefined();
|
||||
// ...and telemetry still flows, via the ephemeral CLI flag instead.
|
||||
expect(cmd).toMatch(/statusline-exporter\.sh$/);
|
||||
});
|
||||
|
||||
it('does not resurrect the legacy exporter when telemetry is off during cleanup', async () => {
|
||||
await applyStatusLineConfig(testDir, true);
|
||||
const settingsPath = join(testDir, '.claude', 'settings.local.json');
|
||||
|
||||
const cmd = await resolveStatusLineCliCommand(testDir, false);
|
||||
|
||||
expect(cmd).toBeUndefined();
|
||||
expect(JSON.parse(readFileSync(settingsPath, 'utf-8')).statusLine).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('statusline exporter script (real shell execution)', () => {
|
||||
const testDir = join(tmpdir(), 'codeman-statusline-script-exec-test-' + Date.now());
|
||||
const binDir = join(tmpdir(), 'codeman-statusline-script-exec-bin-' + Date.now());
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
rmSync(binDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// A stand-in for the real `curl` binary, placed FIRST on PATH — same technique
|
||||
// the exporter's own review used ("an arg-echoing stand-in"). It ignores every
|
||||
// arg curl would have received; only its own scripted behavior matters here.
|
||||
function writeFakeCurl(script: string): void {
|
||||
const curlPath = join(binDir, 'curl');
|
||||
writeFileSync(curlPath, `#!/bin/sh\n${script}\n`);
|
||||
chmodSync(curlPath, 0o755);
|
||||
}
|
||||
|
||||
function runExporter(
|
||||
env: Record<string, string>
|
||||
): Promise<{ code: number | null; stdout: string; durationMs: number }> {
|
||||
return resolveStatusLineCliCommand(testDir, true).then(
|
||||
(scriptPath) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const start = Date.now();
|
||||
const child = spawn('sh', [scriptPath!], {
|
||||
env: { ...env, PATH: `${binDir}:${process.env.PATH}` },
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
});
|
||||
let stdout = '';
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => resolve({ code, stdout, durationMs: Date.now() - start }));
|
||||
child.stdin.end('{}');
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const baseEnv = {
|
||||
CODEMAN_SESSION_ID: 'x',
|
||||
CODEMAN_API_URL: 'http://127.0.0.1:1',
|
||||
CODEMAN_HOOK_SECRET_FILE: '/dev/null',
|
||||
};
|
||||
|
||||
it('no-user-statusline branch: the POST runs in the foreground and its OWN stdout becomes the footer', async () => {
|
||||
writeFakeCurl(`echo 'model: opus | 42% used'`);
|
||||
const result = await runExporter(baseEnv);
|
||||
expect(result.stdout.trim()).toBe('model: opus | 42% used');
|
||||
});
|
||||
|
||||
it('no-user-statusline branch: falls back to the plain "codeman" marker when curl fails', async () => {
|
||||
writeFakeCurl(`exit 1`);
|
||||
const result = await runExporter(baseEnv);
|
||||
expect(result.stdout.trim()).toBe('codeman');
|
||||
});
|
||||
|
||||
it('wrap branch: never blocks a reader-to-EOF on a slow/hung curl (background subshell closes stdin too)', async () => {
|
||||
writeFakeCurl(`sleep 3`);
|
||||
const result = await runExporter({ ...baseEnv, CODEMAN_USER_STATUSLINE_CMD: 'echo my-own-statusline' });
|
||||
expect(result.stdout.trim()).toBe('my-own-statusline');
|
||||
expect(result.durationMs).toBeLessThan(1000);
|
||||
}, 10000);
|
||||
|
||||
it('curl is bounded with --max-time so a HUNG (not just refused) Codeman cannot wedge the render', async () => {
|
||||
const scriptPath = await resolveStatusLineCliCommand(testDir, true);
|
||||
expect(readFileSync(scriptPath!, 'utf-8')).toContain('--max-time');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findEffectiveUserStatusLineCommand', () => {
|
||||
const testDir = join(tmpdir(), 'codeman-statusline-precedence-test-' + Date.now());
|
||||
const userSettingsPath = join(homedir(), '.claude', 'settings.json');
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
rmSync(userSettingsPath, { force: true }); // don't leak into other tests sharing this HOME
|
||||
});
|
||||
|
||||
it('returns undefined when nothing is configured anywhere', async () => {
|
||||
expect(await findEffectiveUserStatusLineCommand(testDir)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('finds the user global ~/.claude/settings.json when nothing else is set', async () => {
|
||||
const userClaudeDir = join(homedir(), '.claude');
|
||||
mkdirSync(userClaudeDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(userClaudeDir, 'settings.json'),
|
||||
JSON.stringify({ statusLine: { type: 'command', command: 'echo user-global' } })
|
||||
);
|
||||
|
||||
expect(await findEffectiveUserStatusLineCommand(testDir)).toBe('echo user-global');
|
||||
});
|
||||
|
||||
it('project-SHARED settings.json wins over user-global', async () => {
|
||||
const userClaudeDir = join(homedir(), '.claude');
|
||||
mkdirSync(userClaudeDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(userClaudeDir, 'settings.json'),
|
||||
JSON.stringify({ statusLine: { type: 'command', command: 'echo user-global' } })
|
||||
);
|
||||
const projectClaudeDir = join(testDir, '.claude');
|
||||
mkdirSync(projectClaudeDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(projectClaudeDir, 'settings.json'),
|
||||
JSON.stringify({ statusLine: { type: 'command', command: 'echo project-shared' } })
|
||||
);
|
||||
|
||||
expect(await findEffectiveUserStatusLineCommand(testDir)).toBe('echo project-shared');
|
||||
});
|
||||
|
||||
it('project-LOCAL settings.local.json wins over everything', async () => {
|
||||
const projectClaudeDir = join(testDir, '.claude');
|
||||
mkdirSync(projectClaudeDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(projectClaudeDir, 'settings.json'),
|
||||
JSON.stringify({ statusLine: { type: 'command', command: 'echo project-shared' } })
|
||||
);
|
||||
writeFileSync(
|
||||
join(projectClaudeDir, 'settings.local.json'),
|
||||
JSON.stringify({ statusLine: { type: 'command', command: 'echo project-local' } })
|
||||
);
|
||||
|
||||
expect(await findEffectiveUserStatusLineCommand(testDir)).toBe('echo project-local');
|
||||
});
|
||||
|
||||
it('skips a legacy Codeman-marked entry in project settings.local.json and falls through', async () => {
|
||||
await applyStatusLineConfig(testDir, true); // simulates a pre-fix disk-written exporter
|
||||
const projectClaudeDir = join(testDir, '.claude');
|
||||
writeFileSync(
|
||||
join(projectClaudeDir, 'settings.json'),
|
||||
JSON.stringify({ statusLine: { type: 'command', command: 'echo project-shared' } })
|
||||
);
|
||||
|
||||
expect(await findEffectiveUserStatusLineCommand(testDir)).toBe('echo project-shared');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -156,7 +156,7 @@ describe('POST /api/sessions workspace hooks', () => {
|
||||
const cwdSettings = join(process.cwd(), '.claude', 'settings.local.json');
|
||||
const before = existsSync(cwdSettings) ? await readFile(cwdSettings, 'utf-8') : null;
|
||||
|
||||
const res = await createSession({ name: 'hooks-no-dir', mode: 'claude', statusLineTelemetry: true });
|
||||
const res = await createSession({ name: 'hooks-no-dir', mode: 'claude' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const after = existsSync(cwdSettings) ? await readFile(cwdSettings, 'utf-8') : null;
|
||||
@@ -166,8 +166,9 @@ describe('POST /api/sessions workspace hooks', () => {
|
||||
it('never writes hooks for a remote attach (workingDir is a user@host pseudo-path)', async () => {
|
||||
// A claude-mode attachRemoteSession create overwrites workingDir with
|
||||
// `user@host:session` — locally a RELATIVE path, so a mkdir would create it
|
||||
// as a junk directory under the server cwd. statusLineTelemetry rides along:
|
||||
// applyStatusLineConfig mkdirs the same way and used to run for remote attaches.
|
||||
// as a junk directory under the server cwd. The statusLine exporter rides
|
||||
// along: applyStatusLineConfig mkdirs the same way and used to run for
|
||||
// remote attaches.
|
||||
// SAFETY (2026-08-29): write straight to `getDataDir()` — `test/setup.ts`
|
||||
// already sandboxes the data dir for the whole file (temp HOME, inherited
|
||||
// CODEMAN_DATA_DIR stripped; same convention as the docker-hosts fixtures
|
||||
@@ -188,7 +189,6 @@ describe('POST /api/sessions workspace hooks', () => {
|
||||
const res = await createSession({
|
||||
name: 'hooks-remote',
|
||||
mode: 'claude',
|
||||
statusLineTelemetry: true,
|
||||
attachRemoteSession: { hostId: 'h1', remoteSessionName: 'codeman-ssh-abc123' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* @fileoverview GET /api/settings must reconcile `showPlanUsageLimits` the
|
||||
* first time it is ever read, closing the gap left by PR #361.
|
||||
*
|
||||
* planUsageChipEnabled() (settings-ui.js) shows the header chip and the App
|
||||
* Settings checkbox as already ON whenever this key has never been set — a
|
||||
* discoverability default from 1.9.3. readPlanUsageTelemetryEnabled()
|
||||
* (hooks-config.ts) deliberately treats an absent key as "no telemetry" —
|
||||
* a privacy default, pinned by its own unit tests. Nothing reconciled those
|
||||
* two independent guesses, so a fresh install showed a checked box that
|
||||
* silently collected nothing until the user opened Settings and hit Save
|
||||
* at least once.
|
||||
*
|
||||
* These tests pin the fix: GET /api/settings persists the resolved default
|
||||
* ONCE when the key is truly absent, and never overwrites an explicit value
|
||||
* either way afterward.
|
||||
*
|
||||
* 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 mutable
|
||||
// persisted-settings fixture has to be built inside vi.hoisted().
|
||||
const { state } = vi.hoisted(() => ({
|
||||
// Mutated per-test to control what "disk" holds before the GET.
|
||||
state: { persisted: {} as Record<string, unknown>, exists: true },
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: {
|
||||
readFile: vi.fn(async () => {
|
||||
if (!state.exists) {
|
||||
const err = new Error('ENOENT') as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
}
|
||||
return JSON.stringify(state.persisted);
|
||||
}),
|
||||
writeFile: vi.fn(async (_path: string, content: string) => {
|
||||
state.persisted = JSON.parse(content);
|
||||
state.exists = true;
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
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(() => []) };
|
||||
});
|
||||
|
||||
describe('GET /api/settings — showPlanUsageLimits default reconciliation', () => {
|
||||
let harness: RouteTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerSystemRoutes);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
it('persists the resolved default (true) the first time the key is absent', async () => {
|
||||
state.persisted = { someOtherSetting: true };
|
||||
state.exists = true;
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/settings' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().showPlanUsageLimits).toBe(true);
|
||||
// Reconciliation actually reached disk, not just the response.
|
||||
expect(state.persisted.showPlanUsageLimits).toBe(true);
|
||||
});
|
||||
|
||||
it('reconciles even when settings.json does not exist at all', async () => {
|
||||
state.exists = false;
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/settings' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().showPlanUsageLimits).toBe(true);
|
||||
expect(state.persisted.showPlanUsageLimits).toBe(true);
|
||||
});
|
||||
|
||||
it('never overwrites an explicit false', async () => {
|
||||
state.persisted = { showPlanUsageLimits: false };
|
||||
state.exists = true;
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/settings' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().showPlanUsageLimits).toBe(false);
|
||||
// Untouched — reconciliation must not have written anything.
|
||||
expect(state.persisted.showPlanUsageLimits).toBe(false);
|
||||
});
|
||||
|
||||
it('never rewrites an explicit true', async () => {
|
||||
state.persisted = { showPlanUsageLimits: true };
|
||||
state.exists = true;
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/settings' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().showPlanUsageLimits).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
* 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
|
||||
* `{statusLineTelemetry:true}` therefore STARTED the subagent watcher (`?? true`)
|
||||
* `{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.
|
||||
@@ -91,8 +91,8 @@ describe('PUT /api/settings — partial body must not reset service toggles', ()
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
// Action-only body: the exact shape that used to flip all three watchers.
|
||||
payload: { statusLineTelemetry: true },
|
||||
// Minimal single-key body: the exact shape that used to flip all three watchers.
|
||||
payload: { showPlanUsageLimits: true },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
@@ -411,21 +411,43 @@ describe('system-routes', () => {
|
||||
// ========== GET /api/settings ==========
|
||||
|
||||
describe('GET /api/settings', () => {
|
||||
it('returns empty object when settings file does not exist', async () => {
|
||||
it('reconciles showPlanUsageLimits to true when the settings file does not exist', async () => {
|
||||
// The chip/checkbox default to ON client-side (planUsageChipEnabled()) whenever
|
||||
// this key is absent, but readPlanUsageTelemetryEnabled() deliberately treats
|
||||
// absence as "no telemetry" — nothing reconciled those two defaults, so a fresh
|
||||
// install showed a checked box that silently collected nothing. GET now persists
|
||||
// the resolved default the first time anything reads settings.
|
||||
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/settings' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body)).toEqual({});
|
||||
expect(JSON.parse(res.body)).toEqual({ showPlanUsageLimits: true });
|
||||
// Reconciliation actually reached disk, not just the response.
|
||||
expect(mockedWriteFile).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
JSON.stringify({ showPlanUsageLimits: true }, null, 2)
|
||||
);
|
||||
});
|
||||
|
||||
it('returns parsed settings when file exists', async () => {
|
||||
it('reconciles showPlanUsageLimits to true when the file exists but omits it', async () => {
|
||||
const settings = { subagentTrackingEnabled: true, showSystemStats: false };
|
||||
mockedReadFile.mockResolvedValue(JSON.stringify(settings) as never);
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/settings' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body)).toEqual({ ...settings, showPlanUsageLimits: true });
|
||||
});
|
||||
|
||||
it('never overwrites an explicit false', async () => {
|
||||
const settings = { subagentTrackingEnabled: true, showPlanUsageLimits: false };
|
||||
mockedReadFile.mockResolvedValue(JSON.stringify(settings) as never);
|
||||
mockedWriteFile.mockClear();
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/settings' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body)).toEqual(settings);
|
||||
// No reconciliation write when the key is already explicit.
|
||||
expect(mockedWriteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @fileoverview Tests for the plan-usage statusLine exporter riding an EPHEMERAL
|
||||
* `claude --settings` CLI flag (buildSpawnCommand's statusLineCommand option),
|
||||
* which superseded writing it into `.claude/settings.local.json` — see
|
||||
* resolveStatusLineCliCommand in hooks-config.ts and its own tests. Verified
|
||||
* live against a real Claude CLI (isolated tmux socket, 2026-08-31) that
|
||||
* `--settings` accepts this exact shape and takes precedence over a file-based
|
||||
* statusLine.
|
||||
*
|
||||
* Extracting and re-parsing the `--settings` argument goes through a REAL
|
||||
* shell (bash -c) rather than a hand-rolled unescaper: the exporter command
|
||||
* itself embeds both single and double quotes, so trusting anything but the
|
||||
* shell's own quoting rules to reverse shellescape() would just be testing
|
||||
* this file's guess at the algorithm, not the actual behavior a spawned pane
|
||||
* sees.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { buildSpawnCommand } from '../src/tmux-manager.js';
|
||||
|
||||
const EXPORTER_CMD = 'curl -sk -X POST "$CODEMAN_API_URL/api/status-telemetry" --data @- 2>/dev/null || echo codeman';
|
||||
|
||||
/** Extract the `--settings <arg>` fragment from a built command and have a
|
||||
* real shell resolve its quoting, printing the arg back out verbatim. */
|
||||
function extractSettingsJson(cmd: string): unknown {
|
||||
const idx = cmd.indexOf('--settings ');
|
||||
expect(idx).toBeGreaterThan(-1);
|
||||
const fragment = cmd.slice(idx);
|
||||
const out = execFileSync('bash', ['-c', `set -- ${fragment}; printf '%s' "$2"`]).toString();
|
||||
return JSON.parse(out);
|
||||
}
|
||||
|
||||
describe('buildSpawnCommand statusLineCommand (claude mode)', () => {
|
||||
it('omits --settings entirely when no statusLineCommand and no effort are given', () => {
|
||||
const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1' });
|
||||
expect(cmd).not.toContain('--settings');
|
||||
});
|
||||
|
||||
it('embeds the exporter command under a statusLine settings key', () => {
|
||||
const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', statusLineCommand: EXPORTER_CMD });
|
||||
expect(cmd).toContain('--settings');
|
||||
expect(extractSettingsJson(cmd)).toEqual({ statusLine: { type: 'command', command: EXPORTER_CMD } });
|
||||
});
|
||||
|
||||
it('merges statusLine and ultracode into the SAME --settings object', () => {
|
||||
const cmd = buildSpawnCommand({
|
||||
mode: 'claude',
|
||||
sessionId: 'sid-1',
|
||||
effort: 'ultracode',
|
||||
statusLineCommand: EXPORTER_CMD,
|
||||
});
|
||||
// Only one --settings flag total — never two (Claude Code accepts just one).
|
||||
expect(cmd.match(/--settings/g)).toHaveLength(1);
|
||||
expect(extractSettingsJson(cmd)).toEqual({
|
||||
ultracode: true,
|
||||
statusLine: { type: 'command', command: EXPORTER_CMD },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a regular --effort flag separate from --settings when both are present', () => {
|
||||
const cmd = buildSpawnCommand({
|
||||
mode: 'claude',
|
||||
sessionId: 'sid-1',
|
||||
effort: 'high',
|
||||
statusLineCommand: EXPORTER_CMD,
|
||||
});
|
||||
expect(cmd).toContain('--effort');
|
||||
expect(cmd).toContain('--settings');
|
||||
expect(extractSettingsJson(cmd)).toEqual({ statusLine: { type: 'command', command: EXPORTER_CMD } });
|
||||
});
|
||||
|
||||
it('shell-escapes an exporter command containing single AND double quotes without breaking the flag', () => {
|
||||
// The real exporter (generateStatusLineCommand) embeds both — a naive
|
||||
// `'${value}'` wrap would be broken out of by the single quotes.
|
||||
const tricky = `echo '{}'; printf '{"a":1}' | curl -sk`;
|
||||
const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', statusLineCommand: tricky });
|
||||
expect(extractSettingsJson(cmd)).toEqual({ statusLine: { type: 'command', command: tricky } });
|
||||
});
|
||||
|
||||
it('round-trips the REAL exporter script path unmodified (ensureStatusLineExporterScript)', async () => {
|
||||
// What resolveStatusLineCliCommand actually hands to buildSpawnCommand at spawn
|
||||
// time today is a bare script PATH (see that function's doc comment for why —
|
||||
// never the raw curl command generateStatusLineCommand() builds, which only
|
||||
// backs the legacy disk-write applyStatusLineConfig path now).
|
||||
const { ensureStatusLineExporterScript } = await import('../src/hooks-config.js');
|
||||
const real = await ensureStatusLineExporterScript();
|
||||
const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', statusLineCommand: real });
|
||||
expect(extractSettingsJson(cmd)).toEqual({ statusLine: { type: 'command', command: real } });
|
||||
});
|
||||
|
||||
it('never adds --settings for non-claude modes even if statusLineCommand is somehow set', () => {
|
||||
const cmd = buildSpawnCommand({ mode: 'omp', sessionId: 'sid-1', statusLineCommand: EXPORTER_CMD } as never);
|
||||
expect(cmd).not.toContain('--settings');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user