mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
Merge #787: turn the Codeman agent skill on by default
This commit is contained in:
commit
8fa2884f48
35 files changed
+1088
-105
No files matched your search
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* @fileoverview The one-time boot migration that lets the 1.50.0 agent skill default reach
|
||||
* installs that saved App Settings before it (src/web/agent-skill-default-migration.ts).
|
||||
*
|
||||
* What is pinned, and why it matters:
|
||||
* - An absent key, a stored `true` or no settings.json at all: settings.json is not written
|
||||
* (byte-identical, odd formatting kept) and the marker is set.
|
||||
* - A stored `false` (what every 1.49 save wrote, chosen or not) becomes `true`, every other key
|
||||
* kept, exactly once: the log line prints once, and a second start reports `already-done`
|
||||
* without reading the file.
|
||||
* - An off set AFTER the migration stays off: through the real `PUT /api/settings` (what the
|
||||
* page's Save sends), then a second start. The PUT neither writes nor drops the marker, and
|
||||
* `GET /api/settings` never returns it, so the page cannot hold it to send it back.
|
||||
* - A settings.json that does not parse is left byte-identical, no marker is written, nothing
|
||||
* throws, and the next start (after the fix) does the migration.
|
||||
* - A marker that cannot be checked switches nothing on (a deliberate off must not be cleared
|
||||
* twice).
|
||||
* - The marker is not a config-backup file, and `WebServer.start()` runs the migration before
|
||||
* its first settings read and before it listens (source wiring guard: the boot call is skipped
|
||||
* in test mode, like the other boot-time writes).
|
||||
*
|
||||
* The page's side (a device blob holding the old `false`) is pinned in
|
||||
* test/agent-skill-setting-ui.test.ts.
|
||||
*
|
||||
* Port: 0 (one WebServer for the PUT/GET cases).
|
||||
*/
|
||||
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
|
||||
import { dataPath } from '../src/config/instance.js';
|
||||
import { CONFIG_BACKUP_FILES } from '../src/config-backup.js';
|
||||
import {
|
||||
AGENT_SKILL_DEFAULT_MARKER,
|
||||
AGENT_SKILL_DEFAULT_MIGRATED_LOG,
|
||||
migrateAgentSkillDefault,
|
||||
} from '../src/web/agent-skill-default-migration.js';
|
||||
|
||||
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
|
||||
|
||||
const MARKER = dataPath(AGENT_SKILL_DEFAULT_MARKER);
|
||||
|
||||
/** Written with a formatting no writer of ours produces, so any rewrite shows in the bytes. */
|
||||
const seedRaw = (text: string) => writeFileSync(SETTINGS_PATH, text);
|
||||
const seed = (value: unknown) => seedRaw(JSON.stringify(value));
|
||||
const raw = () => readFileSync(SETTINGS_PATH, 'utf-8');
|
||||
const stored = () => JSON.parse(raw()) as Record<string, unknown>;
|
||||
const marker = () => JSON.parse(readFileSync(MARKER, 'utf-8')) as { migratedAt: string; switchedOn: boolean };
|
||||
|
||||
/** The bytes of a torn settings.json (test/settings-json-write-serialization.test.ts). */
|
||||
const TORN = '{\n "agentSkillEnabled": false\n}' + 'rt-1761000000000"\n}';
|
||||
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||
const migratedLogs = () => logSpy.mock.calls.filter(([line]) => line === AGENT_SKILL_DEFAULT_MIGRATED_LOG).length;
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
|
||||
rmSync(SETTINGS_PATH, { force: true });
|
||||
rmSync(MARKER, { force: true });
|
||||
logSpy = vi.spyOn(console, 'log');
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('nothing to switch on', () => {
|
||||
it('an absent key: settings.json is not written, the marker is set', async () => {
|
||||
seed({ lastUsedCase: 'demo' });
|
||||
const before = raw();
|
||||
expect(await migrateAgentSkillDefault()).toBe('nothing-to-change');
|
||||
expect(raw()).toBe(before);
|
||||
expect(marker()).toMatchObject({ switchedOn: false });
|
||||
expect(migratedLogs()).toBe(0);
|
||||
});
|
||||
|
||||
it('no settings.json at all: none is created, the marker is set', async () => {
|
||||
expect(await migrateAgentSkillDefault()).toBe('nothing-to-change');
|
||||
expect(existsSync(SETTINGS_PATH)).toBe(false);
|
||||
expect(marker()).toMatchObject({ switchedOn: false });
|
||||
});
|
||||
|
||||
it('a stored true is left untouched', async () => {
|
||||
seed({ agentSkillEnabled: true, caseOrder: ['a'] });
|
||||
const before = raw();
|
||||
expect(await migrateAgentSkillDefault()).toBe('nothing-to-change');
|
||||
expect(raw()).toBe(before);
|
||||
expect(marker()).toMatchObject({ switchedOn: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a stored false from before 1.50.0', () => {
|
||||
it('is switched on once, every other key kept, with one log line', async () => {
|
||||
seed({ agentSkillEnabled: false, lastUsedCase: 'demo', nice: { enabled: true, niceValue: 5 } });
|
||||
expect(await migrateAgentSkillDefault()).toBe('switched-on');
|
||||
expect(stored()).toEqual({ agentSkillEnabled: true, lastUsedCase: 'demo', nice: { enabled: true, niceValue: 5 } });
|
||||
expect(marker()).toMatchObject({ switchedOn: true });
|
||||
expect(Number.isNaN(Date.parse(marker().migratedAt))).toBe(false);
|
||||
expect(migratedLogs()).toBe(1);
|
||||
|
||||
// A second start finds the marker and reads nothing.
|
||||
const after = raw();
|
||||
expect(await migrateAgentSkillDefault()).toBe('already-done');
|
||||
expect(raw()).toBe(after);
|
||||
expect(migratedLogs()).toBe(1);
|
||||
});
|
||||
|
||||
it('a false written by hand after the migration is never switched on again', async () => {
|
||||
seed({ agentSkillEnabled: false });
|
||||
expect(await migrateAgentSkillDefault()).toBe('switched-on');
|
||||
seed({ agentSkillEnabled: false });
|
||||
expect(await migrateAgentSkillDefault()).toBe('already-done');
|
||||
expect(stored()).toEqual({ agentSkillEnabled: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a settings.json that does not parse', () => {
|
||||
it('is left alone, writes no marker, does not throw, and the next start after the fix migrates', async () => {
|
||||
seedRaw(TORN);
|
||||
expect(await migrateAgentSkillDefault()).toBe('refused');
|
||||
expect(raw()).toBe(TORN);
|
||||
expect(existsSync(MARKER)).toBe(false);
|
||||
expect(
|
||||
warnSpy.mock.calls.some(([line]) => String(line).startsWith('[agent-skill] default migration skipped'))
|
||||
).toBe(true);
|
||||
|
||||
// The user repairs the file (or restores a backup): the migration has not run yet, so it runs now.
|
||||
seed({ agentSkillEnabled: false });
|
||||
expect(await migrateAgentSkillDefault()).toBe('switched-on');
|
||||
expect(stored()).toEqual({ agentSkillEnabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a marker that cannot be checked', () => {
|
||||
it('switches nothing on and writes no marker', async () => {
|
||||
seed({ agentSkillEnabled: false });
|
||||
// A path below a regular file: lstat answers ENOTDIR, not ENOENT.
|
||||
expect(await migrateAgentSkillDefault(join(SETTINGS_PATH, 'marker.json'))).toBe('refused');
|
||||
expect(stored()).toEqual({ agentSkillEnabled: false });
|
||||
expect(existsSync(MARKER)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with the page and the server (port 0)', () => {
|
||||
let server: WebServer;
|
||||
const url = (p: string) => `http://localhost:${server.boundPort}${p}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.stop();
|
||||
}, 30000);
|
||||
|
||||
it('an off saved through PUT /api/settings after the migration stays off at the next start, and the PUT leaves the marker alone', async () => {
|
||||
seed({ agentSkillEnabled: false, lastUsedCase: 'demo' });
|
||||
expect(await migrateAgentSkillDefault()).toBe('switched-on');
|
||||
const markerBytes = readFileSync(MARKER, 'utf-8');
|
||||
|
||||
// What the page sends when the user turns the switch off (plus another synced key).
|
||||
const res = await fetch(url('/api/settings'), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ agentSkillEnabled: false, autoNameSessions: true }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(stored()).toEqual({ agentSkillEnabled: false, lastUsedCase: 'demo', autoNameSessions: true });
|
||||
expect(readFileSync(MARKER, 'utf-8')).toBe(markerBytes);
|
||||
|
||||
expect(await migrateAgentSkillDefault()).toBe('already-done');
|
||||
expect(stored().agentSkillEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('GET /api/settings never carries the marker, so the page cannot hold or send it', async () => {
|
||||
seed({ agentSkillEnabled: false });
|
||||
await migrateAgentSkillDefault();
|
||||
const res = await fetch(url('/api/settings'));
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
const data = (body.success === true ? body.data : body) as Record<string, unknown>;
|
||||
expect(data).toEqual({ agentSkillEnabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('placement', () => {
|
||||
it('the marker is not a config-backup file, so a restore never re-arms or drops it', () => {
|
||||
expect(CONFIG_BACKUP_FILES as readonly string[]).not.toContain(AGENT_SKILL_DEFAULT_MARKER);
|
||||
});
|
||||
|
||||
it('WebServer.start() runs it before its first settings read, the restore and listen, and drops the settings cache', () => {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/server.ts'), 'utf8');
|
||||
const start = source.slice(source.indexOf(' async start(): Promise<void> {'));
|
||||
const call = start.indexOf('await migrateAgentSkillDefault()');
|
||||
expect(call).toBeGreaterThan(0);
|
||||
for (const later of [
|
||||
'await this.getAgentInboxPersistEnabled()',
|
||||
'await this.restoreMuxSessions()',
|
||||
'await this.app.listen(',
|
||||
]) {
|
||||
expect(start.indexOf(later), later).toBeGreaterThan(call);
|
||||
}
|
||||
expect(start.slice(call, call + 200)).toContain('this._settingsCache = null;');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* @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>');
|
||||
});
|
||||
});
|
||||
@@ -81,6 +81,8 @@ describe('applyAgentSkill with a directory', () => {
|
||||
expect(skillMd.startsWith('---\nname: codeman')).toBe(true);
|
||||
expect(skillMd).toContain(MARKER_PREFIX);
|
||||
expect(existsSync(join(skillDir, 'reference', 'endpoints.md'))).toBe(true);
|
||||
// It ignores itself in git there too, like the Claude Code copy.
|
||||
expect(await readFile(join(skillDir, '.gitignore'), 'utf-8')).toBe('*\n');
|
||||
expect(existsSync(join(casePath, '.claude'))).toBe(false);
|
||||
expect(await applyAgentSkill(casePath, true, AGENTS_DIR)).toBe('unchanged');
|
||||
});
|
||||
|
||||
+168
-1
@@ -7,12 +7,14 @@
|
||||
* publish without them would be caught here before the `files` entry silently
|
||||
* ignores the missing directory.
|
||||
*
|
||||
* Pure filesystem tests in a per-test temp dir. Port: N/A.
|
||||
* Filesystem tests in a per-test temp dir; the self-ignoring block also runs real
|
||||
* `git` there (no global or system config). Port: N/A.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtemp, rm, mkdir, writeFile, readFile, symlink, readdir, stat, utimes } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir, homedir } from 'node:os';
|
||||
import {
|
||||
@@ -92,6 +94,8 @@ describe('installAgentSkillInto / applyAgentSkill(enabled)', () => {
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('foreign');
|
||||
expect(await readFile(join(skillDir(), 'SKILL.md'), 'utf-8')).toContain('my own skill');
|
||||
expect(existsSync(join(skillDir(), 'reference'))).toBe(false);
|
||||
// Not even the ignore file: a foreign copy is the user's to track or not.
|
||||
expect(existsSync(join(skillDir(), '.gitignore'))).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses to write through a symlinked skill dir (dogfooding layout)', async () => {
|
||||
@@ -107,9 +111,61 @@ describe('installAgentSkillInto / applyAgentSkill(enabled)', () => {
|
||||
expect(await installAgentSkillInto(skillDir())).toBe('symlink');
|
||||
expect(await readdir(join(casePath, 'real-skills'))).toEqual([]);
|
||||
});
|
||||
|
||||
// A cloned repository can commit `reference` as a link, and a Docker case's container
|
||||
// can plant one in the bind-mounted workspace the host injects into (on by default).
|
||||
it('refuses a symlinked reference/ folder inside the copy, so nothing is written where it points', async () => {
|
||||
const outside = join(casePath, 'outside');
|
||||
await mkdir(outside, { recursive: true });
|
||||
await writeFile(join(outside, 'history.md'), 'my notes\n');
|
||||
await mkdir(skillDir(), { recursive: true });
|
||||
await symlink(outside, join(skillDir(), 'reference'));
|
||||
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('symlink');
|
||||
expect(await readdir(outside)).toEqual(['history.md']);
|
||||
expect(await readFile(join(outside, 'history.md'), 'utf-8')).toBe('my notes\n');
|
||||
// Refused before the first write: no SKILL.md, no ignore file.
|
||||
expect(await readdir(skillDir())).toEqual(['reference']);
|
||||
});
|
||||
|
||||
it('refuses a symlinked reference/ folder planted beside an existing Codeman copy', async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
const outside = join(casePath, 'outside');
|
||||
await mkdir(outside, { recursive: true });
|
||||
await writeFile(join(outside, 'recipes.md'), 'my recipes\n');
|
||||
await rm(join(skillDir(), 'reference'), { recursive: true });
|
||||
await symlink(outside, join(skillDir(), 'reference'));
|
||||
const skillMd = await readFile(join(skillDir(), 'SKILL.md'), 'utf-8');
|
||||
await writeFile(join(skillDir(), 'SKILL.md'), `stale\n${MARKER_PREFIX}: old -->\n`);
|
||||
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('symlink');
|
||||
expect(await readdir(outside)).toEqual(['recipes.md']);
|
||||
expect(await readFile(join(outside, 'recipes.md'), 'utf-8')).toBe('my recipes\n');
|
||||
// The stale copy is left as found, not refreshed through the link.
|
||||
expect(await readFile(join(skillDir(), 'SKILL.md'), 'utf-8')).not.toBe(skillMd);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeAgentSkillFrom / applyAgentSkill(disabled)', () => {
|
||||
it('refuses to delete through a symlinked reference/ folder inside the copy', async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
const outside = join(casePath, 'outside');
|
||||
await mkdir(outside, { recursive: true });
|
||||
// Files named like the packaged reference files: an unlink through the link would take them.
|
||||
for (const name of await readdir(join(skillDir(), 'reference'))) {
|
||||
await writeFile(join(outside, name), 'not yours\n');
|
||||
}
|
||||
const names = (await readdir(outside)).sort();
|
||||
expect(names.length).toBeGreaterThan(0);
|
||||
await rm(join(skillDir(), 'reference'), { recursive: true });
|
||||
await symlink(outside, join(skillDir(), 'reference'));
|
||||
|
||||
expect(await applyAgentSkill(casePath, false)).toBe('symlink');
|
||||
expect((await readdir(outside)).sort()).toEqual(names);
|
||||
for (const name of names) expect(await readFile(join(outside, name), 'utf-8')).toBe('not yours\n');
|
||||
expect(existsSync(join(skillDir(), 'SKILL.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('removes our copy and prunes the emptied directories', async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
expect(await applyAgentSkill(casePath, false)).toBe('removed');
|
||||
@@ -139,6 +195,117 @@ describe('removeAgentSkillFrom / applyAgentSkill(disabled)', () => {
|
||||
expect(existsSync(join(skillDir(), 'reference', 'endpoints.md'))).toBe(false);
|
||||
// The user's file and the directories holding it survive.
|
||||
expect(await readFile(join(skillDir(), 'reference', 'my-notes.md'), 'utf-8')).toBe('mine\n');
|
||||
// And so does the ignore file, so their notes stay out of `git status` as before.
|
||||
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('*\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the injected copy ignores itself in git', () => {
|
||||
const ENV = {
|
||||
...process.env,
|
||||
LC_ALL: 'C',
|
||||
GIT_AUTHOR_NAME: 'T',
|
||||
GIT_AUTHOR_EMAIL: 't@example.com',
|
||||
GIT_COMMITTER_NAME: 'T',
|
||||
GIT_COMMITTER_EMAIL: 't@example.com',
|
||||
GIT_CONFIG_GLOBAL: '/dev/null',
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
};
|
||||
const git = (...args: string[]) =>
|
||||
execFileSync('git', args, { cwd: casePath, env: ENV, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
/** Exit status of `git check-ignore -q -- <path>`: 0 ignored, 1 not ignored. */
|
||||
const checkIgnore = (path: string): number => {
|
||||
try {
|
||||
git('check-ignore', '-q', '--', path);
|
||||
return 0;
|
||||
} catch (err) {
|
||||
return (err as { status: number }).status;
|
||||
}
|
||||
};
|
||||
/** A repository with one commit and a tracked `.gitignore` of its own. */
|
||||
const initRepo = async () => {
|
||||
git('init', '-q', '-b', 'main');
|
||||
await writeFile(join(casePath, 'README.md'), 'hello\n');
|
||||
await writeFile(join(casePath, '.gitignore'), 'node_modules/\n');
|
||||
git('add', 'README.md', '.gitignore');
|
||||
git('-c', 'commit.gpgsign=false', 'commit', '-q', '-m', 'init');
|
||||
};
|
||||
|
||||
it('writes a .gitignore of `*` into the copy on install', async () => {
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('installed');
|
||||
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('*\n');
|
||||
// Never part of the skill content: a second run has nothing to do.
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('unchanged');
|
||||
});
|
||||
|
||||
it('keeps `git status` of the user repository clean, and leaves its own ignore file alone', async () => {
|
||||
await initRepo();
|
||||
await applyAgentSkill(casePath, true);
|
||||
await applyAgentSkill(casePath, true, '.agents/skills');
|
||||
|
||||
expect(git('status', '--porcelain', '--untracked-files=all')).toBe('');
|
||||
expect(await readFile(join(casePath, '.gitignore'), 'utf-8')).toBe('node_modules/\n');
|
||||
// Nor the repository's local exclude file (git's template may or may not create one).
|
||||
const exclude = join(casePath, '.git', 'info', 'exclude');
|
||||
if (existsSync(exclude)) expect(await readFile(exclude, 'utf-8')).not.toContain('codeman');
|
||||
});
|
||||
|
||||
it('ignores the copy contents, never the folder above it (Claude Code checks only those)', async () => {
|
||||
// Claude Code 2.1.319 skips a nested skills folder when `git check-ignore` says its
|
||||
// project directory is ignored; it never checks a skill folder or its SKILL.md.
|
||||
await initRepo();
|
||||
await applyAgentSkill(casePath, true);
|
||||
expect(checkIgnore('.claude/skills/codeman/SKILL.md')).toBe(0);
|
||||
expect(checkIgnore('.claude/skills/codeman')).toBe(1);
|
||||
expect(checkIgnore('.claude/skills')).toBe(1);
|
||||
expect(checkIgnore('.claude')).toBe(1);
|
||||
});
|
||||
|
||||
it('never overwrites a .gitignore that is already in the folder', async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
await writeFile(join(skillDir(), '.gitignore'), '# mine\nSKILL.md\n');
|
||||
// A stale copy: the refresh rewrites the skill but not the ignore file.
|
||||
await writeFile(join(skillDir(), 'SKILL.md'), `stale\n${MARKER_PREFIX}: old -->\n`);
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('refreshed');
|
||||
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('# mine\nSKILL.md\n');
|
||||
});
|
||||
|
||||
it('a copy injected before the ignore file existed gains it on the next install', async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
await rm(join(skillDir(), '.gitignore'));
|
||||
// Every skill file is current, so only the ignore file is new: still a refresh.
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('refreshed');
|
||||
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('*\n');
|
||||
});
|
||||
|
||||
it('does not write through a symlink planted at the ignore file name', async () => {
|
||||
await mkdir(skillDir(), { recursive: true });
|
||||
const outside = join(casePath, 'outside.txt');
|
||||
await writeFile(outside, 'untouched\n');
|
||||
await symlink(outside, join(skillDir(), '.gitignore'));
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('installed');
|
||||
expect(await readFile(outside, 'utf-8')).toBe('untouched\n');
|
||||
});
|
||||
|
||||
it('uninstall removes the ignore file with the copy, so no folder is left behind', async () => {
|
||||
await initRepo();
|
||||
await applyAgentSkill(casePath, true);
|
||||
expect(await applyAgentSkill(casePath, false)).toBe('removed');
|
||||
expect(existsSync(skillDir())).toBe(false);
|
||||
expect(existsSync(join(casePath, '.claude', 'skills'))).toBe(false);
|
||||
expect(git('status', '--porcelain', '--untracked-files=all')).toBe('');
|
||||
});
|
||||
|
||||
it("uninstall keeps an ignore file that is not Codeman's", async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
await writeFile(join(skillDir(), '.gitignore'), 'SKILL.md\n');
|
||||
expect(await applyAgentSkill(casePath, false)).toBe('removed');
|
||||
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('SKILL.md\n');
|
||||
});
|
||||
|
||||
it('the packaged source carries no ignore file (the plugin mirror pins its file set)', () => {
|
||||
expect(existsSync(join(process.cwd(), 'skills', 'codeman', '.gitignore'))).toBe(false);
|
||||
expect(existsSync(join(process.cwd(), 'plugins', 'codeman', 'skills', 'codeman', '.gitignore'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { safeRmHomeTree } from './mocks/index.js';
|
||||
|
||||
const CASES_DIR = join(homedir(), 'codeman-cases');
|
||||
// A folder of this file's own for the sessions below, never the machine's real `/tmp`:
|
||||
// a create writes the workspace hooks and the agent skill (on by default) into its
|
||||
// working dir, and those would outlive the run there.
|
||||
const WORK_DIR = mkdtempSync(join(tmpdir(), 'codeman-edge-cases-'));
|
||||
afterAll(() => rmSync(WORK_DIR, { recursive: true, force: true }));
|
||||
|
||||
describe('Edge Cases and Error Handling', () => {
|
||||
let server: WebServer;
|
||||
@@ -279,7 +285,7 @@ describe('Concurrent Session Handling', () => {
|
||||
fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
}).then((r) => r.json())
|
||||
);
|
||||
|
||||
@@ -312,7 +318,7 @@ describe('Concurrent Session Handling', () => {
|
||||
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
expect(createData.success).toBe(true);
|
||||
|
||||
@@ -128,10 +128,10 @@ export function createMockRouteContext(options?: {
|
||||
getModelConfig: vi.fn(async () => null),
|
||||
getClaudeModeConfig: vi.fn(async () => ({})),
|
||||
getTerminalHistoryConfig: vi.fn(async () => resolveTerminalHistoryConfig({})),
|
||||
// Default OFF mirrors the shipped setting, so existing tests never touch a
|
||||
// case's .claude/skills. Overridable per test because the create-time
|
||||
// injection call sites are otherwise unreachable from a route test.
|
||||
getAgentSkillEnabled: vi.fn(async () => options?.agentSkillEnabled ?? false),
|
||||
// Default ON mirrors the shipped setting (since 1.50.0), so a route test sees
|
||||
// what a user sees. Writes land in the test's temp working dir, never in a real
|
||||
// repo. Overridable per test, so the OFF branch stays reachable.
|
||||
getAgentSkillEnabled: vi.fn(async () => options?.agentSkillEnabled ?? true),
|
||||
// Default OFF mirrors the shipped setting: no test opens a voice relay by accident.
|
||||
getClaudeVoiceEnabled: vi.fn(async () => options?.claudeVoiceEnabled ?? false),
|
||||
// Default ON mirrors the shipped setting, so a route test sees what a user sees.
|
||||
|
||||
@@ -13,9 +13,16 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { safeRmHomeTree } from './mocks/index.js';
|
||||
import { homedir } from 'node:os';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// A folder of this file's own for the sessions below, never the machine's real `/tmp`:
|
||||
// a create writes the workspace hooks and the agent skill (on by default) into its
|
||||
// working dir, and those would outlive the run there.
|
||||
const WORK_DIR = mkdtempSync(join(tmpdir(), 'codeman-lightspeed-'));
|
||||
afterAll(() => rmSync(WORK_DIR, { recursive: true, force: true }));
|
||||
|
||||
// Helper to parse SSE events from raw text
|
||||
function parseSSEEvents(text: string): Array<{ event: string; data: unknown }> {
|
||||
const events: Array<{ event: string; data: unknown }> = [];
|
||||
@@ -86,7 +93,7 @@ async function createSession(baseUrl: string): Promise<string> {
|
||||
const res = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.data?.session?.id) {
|
||||
@@ -1226,7 +1233,7 @@ describe('Operation Lightspeed', () => {
|
||||
fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
}).then((r) => r.json())
|
||||
)
|
||||
);
|
||||
|
||||
+60
-32
@@ -174,10 +174,13 @@ describe('Session Management', () => {
|
||||
|
||||
describe('POST /api/sessions', () => {
|
||||
it('should create a new session', async () => {
|
||||
// Under the test HOME, never the machine's real `/tmp`: a create writes the workspace
|
||||
// hooks and the agent skill (on by default) into its working dir.
|
||||
const workingDir = mkdtempSync(join(TEST_HOME, 'work-'));
|
||||
const response = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
@@ -185,7 +188,7 @@ describe('Session Management', () => {
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.data.session).toBeDefined();
|
||||
expect(data.data.session.id).toBeDefined();
|
||||
expect(data.data.session.workingDir).toBe('/tmp');
|
||||
expect(data.data.session.workingDir).toBe(workingDir);
|
||||
expect(data.data.session.status).toBe('idle');
|
||||
});
|
||||
});
|
||||
@@ -353,49 +356,63 @@ describe('Agent skill injection (agentSkillEnabled)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('does not inject by default, accepts the setting via PUT, then injects on quick-start', async () => {
|
||||
// 1. Default OFF: a claude quick-start creates the case without the skill.
|
||||
const offCase = 'test-skill-off-' + Date.now();
|
||||
createdCases.push(offCase);
|
||||
const offResponse = await fetch(`${baseUrl}/api/quick-start`, {
|
||||
// The tests below run in order against one server and one settings.json: absent key
|
||||
// (on), a partial PUT that omits it (still on), then an explicit false (off).
|
||||
const skillDirOf = (caseName: string) => join(CASES_DIR, caseName, '.claude', 'skills', 'codeman');
|
||||
|
||||
async function quickStart(prefix: string, mode?: string): Promise<string> {
|
||||
const caseName = `${prefix}-${Date.now()}`;
|
||||
createdCases.push(caseName);
|
||||
const response = await fetch(`${baseUrl}/api/quick-start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseName: offCase }),
|
||||
body: JSON.stringify(mode ? { caseName, mode } : { caseName }),
|
||||
});
|
||||
const offData = await offResponse.json();
|
||||
expect(offData.success).toBe(true);
|
||||
expect(existsSync(join(CASES_DIR, offCase, '.claude', 'skills', 'codeman'))).toBe(false);
|
||||
expect((await response.json()).success).toBe(true);
|
||||
return caseName;
|
||||
}
|
||||
|
||||
// 2. The `.strict()` settings schema accepts the new synced key.
|
||||
const putResponse = await fetch(`${baseUrl}/api/settings`, {
|
||||
async function putSettings(body: Record<string, unknown>): Promise<void> {
|
||||
const response = await fetch(`${baseUrl}/api/settings`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ agentSkillEnabled: true }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const putData = await putResponse.json();
|
||||
expect(putData.success).toBe(true);
|
||||
|
||||
// 3. The server's settings read is cached ~2s; outwait it so the create sees the toggle.
|
||||
expect((await response.json()).success).toBe(true);
|
||||
// The server's settings read is cached ~2s; outwait it so the next create sees the change.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2100));
|
||||
}
|
||||
|
||||
// 4. Quick-start now injects the marker-carrying skill into the new case.
|
||||
const onCase = 'test-skill-on-' + Date.now();
|
||||
createdCases.push(onCase);
|
||||
const onResponse = await fetch(`${baseUrl}/api/quick-start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseName: onCase }),
|
||||
});
|
||||
const onData = await onResponse.json();
|
||||
expect(onData.success).toBe(true);
|
||||
/** What settings.json holds, read back through GET /api/settings (enveloped or not). */
|
||||
async function storedSettings(): Promise<Record<string, unknown>> {
|
||||
const body = (await (await fetch(`${baseUrl}/api/settings`)).json()) as Record<string, unknown>;
|
||||
const settings = (body.success === true ? body.data : body) as Record<string, unknown>;
|
||||
// Not vacuous: every quick-start above records the case it used.
|
||||
expect(settings).toHaveProperty('lastUsedCase');
|
||||
return settings;
|
||||
}
|
||||
|
||||
it('injects by default: a settings.json without the key means on', async () => {
|
||||
const caseName = await quickStart('test-skill-default');
|
||||
// A quick-start writes lastUsedCase, never this key.
|
||||
expect(await storedSettings()).not.toHaveProperty('agentSkillEnabled');
|
||||
|
||||
const skillDir = join(CASES_DIR, onCase, '.claude', 'skills', 'codeman');
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const skillMd = readFileSync(join(skillDir, 'SKILL.md'), 'utf-8');
|
||||
const skillMd = readFileSync(join(skillDirOf(caseName), 'SKILL.md'), 'utf-8');
|
||||
expect(skillMd.startsWith('---\nname: codeman')).toBe(true);
|
||||
expect(skillMd).toContain('<!-- codeman-managed-agent-skill');
|
||||
expect(existsSync(join(skillDir, 'reference', 'endpoints.md'))).toBe(true);
|
||||
expect(existsSync(join(skillDir, 'reference', 'recipes.md'))).toBe(true);
|
||||
expect(existsSync(join(skillDirOf(caseName), 'reference', 'endpoints.md'))).toBe(true);
|
||||
expect(existsSync(join(skillDirOf(caseName), 'reference', 'recipes.md'))).toBe(true);
|
||||
// The copy ignores itself in git, so the default leaves a repository's status clean.
|
||||
expect(readFileSync(join(skillDirOf(caseName), '.gitignore'), 'utf-8')).toBe('*\n');
|
||||
}, 30000);
|
||||
|
||||
it('a partial settings PUT that omits the key keeps it on', async () => {
|
||||
await putSettings({ autoNameSessions: false });
|
||||
expect(await storedSettings()).not.toHaveProperty('agentSkillEnabled');
|
||||
|
||||
const caseName = await quickStart('test-skill-partial');
|
||||
expect(existsSync(join(skillDirOf(caseName), 'SKILL.md'))).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it('does not inject for shell-mode quick-start even when enabled', async () => {
|
||||
@@ -410,4 +427,15 @@ describe('Agent skill injection (agentSkillEnabled)', () => {
|
||||
expect(data.success).toBe(true);
|
||||
expect(existsSync(join(CASES_DIR, shellCase, '.claude', 'skills', 'codeman'))).toBe(false);
|
||||
});
|
||||
|
||||
it('an explicit false turns it off, and a later partial PUT leaves it off', async () => {
|
||||
// The `.strict()` settings schema accepts the synced key.
|
||||
await putSettings({ agentSkillEnabled: false });
|
||||
await putSettings({ autoNameSessions: true });
|
||||
expect((await storedSettings()).agentSkillEnabled).toBe(false);
|
||||
|
||||
const caseName = await quickStart('test-skill-off');
|
||||
expect(existsSync(join(CASES_DIR, caseName))).toBe(true);
|
||||
expect(existsSync(skillDirOf(caseName))).toBe(false);
|
||||
}, 30000);
|
||||
});
|
||||
@@ -2,8 +2,9 @@
|
||||
* @fileoverview Agent-skill injection on POST /api/sessions (docs/agent-control-plan.md §2).
|
||||
*
|
||||
* The quick-start half of this is covered end-to-end in test/quick-start.test.ts;
|
||||
* the plain create path had NO coverage, because the shared mock context hardcoded
|
||||
* `getAgentSkillEnabled` to false and nothing could flip it. This exercises the real
|
||||
* the plain create path had NO coverage, because the shared mock context once hardcoded
|
||||
* `getAgentSkillEnabled` to false and nothing could flip it (it now defaults to true,
|
||||
* the shipped default since 1.50.0, and takes an override). This exercises the real
|
||||
* `applyAgentSkill` against a temp working dir, so it asserts bytes on disk rather
|
||||
* than a spy call.
|
||||
*
|
||||
@@ -81,6 +82,27 @@ describe('POST /api/sessions agent-skill injection', () => {
|
||||
// remove what this create wrote.
|
||||
expect(skillMd).toContain(MARKER_PREFIX);
|
||||
expect(existsSync(join(skillDir(), 'reference', 'endpoints.md'))).toBe(true);
|
||||
// The copy ignores itself, so the default leaves the user's `git status` clean.
|
||||
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('*\n');
|
||||
});
|
||||
|
||||
it('injects with the shared mock context left at its default (the shipped default is ON)', async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
const ctx = createMockRouteContext();
|
||||
registerSessionRoutes(app, ctx);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
harness = { app, ctx };
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { name: 'skill-default', mode: 'claude', workingDir },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(existsSync(join(skillDir(), 'SKILL.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the working dir untouched when the gate is OFF', async () => {
|
||||
|
||||
+12
-6
@@ -1,15 +1,20 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
describe('Interactive Session Lifecycle', () => {
|
||||
let server: WebServer;
|
||||
let baseUrl: string;
|
||||
let customDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// The 'custom working directory' test creates a session in /tmp/test; workingDir
|
||||
// validation requires the dir to exist, so ensure it does (idempotent, CI-safe).
|
||||
mkdirSync('/tmp/test', { recursive: true });
|
||||
// The 'custom working directory' test creates a session in a folder of its own
|
||||
// (workingDir validation requires it to exist), never a fixed path in the machine's
|
||||
// real `/tmp`: a create writes the workspace hooks and the agent skill (on by
|
||||
// default) into its working dir, and those would outlive the run there.
|
||||
customDir = mkdtempSync(join(tmpdir(), 'codeman-session-test-'));
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${server.boundPort}`;
|
||||
@@ -17,6 +22,7 @@ describe('Interactive Session Lifecycle', () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await server.stop();
|
||||
rmSync(customDir, { recursive: true, force: true });
|
||||
}, 60000); // Extended timeout for cleanup
|
||||
|
||||
describe('Session Creation', () => {
|
||||
@@ -39,13 +45,13 @@ describe('Interactive Session Lifecycle', () => {
|
||||
const response = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp/test' }),
|
||||
body: JSON.stringify({ workingDir: customDir }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.data.session.workingDir).toBe('/tmp/test');
|
||||
expect(data.data.session.workingDir).toBe(customDir);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+11
-4
@@ -2,9 +2,16 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { safeRmHomeTree } from './mocks/index.js';
|
||||
import { homedir } from 'node:os';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// A folder of this file's own for the sessions below, never the machine's real `/tmp`:
|
||||
// a create writes the workspace hooks and the agent skill (on by default) into its
|
||||
// working dir, and those would outlive the run there.
|
||||
const WORK_DIR = mkdtempSync(join(tmpdir(), 'codeman-sse-events-'));
|
||||
afterAll(() => rmSync(WORK_DIR, { recursive: true, force: true }));
|
||||
|
||||
// Helper to parse SSE events
|
||||
function parseSSEEvents(text: string): Array<{ event: string; data: unknown }> {
|
||||
const events: Array<{ event: string; data: unknown }> = [];
|
||||
@@ -130,7 +137,7 @@ describe('SSE Events', () => {
|
||||
await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
|
||||
// Wait a bit for the event
|
||||
@@ -168,7 +175,7 @@ describe('SSE Events', () => {
|
||||
await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/status`);
|
||||
@@ -221,7 +228,7 @@ describe('SSE Event Types', () => {
|
||||
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { safeRmHomeTree } from './mocks/index.js';
|
||||
import { homedir } from 'node:os';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// A folder of this file's own for the sessions below, never the machine's real `/tmp`:
|
||||
// a create writes the workspace hooks and the agent skill (on by default) into its
|
||||
// working dir, and those would outlive the run there.
|
||||
const WORK_DIR = mkdtempSync(join(tmpdir(), 'codeman-sse-filter-'));
|
||||
afterAll(() => rmSync(WORK_DIR, { recursive: true, force: true }));
|
||||
|
||||
// Helper to parse SSE events from raw text
|
||||
function parseSSEEvents(text: string): Array<{ event: string; data: unknown }> {
|
||||
const events: Array<{ event: string; data: unknown }> = [];
|
||||
@@ -157,7 +164,7 @@ describe('SSE Subscription Filtering', () => {
|
||||
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
const sessionId = createData.data.session.id;
|
||||
@@ -208,7 +215,7 @@ describe('SSE Subscription Filtering', () => {
|
||||
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
const sessionId = createData.data.session.id;
|
||||
@@ -263,14 +270,14 @@ describe('SSE Subscription Filtering', () => {
|
||||
const createRes1 = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
const session1 = (await createRes1.json()).data.session;
|
||||
|
||||
const createRes2 = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
const session2 = (await createRes2.json()).data.session;
|
||||
|
||||
@@ -330,14 +337,14 @@ describe('SSE Subscription Filtering', () => {
|
||||
const createRes1 = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
const session1 = (await createRes1.json()).data.session;
|
||||
|
||||
const createRes2 = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
const session2 = (await createRes2.json()).data.session;
|
||||
|
||||
|
||||
Reference in new issue
Block a user