mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
Every App Settings Save up to 1.49.0 wrote `agentSkillEnabled: false`, chosen or not (the switch loaded an absent key as off and Save sends the whole form), so the new default never reached an install that had ever saved. On its first start the server now replaces a stored false with true, once per data dir, and logs one line when it does. It cannot tell a deliberate off from the old default; that is accepted and stated in the release notes. An off set after it ran stays off. It stores true rather than deleting the key: the page's merge overwrites only the synced keys the server has, so every device that saved on 1.49 kept its own false, showed the switch off and wrote false back on its next Save (measured in the JSDOM harness). True wins on every device, including a browser still on a cached settings-ui.js. For a server with no key the page now drops the device's copy as well, so the switch shows the default. The "done" marker is its own file, `agent-skill-default-migrated.json` in the data dir: it is not a setting, so GET /api/settings never returns it, no Save can send it back (the strict schema would refuse it) or drop it (which would re-arm the migration), and a config-backup restore leaves it alone. It is checked before anything is read and written only after the settings write landed. The migration runs in WebServer.start() before the first settings read and before listen (skipped in test mode, like the other boot-time writes), writes only through mutateSettingsFile(), drops the 2 s settings cache, and leaves a settings.json that does not parse alone with no marker, so the next start after the fix runs it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
244 lines
10 KiB
TypeScript
244 lines
10 KiB
TypeScript
/**
|
|
* @fileoverview The Codeman Agent Skill switch (`agentSkillEnabled`) in App Settings,
|
|
* on by default since 1.50.0.
|
|
*
|
|
* What is pinned, and why it matters:
|
|
* - The REAL openAppSettings() reads an ABSENT key as on, like the server's
|
|
* getAgentSkillEnabled() (`!== false`). The page and the server must agree: a
|
|
* page that showed an absent key as off would write `false` on the next Save of
|
|
* any other setting, and the default would be gone without the user touching it.
|
|
* - Only an explicit false shows the switch off, wherever it came from (this
|
|
* device's blob or the server's settings.json, merged as a synced key).
|
|
* - saveAppSettings() sends the switch in the settings PUT, and the body passes the
|
|
* .strict() SettingsUpdateSchema (an undeclared key 400s every save).
|
|
* - A device that saved on 1.49.0 or earlier keeps `false` in its own blob. After the
|
|
* server's one-time boot migration (stored true) its switch shows on and a Save keeps it
|
|
* on; on a server without the key the device's copy is dropped for the default; a later
|
|
* explicit off on the server stays off. The Save body never carries the migration marker.
|
|
* - The row says it is on by default, that the copy stays out of git, and how to
|
|
* remove copies already written (turning it off sweeps nothing).
|
|
*
|
|
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only'), with the
|
|
* harness of test/tab-cli-logos-setting.test.ts.
|
|
*
|
|
* Port: none.
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { JSDOM } from 'jsdom';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
|
|
|
const PUBLIC = join(process.cwd(), 'src/web/public');
|
|
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
|
|
const INDEX = read('index.html');
|
|
const SCRIPTS = ['constants.js', 'app.js', 'settings-ui.js'].map(read);
|
|
|
|
const DESKTOP_KEY = 'codeman-app-settings';
|
|
|
|
type Settings = Record<string, unknown>;
|
|
interface SettingsApp {
|
|
openAppSettings(): void;
|
|
saveAppSettings(): Promise<void>;
|
|
loadAppSettingsFromServer(p: Promise<Settings>): Promise<Settings>;
|
|
_apiPut: ReturnType<typeof vi.fn>;
|
|
}
|
|
|
|
/** Methods that run for real; every other method of the app is a no-op here. */
|
|
const REAL = new Set([
|
|
'openAppSettings',
|
|
'saveAppSettings',
|
|
'loadAppSettingsFromServer',
|
|
'loadAppSettingsFromStorage',
|
|
'saveAppSettingsToStorage',
|
|
'getSettingsStorageKey',
|
|
'getDefaultSettings',
|
|
'resolveTabArrangement',
|
|
'resolveTabStateOrder',
|
|
// Real so the save builds the body a browser would send (the schema check).
|
|
'resolveHeaderStatsStyle',
|
|
'resolveSessionSidebarFontSize',
|
|
'_displaySettingKeySet',
|
|
]);
|
|
|
|
/** Methods the load/save paths call that other modules add to the prototype. */
|
|
const ELSEWHERE = [
|
|
'showToast',
|
|
'_updateLocalEchoState',
|
|
'renderProjectInsightsPanel',
|
|
'updateSubagentWindowVisibility',
|
|
'showWelcome',
|
|
'_applyRunMode',
|
|
];
|
|
|
|
const windows: { close(): void }[] = [];
|
|
afterEach(() => {
|
|
for (const win of windows.splice(0)) win.close();
|
|
});
|
|
|
|
/** The whole index.html with constants.js, app.js and settings-ui.js loaded into it. */
|
|
async function boot(stored?: Settings) {
|
|
const dom = new JSDOM(INDEX, { url: 'http://localhost/', runScripts: 'outside-only' });
|
|
const win = dom.window as unknown as Window & typeof globalThis & { eval(src: string): void; __App: any };
|
|
windows.push(win);
|
|
// After load, so app.js's DOMContentLoaded boot never runs; only the prototype is under test.
|
|
if (win.document.readyState !== 'complete') await new Promise((resolve) => win.addEventListener('load', resolve));
|
|
win.setInterval = (() => 0) as unknown as typeof win.setInterval;
|
|
if (stored) win.localStorage.setItem(DESKTOP_KEY, JSON.stringify(stored));
|
|
win.eval(
|
|
[
|
|
`var MobileDetection = {
|
|
getDeviceType: () => 'desktop',
|
|
isHandheldDevice: () => false,
|
|
isMobile: () => false,
|
|
isTouchDevice: () => false,
|
|
};
|
|
var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {};
|
|
var KeyboardAccessoryBar = { setMode() {} };
|
|
var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
|
|
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
|
|
var DEFAULT_VOICE_KEYTERMS = '';`,
|
|
...SCRIPTS,
|
|
'window.__App = CodemanApp;',
|
|
].join('\n')
|
|
);
|
|
const target = Object.create(win.__App.prototype);
|
|
target.sessions = new Map();
|
|
target.sessionOrder = [];
|
|
target._apiPut = vi.fn(async () => ({ ok: true }));
|
|
const noop = () => undefined;
|
|
for (const name of ELSEWHERE) target[name] = noop;
|
|
const app = new Proxy(target, {
|
|
get(t, key, receiver) {
|
|
const value = Reflect.get(t, key, receiver);
|
|
const stubbed =
|
|
typeof key === 'string' && typeof value === 'function' && !Object.hasOwn(t, key) && !REAL.has(key);
|
|
return stubbed ? noop : value;
|
|
},
|
|
}) as SettingsApp;
|
|
const checkbox = () => win.document.getElementById('appSettingsAgentSkill') as HTMLInputElement;
|
|
const putBody = () => {
|
|
const calls = app._apiPut.mock.calls as [string, Settings][];
|
|
const call = calls.find(([path]) => path === '/api/settings');
|
|
expect(call, 'a settings PUT').toBeDefined();
|
|
return call![1];
|
|
};
|
|
return { app, checkbox, putBody };
|
|
}
|
|
|
|
describe('the switch loads on by default', () => {
|
|
it('an absent key shows on, an explicit false off, an explicit true on', async () => {
|
|
for (const [stored, expected] of [
|
|
[{}, true],
|
|
[{ agentSkillEnabled: false }, false],
|
|
[{ agentSkillEnabled: true }, true],
|
|
] as const) {
|
|
const { app, checkbox } = await boot(stored);
|
|
checkbox().checked = !expected;
|
|
app.openAppSettings();
|
|
expect(checkbox().checked, JSON.stringify(stored)).toBe(expected);
|
|
}
|
|
});
|
|
|
|
it('a fresh device on a server with no stored value shows on', async () => {
|
|
const { app, checkbox } = await boot();
|
|
await app.loadAppSettingsFromServer(Promise.resolve({ lastUsedCase: 'demo' }));
|
|
checkbox().checked = false;
|
|
app.openAppSettings();
|
|
expect(checkbox().checked).toBe(true);
|
|
});
|
|
|
|
it("the server's explicit false wins over this device's blob (a synced key)", async () => {
|
|
const { app, checkbox } = await boot({ agentSkillEnabled: true });
|
|
await app.loadAppSettingsFromServer(Promise.resolve({ agentSkillEnabled: false }));
|
|
app.openAppSettings();
|
|
expect(checkbox().checked).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Save', () => {
|
|
it('saving other settings on a never-touched install keeps it on, and the body passes the schema', async () => {
|
|
const { app, putBody } = await boot({});
|
|
app.openAppSettings();
|
|
await app.saveAppSettings();
|
|
const body = putBody();
|
|
expect(body.agentSkillEnabled).toBe(true);
|
|
// The whole body, not just this key: an undeclared key 400s every save.
|
|
expect(SettingsUpdateSchema.safeParse(body).error?.issues ?? []).toEqual([]);
|
|
});
|
|
|
|
it('switching it off sends an explicit false', async () => {
|
|
const { app, checkbox, putBody } = await boot({});
|
|
app.openAppSettings();
|
|
checkbox().checked = false;
|
|
await app.saveAppSettings();
|
|
expect(putBody().agentSkillEnabled).toBe(false);
|
|
});
|
|
});
|
|
|
|
// Every device that saved App Settings on 1.49.0 or earlier holds `agentSkillEnabled: false` in
|
|
// its own blob (the switch loaded an absent key as off). The server's boot migration
|
|
// (src/web/agent-skill-default-migration.ts) switches a stored false on by storing TRUE, because
|
|
// the merge only overwrites a synced key the server has: a key it deleted would leave the
|
|
// device's false in place, and the next Save would write it back.
|
|
describe('a device that saved before 1.50.0', () => {
|
|
const OLD_BLOB = { agentSkillEnabled: false, autoNameSessions: false };
|
|
|
|
it("after the server's migration (stored true) the switch shows on, and a Save keeps it on and passes the schema", async () => {
|
|
const { app, checkbox, putBody } = await boot(OLD_BLOB);
|
|
await app.loadAppSettingsFromServer(Promise.resolve({ agentSkillEnabled: true, lastUsedCase: 'demo' }));
|
|
app.openAppSettings();
|
|
expect(checkbox().checked).toBe(true);
|
|
await app.saveAppSettings();
|
|
const body = putBody();
|
|
expect(body.agentSkillEnabled).toBe(true);
|
|
// The migration's marker is a file of its own, never a setting: the Save can neither send it
|
|
// back nor be refused for it by the .strict() schema.
|
|
expect(Object.keys(body).filter((key) => /migrat/i.test(key))).toEqual([]);
|
|
expect(SettingsUpdateSchema.safeParse(body).error?.issues ?? []).toEqual([]);
|
|
});
|
|
|
|
it('on a server without the key (a save that never reached it) the switch shows the default, on', async () => {
|
|
const { app, checkbox, putBody } = await boot(OLD_BLOB);
|
|
await app.loadAppSettingsFromServer(Promise.resolve({ lastUsedCase: 'demo' }));
|
|
app.openAppSettings();
|
|
expect(checkbox().checked).toBe(true);
|
|
await app.saveAppSettings();
|
|
expect(putBody().agentSkillEnabled).toBe(true);
|
|
});
|
|
|
|
it('an off the user saved after the migration stays off on every device', async () => {
|
|
const { app, checkbox, putBody } = await boot({ agentSkillEnabled: true });
|
|
await app.loadAppSettingsFromServer(Promise.resolve({ agentSkillEnabled: false }));
|
|
app.openAppSettings();
|
|
expect(checkbox().checked).toBe(false);
|
|
await app.saveAppSettings();
|
|
expect(putBody().agentSkillEnabled).toBe(false);
|
|
});
|
|
|
|
it('without a server answer the device keeps what it has', async () => {
|
|
const { app, checkbox } = await boot(OLD_BLOB);
|
|
await app.loadAppSettingsFromServer(Promise.resolve(null as unknown as Settings));
|
|
app.openAppSettings();
|
|
expect(checkbox().checked).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('the App Settings row', () => {
|
|
const doc = new JSDOM(INDEX).window.document;
|
|
const row = doc.getElementById('appSettingsAgentSkill')!.closest('.set-row') as HTMLElement;
|
|
const desc = row.querySelector('.set-row-desc')!.textContent!;
|
|
|
|
it('is a synced switch', () => {
|
|
expect((doc.getElementById('appSettingsAgentSkill') as HTMLInputElement).type).toBe('checkbox');
|
|
expect(row.closest('.set-group')!.querySelector('.set-scope')!.textContent).toBe('synced');
|
|
});
|
|
|
|
it('says it is on by default, that git does not list the copy, and how to remove copies', () => {
|
|
expect(desc).toContain('On by default');
|
|
expect(desc).toContain('ignores itself in git');
|
|
expect(desc).toContain('codeman skill uninstall --case <name>');
|
|
});
|
|
});
|