Files
Codeman/test/agent-skill-setting-ui.test.ts
T
Ludo FourrageandClaude Opus 5.5 308de1fd4d fix(voice): 30 s and Manual outlast silent Web Speech runs
_onWebSpeechEnd counted every run that ended without a result toward the
two-retry cap, however long the run lasted. Under 30 s or Manual, each
no-speech window of the engine is one silent run, so the third ended the
recording at about 18 s of silence, before the deadline, and Manual
stopped on its own. Both recognition.start() sites now stamp the run start;
an empty run counts toward the cap only when it ended within a second of
starting (a recognizer that cannot listen), and any other run resets it.
The 250 ms backoff is unchanged.

The vm harness in test/voice-silence-timeout.test.ts now passes the test's
Date into the context, whose own Date ignores fake timers.

Also from review: the VoiceInput stubs of the four settings-ui.js tests
carry SILENCE_TIMEOUT_SECONDS; Settings-Reference lists Stop after silence;
the touchscreen Linux fallback leaves out ChromeOS and says a touch Linux
laptop still matches; the dead _webSpeechStream teardown is gone; the two
voice tests use vitest expect instead of node:assert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 07:57:14 -08:00

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 = { SILENCE_TIMEOUT_SECONDS: [0, 3, 6, 15, 30], _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>');
});
});