Files
Codeman/test/plan-usage-collection-flip.test.ts
Codeman maintainer 707ea345eb fix(statusline): GET /api/settings never writes, and a save sends the collection switch only on a flip
Two follow-ups to #361's sticky telemetry switch.

GET /api/settings reconciled an absent showPlanUsageLimits by persisting
true, but readJsonConfig() answers {} for ANY read failure (a parse
error, EACCES, EMFILE, a read landing inside PUT's non-atomic write), not
only ENOENT, and every page load calls this route, so one unlucky read
replaced the whole settings file with a one-key file. The route is a
plain read again and the default moved into the reader:
readPlanUsageTelemetryEnabled() treats an absent key as ON, the same way
readWorkspaceHooksEnabled() does, which is what the desktop chip already
shows for an install that never touched the setting.

saveAppSettings() sent showPlanUsageLimits on every save. The chip
defaults OFF on handhelds, so a phone saving its font size persisted
false and switched collection off for every desktop, whose chip then
went stale with no error anywhere. The key is now stripped like the
other per-device display keys and re-added only when the save FLIPS the
chip relative to what the device had (planUsageCollectionFlip), so an
explicit toggle on any device still writes it in either direction.

Tests pin both: the GET route with a mocked filesystem (absent, missing,
EACCES, garbage, explicit), the reader default, and the flip helper plus
its wiring in saveAppSettings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:59:00 +02:00

79 lines
3.7 KiB
TypeScript

/**
* `planUsageCollectionFlip()` in settings-ui.js: the one place that decides
* whether a settings save carries `showPlanUsageLimits` to the server.
*
* The chip is per-device for DISPLAY (desktop default ON, handhelds OFF) but
* the same persisted key is the server-side telemetry COLLECTION switch, read
* at every claude spawn. Sending it on every save let a phone saving its font
* size persist `false` and turn collection off for every desktop. So the save
* sends the key ONLY when it flips the chip relative to what the device had,
* and the server reads an absent key as ON.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/settings-ui.js'), 'utf8');
function loadSettingsUi(defaultChip: boolean) {
const CodemanApp = function CodemanApp(this: unknown) {};
const context = vm.createContext({
CodemanApp,
VoiceInput: {},
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: () => null },
console,
});
vm.runInContext(SOURCE, context, { filename: 'settings-ui.js' });
const app = Object.create(CodemanApp.prototype) as {
getDefaultSettings: () => { showPlanUsageLimits: boolean };
planUsageCollectionFlip: (prev: Record<string, unknown> | null, now: boolean) => boolean | undefined;
};
app.getDefaultSettings = () => ({ showPlanUsageLimits: defaultChip });
return app;
}
describe('planUsageCollectionFlip', () => {
it('says nothing when a desktop that never touched the chip saves with it still on', () => {
const desktop = loadSettingsUi(true);
expect(desktop.planUsageCollectionFlip({}, true)).toBeUndefined();
expect(desktop.planUsageCollectionFlip(null, true)).toBeUndefined();
});
it('says nothing when a handheld (chip default OFF) saves an unrelated setting', () => {
const phone = loadSettingsUi(false);
expect(phone.planUsageCollectionFlip({ terminalFontSize: 14 }, false)).toBeUndefined();
expect(phone.planUsageCollectionFlip({ showPlanUsageLimits: false }, false)).toBeUndefined();
});
it('sends false only on the save that turned the chip off', () => {
const desktop = loadSettingsUi(true);
expect(desktop.planUsageCollectionFlip({}, false)).toBe(false);
expect(desktop.planUsageCollectionFlip({ showPlanUsageLimits: true }, false)).toBe(false);
expect(desktop.planUsageCollectionFlip({ showPlanUsageLimits: false }, false)).toBeUndefined();
});
it('sends true when any device, a handheld included, turns the chip on', () => {
const phone = loadSettingsUi(false);
expect(phone.planUsageCollectionFlip({}, true)).toBe(true);
expect(phone.planUsageCollectionFlip({ showPlanUsageLimits: false }, true)).toBe(true);
expect(phone.planUsageCollectionFlip({ showPlanUsageLimits: true }, true)).toBeUndefined();
});
});
describe('saveAppSettings wiring', () => {
it('strips showPlanUsageLimits from the synced payload and re-adds it only through the flip', () => {
const save = SOURCE.slice(
SOURCE.indexOf('async saveAppSettings()'),
SOURCE.indexOf('closeAppSettings()', SOURCE.indexOf('async saveAppSettings()'))
);
// Stripped from serverSettings like the other per-device display keys.
expect(save).toMatch(/showPlanUsageLimits: _pul,/);
// Decided once against the device's prior settings, before they are overwritten.
expect(save).toMatch(/const _chipFlip = this\.planUsageCollectionFlip\(_prev, settings\.showPlanUsageLimits\);/);
// And only a real flip reaches the PUT body.
expect(save).toMatch(/\.\.\.\(_chipFlip !== undefined \? \{ showPlanUsageLimits: _chipFlip \} : \{\}\),/);
});
});