mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-13 18:29:32 +01:00
1014 lines
39 KiB
TypeScript
1014 lines
39 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import vm from 'node:vm';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
function loadPaletteHarness(overrides: Record<string, any> = {}, { sessionUi = false } = {}) {
|
|
const elements: Record<string, any> = {};
|
|
const listeners: Record<string, (event: any) => void> = {};
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
|
|
const makeClassList = () => {
|
|
const classes = new Set<string>();
|
|
return {
|
|
add: (...names: string[]) => names.forEach((name) => classes.add(name)),
|
|
remove: (...names: string[]) => names.forEach((name) => classes.delete(name)),
|
|
contains: (name: string) => classes.has(name),
|
|
toggle: (name: string, force?: boolean) => {
|
|
const shouldAdd = force ?? !classes.has(name);
|
|
if (shouldAdd) classes.add(name);
|
|
else classes.delete(name);
|
|
return shouldAdd;
|
|
},
|
|
};
|
|
};
|
|
|
|
elements.commandPaletteModal = {
|
|
classList: makeClassList(),
|
|
addEventListener: vi.fn((event: string, handler: (event: any) => void) => {
|
|
listeners[`modal:${event}`] = handler;
|
|
}),
|
|
};
|
|
elements.commandPaletteSearch = {
|
|
value: '',
|
|
focus: vi.fn(),
|
|
select: vi.fn(),
|
|
addEventListener: vi.fn((event: string, handler: (event: any) => void) => {
|
|
listeners[`search:${event}`] = handler;
|
|
}),
|
|
};
|
|
elements.commandPaletteList = {
|
|
innerHTML: '',
|
|
addEventListener: vi.fn((event: string, handler: (event: any) => void) => {
|
|
listeners[`list:${event}`] = handler;
|
|
}),
|
|
};
|
|
elements.quickStartCase = {
|
|
value: 'plex-previews',
|
|
};
|
|
|
|
const context = vm.createContext({
|
|
CodemanApp,
|
|
document: {
|
|
getElementById: (id: string) => elements[id] ?? null,
|
|
createElement: (tagName: string) => ({
|
|
tagName: tagName.toUpperCase(),
|
|
value: '',
|
|
textContent: '',
|
|
}),
|
|
},
|
|
console,
|
|
escapeHtml: (value: string) =>
|
|
String(value)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, '''),
|
|
...overrides,
|
|
});
|
|
|
|
const panelsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/panels-ui.js'), 'utf8');
|
|
vm.runInContext(panelsUi, context, { filename: 'panels-ui.js' });
|
|
// session-ui.js loads after panels-ui.js in index.html. Opt-in: it also brings
|
|
// the real selectQuickStartCase, which the case-pick tests above stub around.
|
|
if (sessionUi) {
|
|
const sessionUiSrc = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
|
vm.runInContext(sessionUiSrc, context, { filename: 'session-ui.js' });
|
|
}
|
|
|
|
const app = new (CodemanApp as any)();
|
|
app.sessions = new Map([
|
|
[
|
|
'sess-alpha',
|
|
{
|
|
id: 'sess-alpha',
|
|
name: 'Alpha API cleanup',
|
|
workingDir: '/repo/api',
|
|
mode: 'codex',
|
|
status: 'busy',
|
|
},
|
|
],
|
|
[
|
|
'sess-beta',
|
|
{
|
|
id: 'sess-beta',
|
|
name: 'Billing prompt polish',
|
|
workingDir: '/repo/billing',
|
|
mode: 'claude',
|
|
status: 'idle',
|
|
},
|
|
],
|
|
[
|
|
'sess-gamma',
|
|
{
|
|
id: 'sess-gamma',
|
|
workingDir: '/repo/flux-player',
|
|
mode: 'codex',
|
|
status: 'busy',
|
|
},
|
|
],
|
|
]);
|
|
app.sessionOrder = ['sess-beta', 'sess-alpha', 'sess-gamma'];
|
|
app.cases = [{ name: 'plex-previews' }, { name: 'flux-player' }, { name: 'api-tools' }];
|
|
app.selectSession = vi.fn();
|
|
app.run = vi.fn();
|
|
app.getShortId = (id: string) => id.slice(0, 8);
|
|
app.getSessionName = (session: any) =>
|
|
session.name || session.workingDir?.split('/').pop() || app.getShortId(session.id);
|
|
|
|
return { app, elements, listeners, makeClassList };
|
|
}
|
|
|
|
describe('Command-K session palette', () => {
|
|
it('recognizes Cmd/Ctrl-K outside text-entry contexts only', () => {
|
|
const { app } = loadPaletteHarness();
|
|
|
|
expect(app.shouldOpenCommandPaletteFromShortcut({ key: 'k', metaKey: true, ctrlKey: false, target: null })).toBe(
|
|
true
|
|
);
|
|
expect(app.shouldOpenCommandPaletteFromShortcut({ key: 'K', metaKey: false, ctrlKey: true, target: null })).toBe(
|
|
true
|
|
);
|
|
expect(
|
|
app.shouldOpenCommandPaletteFromShortcut({
|
|
key: 'k',
|
|
metaKey: true,
|
|
ctrlKey: false,
|
|
target: { tagName: 'INPUT', isContentEditable: false },
|
|
})
|
|
).toBe(false);
|
|
expect(
|
|
app.shouldOpenCommandPaletteFromShortcut({
|
|
key: 'k',
|
|
metaKey: false,
|
|
ctrlKey: true,
|
|
target: { tagName: 'DIV', isContentEditable: true },
|
|
})
|
|
).toBe(false);
|
|
});
|
|
|
|
it('recognizes Ctrl-K from the focused xterm helper textarea', () => {
|
|
const { app } = loadPaletteHarness();
|
|
|
|
expect(
|
|
app.shouldOpenCommandPaletteFromShortcut({
|
|
key: 'k',
|
|
code: 'KeyK',
|
|
metaKey: false,
|
|
ctrlKey: true,
|
|
altKey: false,
|
|
target: {
|
|
tagName: 'TEXTAREA',
|
|
isContentEditable: false,
|
|
classList: { contains: (name: string) => name === 'xterm-helper-textarea' },
|
|
},
|
|
})
|
|
).toBe(true);
|
|
});
|
|
|
|
it('recognizes macOS Option-K by physical key code', () => {
|
|
const { app } = loadPaletteHarness();
|
|
|
|
expect(
|
|
app.shouldOpenCommandPaletteFromShortcut({
|
|
key: '˚',
|
|
code: 'KeyK',
|
|
metaKey: false,
|
|
ctrlKey: false,
|
|
altKey: true,
|
|
target: null,
|
|
})
|
|
).toBe(true);
|
|
});
|
|
|
|
it('rejects the palette chord when extra modifiers are held (Ctrl+Shift+K is the Firefox devtools console)', () => {
|
|
const { app } = loadPaletteHarness();
|
|
|
|
expect(
|
|
app.shouldOpenCommandPaletteFromShortcut({ key: 'K', code: 'KeyK', ctrlKey: true, shiftKey: true, target: null })
|
|
).toBe(false);
|
|
});
|
|
|
|
it('honors a disabled or rebound palette shortcut from the registry', () => {
|
|
const { app } = loadPaletteHarness();
|
|
|
|
// Disabled entry → never opens, even for the default chord.
|
|
app.getShortcutRegistry = () => [
|
|
{ id: 'command-palette', disabled: true, bindings: [{ modifiers: ['ctrl'], key: 'k', code: 'KeyK' }] },
|
|
];
|
|
app.matchesShortcutEvent = () => true;
|
|
expect(app.shouldOpenCommandPaletteFromShortcut({ key: 'k', code: 'KeyK', ctrlKey: true, target: null })).toBe(
|
|
false
|
|
);
|
|
|
|
// Rebound entry → the new chord opens, the old default no longer does.
|
|
app.getShortcutRegistry = () => [{ id: 'command-palette', bindings: [{ modifiers: ['ctrl'], code: 'KeyP' }] }];
|
|
app.matchesShortcutEvent = (e: any, s: any) => e.code === s.bindings[0].code;
|
|
expect(app.shouldOpenCommandPaletteFromShortcut({ key: 'k', code: 'KeyK', ctrlKey: true, target: null })).toBe(
|
|
false
|
|
);
|
|
expect(app.shouldOpenCommandPaletteFromShortcut({ key: 'p', code: 'KeyP', ctrlKey: true, target: null })).toBe(
|
|
true
|
|
);
|
|
});
|
|
|
|
it('opens and focuses the palette search box', () => {
|
|
const { app, elements } = loadPaletteHarness();
|
|
|
|
app.openCommandPalette();
|
|
|
|
expect(elements.commandPaletteModal.classList.contains('active')).toBe(true);
|
|
expect(elements.commandPaletteSearch.focus).toHaveBeenCalledTimes(1);
|
|
expect(elements.commandPaletteList.innerHTML).toContain('Alpha API cleanup');
|
|
});
|
|
|
|
it('filters currently open sessions and always includes a new-session action', () => {
|
|
const { app } = loadPaletteHarness();
|
|
|
|
const results = app.buildCommandPaletteItems('bill');
|
|
|
|
expect(results.map((item: any) => item.id)).toEqual(['session:sess-beta', 'new-session', 'browse-sessions']);
|
|
expect(results[0]).toMatchObject({ type: 'session', sessionId: 'sess-beta', title: 'Billing prompt polish' });
|
|
expect(results[1]).toMatchObject({ type: 'new-session', title: 'New session' });
|
|
expect(results[2]).toMatchObject({ type: 'browse-sessions' });
|
|
});
|
|
|
|
it('uses the tab name instead of the short session id for unnamed sessions', () => {
|
|
const { app } = loadPaletteHarness();
|
|
|
|
const results = app.buildCommandPaletteItems('flux-player');
|
|
|
|
expect(results[0]).toMatchObject({
|
|
type: 'session',
|
|
sessionId: 'sess-gamma',
|
|
title: 'flux-player',
|
|
});
|
|
expect(results[0].title).not.toBe('sess-gam');
|
|
});
|
|
|
|
it('uses the best matching case for the new-session action', async () => {
|
|
const { app, elements } = loadPaletteHarness();
|
|
|
|
const results = app.buildCommandPaletteItems('flux');
|
|
const newSession = results.find((item: any) => item.type === 'new-session');
|
|
|
|
expect(newSession).toMatchObject({
|
|
type: 'new-session',
|
|
caseName: 'flux-player',
|
|
subtitle: 'Run Claude in flux-player',
|
|
});
|
|
|
|
app.commandPaletteItems = [newSession];
|
|
app.commandPaletteActiveIndex = 0;
|
|
await app.activateCommandPaletteItem();
|
|
|
|
expect(elements.quickStartCase.value).toBe('flux-player');
|
|
expect(app.run).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('adds the matched case option before selecting it for a new session', async () => {
|
|
const { app, elements } = loadPaletteHarness();
|
|
const options = [{ value: 'plex-previews' }];
|
|
elements.quickStartCase = {
|
|
tagName: 'SELECT',
|
|
options,
|
|
appendChild: vi.fn((option: any) => options.push(option)),
|
|
get value() {
|
|
return this._value || '';
|
|
},
|
|
set value(next: string) {
|
|
this._value = options.some((option) => option.value === next) ? next : '';
|
|
},
|
|
};
|
|
elements.quickStartCase.value = 'plex-previews';
|
|
|
|
const newSession = app.buildCommandPaletteItems('flux').find((item: any) => item.type === 'new-session');
|
|
app.commandPaletteItems = [newSession];
|
|
app.commandPaletteActiveIndex = 0;
|
|
|
|
await app.activateCommandPaletteItem();
|
|
|
|
expect(elements.quickStartCase.appendChild).toHaveBeenCalledTimes(1);
|
|
expect(elements.quickStartCase.value).toBe('flux-player');
|
|
expect(app.run).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('routes the new-session case pick through selectQuickStartCase when the picker mixin is loaded', async () => {
|
|
const { app } = loadPaletteHarness();
|
|
app.selectQuickStartCase = vi.fn();
|
|
|
|
const newSession = app.buildCommandPaletteItems('flux').find((item: any) => item.type === 'new-session');
|
|
app.commandPaletteItems = [newSession];
|
|
app.commandPaletteActiveIndex = 0;
|
|
await app.activateCommandPaletteItem();
|
|
|
|
// Keeps the searchable combobox, dir display, and lastUsedCase in sync
|
|
// instead of silently mutating the hidden native <select>.
|
|
expect(app.selectQuickStartCase).toHaveBeenCalledWith('flux-player');
|
|
expect(app.run).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('activates the highlighted session result', async () => {
|
|
const { app } = loadPaletteHarness();
|
|
app.openCommandPalette();
|
|
app.commandPaletteItems = app.buildCommandPaletteItems('api');
|
|
app.commandPaletteActiveIndex = 0;
|
|
|
|
await app.activateCommandPaletteItem();
|
|
|
|
expect(app.selectSession).toHaveBeenCalledWith('sess-alpha');
|
|
expect(app.run).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('activates the new-session result through the current run path', async () => {
|
|
const { app } = loadPaletteHarness();
|
|
app.openCommandPalette();
|
|
app.commandPaletteItems = app.buildCommandPaletteItems('does-not-match');
|
|
app.commandPaletteActiveIndex = 0;
|
|
|
|
await app.activateCommandPaletteItem();
|
|
|
|
expect(app.run).toHaveBeenCalledTimes(1);
|
|
expect(app.selectSession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('routes Enter from the palette search to the current result', async () => {
|
|
const { app, listeners } = loadPaletteHarness();
|
|
app.openCommandPalette();
|
|
app.commandPaletteItems = app.buildCommandPaletteItems('api');
|
|
app.commandPaletteActiveIndex = 0;
|
|
|
|
const event = { key: 'Enter', preventDefault: vi.fn(), stopPropagation: vi.fn() };
|
|
await listeners['search:keydown'](event);
|
|
|
|
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
|
expect(app.selectSession).toHaveBeenCalledWith('sess-alpha');
|
|
});
|
|
});
|
|
|
|
const PALETTE_CATALOG = [
|
|
{ id: 'claude', label: 'Claude Code', kind: 'agent', enabled: true },
|
|
{ id: 'shell', label: 'Shell', kind: 'shell', enabled: true },
|
|
{ id: 'codex', label: 'Codex', kind: 'agent', enabled: true },
|
|
{ id: 'opencode', label: 'OpenCode', kind: 'agent', enabled: true },
|
|
{ id: 'gemini', label: 'Gemini', kind: 'agent', enabled: false },
|
|
];
|
|
|
|
/** A palette whose agent list comes from the injected CLI catalog, codex not installed. */
|
|
function loadAgentPalette(catalog: any[] = PALETTE_CATALOG, options: { sessionUi?: boolean } = {}) {
|
|
const available: Record<string, boolean> = { claude: true, shell: true, codex: false, opencode: true, gemini: true };
|
|
const harness = loadPaletteHarness(
|
|
{ window: { __codemanCliCatalog: catalog, __codemanCliAvailable: available } },
|
|
options
|
|
);
|
|
// Mirrors settings-ui.js isCliAvailable(): unknown reads as available.
|
|
harness.app.isCliAvailable = (id: string) => available[id] !== false;
|
|
harness.app._runMode = 'claude';
|
|
return harness;
|
|
}
|
|
|
|
function arrow(key: 'ArrowLeft' | 'ArrowRight', caret: number, value = '') {
|
|
return {
|
|
key,
|
|
preventDefault: vi.fn(),
|
|
stopPropagation: vi.fn(),
|
|
target: { value, selectionStart: caret, selectionEnd: caret },
|
|
};
|
|
}
|
|
|
|
describe('Command-K new-session agent picker', () => {
|
|
it('offers the enabled, installed CLIs from the registry catalog, in catalog order', () => {
|
|
const { app } = loadAgentPalette();
|
|
|
|
expect(app._commandPaletteAgentChoices().map((c: any) => c.id)).toEqual(['claude', 'shell', 'opencode']);
|
|
});
|
|
|
|
it('starts on the Run button mode and labels the row from the catalog', () => {
|
|
const { app, elements } = loadAgentPalette();
|
|
app._runMode = 'opencode';
|
|
|
|
app.openCommandPalette();
|
|
const item = app.buildCommandPaletteItems('flux').find((i: any) => i.type === 'new-session');
|
|
|
|
expect(item).toMatchObject({ mode: 'opencode', subtitle: 'Run OpenCode in flux-player' });
|
|
expect(elements.commandPaletteList.innerHTML).toContain('data-palette-agent-cycle="next"');
|
|
expect(elements.commandPaletteList.innerHTML).toContain('run-mode-dot opencode');
|
|
});
|
|
|
|
it('keeps the switch out of the row button accessible name and gives its tooltip a zh-CN entry', () => {
|
|
const { app, elements } = loadAgentPalette();
|
|
app.openCommandPalette();
|
|
const html = elements.commandPaletteList.innerHTML;
|
|
const title = 'Agent for the new session (Left/Right to change)';
|
|
|
|
// No nested buttons inside the row's <button>: the subtitle names the agent.
|
|
expect(html).toContain(`class="command-palette-agent" aria-hidden="true" title="${title}"`);
|
|
expect(html).not.toContain('role="button"');
|
|
expect(html).not.toContain('aria-label=');
|
|
const i18n = readFileSync(resolve(import.meta.dirname, '../src/web/public/i18n.js'), 'utf8');
|
|
expect(i18n.split(`'${title}':`).length - 1).toBe(1);
|
|
});
|
|
|
|
it('falls back to the first offered agent when the Run button mode is not offered', () => {
|
|
const { app } = loadAgentPalette();
|
|
app._runMode = 'codex'; // not installed
|
|
|
|
app.openCommandPalette();
|
|
|
|
expect(app.buildCommandPaletteItems('').find((i: any) => i.type === 'new-session').mode).toBe('claude');
|
|
});
|
|
|
|
it('cycles the agent with Right/Left on the new-session row, wrapping', async () => {
|
|
const { app, listeners, elements } = loadAgentPalette();
|
|
app.openCommandPalette();
|
|
elements.commandPaletteSearch.value = 'nomatch';
|
|
app.renderCommandPalette();
|
|
expect(app.commandPaletteItems[app.commandPaletteActiveIndex].type).toBe('new-session');
|
|
|
|
const right = arrow('ArrowRight', 7, 'nomatch');
|
|
await listeners['search:keydown'](right);
|
|
expect(right.preventDefault).toHaveBeenCalled();
|
|
expect(app.commandPaletteItems[app.commandPaletteActiveIndex]).toMatchObject({ mode: 'shell' });
|
|
expect(elements.commandPaletteList.innerHTML).toContain('Run Shell in');
|
|
|
|
await listeners['search:keydown'](arrow('ArrowLeft', 0, 'nomatch'));
|
|
await listeners['search:keydown'](arrow('ArrowLeft', 0, 'nomatch'));
|
|
expect(app.commandPaletteItems[app.commandPaletteActiveIndex]).toMatchObject({ mode: 'opencode' });
|
|
});
|
|
|
|
it('cycles both ways at the end of a typed case query without moving its caret', async () => {
|
|
const { app, listeners, elements } = loadAgentPalette();
|
|
app.openCommandPalette();
|
|
elements.commandPaletteSearch.value = 'nomatch';
|
|
app.renderCommandPalette();
|
|
const left = arrow('ArrowLeft', 7, 'nomatch');
|
|
await listeners['search:keydown'](left);
|
|
expect(left.preventDefault).toHaveBeenCalled();
|
|
expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('opencode');
|
|
const right = arrow('ArrowRight', 7, 'nomatch');
|
|
await listeners['search:keydown'](right);
|
|
expect(right.preventDefault).toHaveBeenCalled();
|
|
expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('claude');
|
|
});
|
|
|
|
it('leaves Left/Right to the caret inside the query', async () => {
|
|
const { app, listeners, elements } = loadAgentPalette();
|
|
app.openCommandPalette();
|
|
elements.commandPaletteSearch.value = 'nomatch';
|
|
app.renderCommandPalette();
|
|
|
|
const left = arrow('ArrowLeft', 3, 'nomatch');
|
|
const right = arrow('ArrowRight', 3, 'nomatch');
|
|
await listeners['search:keydown'](left);
|
|
await listeners['search:keydown'](right);
|
|
|
|
expect(left.preventDefault).not.toHaveBeenCalled();
|
|
expect(right.preventDefault).not.toHaveBeenCalled();
|
|
expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('claude');
|
|
});
|
|
|
|
it.each(['altKey', 'ctrlKey', 'metaKey', 'shiftKey'])(
|
|
'preserves %s arrow editing at query boundaries',
|
|
async (modifier) => {
|
|
const { app, listeners, elements } = loadAgentPalette();
|
|
app.openCommandPalette();
|
|
elements.commandPaletteSearch.value = 'nomatch';
|
|
app.renderCommandPalette();
|
|
const event = { ...arrow('ArrowLeft', 7, 'nomatch'), [modifier]: true };
|
|
await listeners['search:keydown'](event);
|
|
expect(event.preventDefault).not.toHaveBeenCalled();
|
|
expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('claude');
|
|
}
|
|
);
|
|
|
|
it('does not cycle while a session row is highlighted', async () => {
|
|
const { app, listeners } = loadAgentPalette();
|
|
app.openCommandPalette();
|
|
expect(app.commandPaletteItems[0].type).toBe('session');
|
|
|
|
const right = arrow('ArrowRight', 0);
|
|
await listeners['search:keydown'](right);
|
|
|
|
expect(right.preventDefault).not.toHaveBeenCalled();
|
|
expect(app.buildCommandPaletteItems('').find((i: any) => i.type === 'new-session').mode).toBe('claude');
|
|
});
|
|
|
|
it('cycles from the row arrows without launching', async () => {
|
|
const { app, listeners } = loadAgentPalette();
|
|
app.openCommandPalette();
|
|
const cycle = { dataset: { paletteAgentCycle: 'next' } };
|
|
const event = {
|
|
preventDefault: vi.fn(),
|
|
stopPropagation: vi.fn(),
|
|
target: { closest: (sel: string) => (sel === '[data-palette-agent-cycle]' ? cycle : null) },
|
|
};
|
|
|
|
listeners['list:click'](event);
|
|
|
|
expect(app.run).not.toHaveBeenCalled();
|
|
expect(app.buildCommandPaletteItems('').find((i: any) => i.type === 'new-session').mode).toBe('shell');
|
|
});
|
|
|
|
it('launches the chosen agent once, without changing the Run button mode', async () => {
|
|
const { app } = loadAgentPalette();
|
|
app.setRunMode = vi.fn();
|
|
let modeDuringRun: string | undefined;
|
|
app.run = vi.fn(async () => {
|
|
modeDuringRun = app._runMode;
|
|
});
|
|
app.openCommandPalette();
|
|
app._cycleCommandPaletteAgent(2); // claude -> shell -> opencode
|
|
|
|
app.commandPaletteItems = app.buildCommandPaletteItems('nomatch');
|
|
app.commandPaletteActiveIndex = 0;
|
|
await app.activateCommandPaletteItem();
|
|
|
|
expect(app.run).toHaveBeenCalledTimes(1);
|
|
expect(modeDuringRun).toBe('opencode');
|
|
expect(app._runMode).toBe('claude');
|
|
expect(app.setRunMode).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('restores the Run button mode even when the launch throws', async () => {
|
|
const { app } = loadAgentPalette();
|
|
app.run = vi.fn(async () => {
|
|
throw new Error('boom');
|
|
});
|
|
app.openCommandPalette();
|
|
app._cycleCommandPaletteAgent(1);
|
|
app.commandPaletteItems = app.buildCommandPaletteItems('nomatch');
|
|
app.commandPaletteActiveIndex = 0;
|
|
|
|
await expect(app.activateCommandPaletteItem()).rejects.toThrow('boom');
|
|
expect(app._runMode).toBe('claude');
|
|
});
|
|
|
|
it('draws no picker when only one agent is offered', () => {
|
|
const { app, elements } = loadAgentPalette([{ id: 'claude', label: 'Claude Code', kind: 'agent', enabled: true }]);
|
|
|
|
app.openCommandPalette();
|
|
|
|
expect(elements.commandPaletteList.innerHTML).not.toContain('data-palette-agent-cycle');
|
|
});
|
|
|
|
it('keeps the Run button mode when a single other agent is offered', async () => {
|
|
// Only Shell is offered while the Run button names claude: with nothing to
|
|
// pick between, the row keeps the Run button's mode, as before the picker
|
|
// existed, so run() answers a missing claude with its install hint instead
|
|
// of the palette silently starting Shell.
|
|
const { app, elements } = loadAgentPalette(PALETTE_CATALOG.slice(0, 2));
|
|
app.isCliAvailable = (id: string) => id !== 'claude';
|
|
let modeDuringRun: string | undefined;
|
|
app.run = vi.fn(async () => {
|
|
modeDuringRun = app._runMode;
|
|
});
|
|
|
|
app.openCommandPalette();
|
|
elements.commandPaletteSearch.value = 'nomatch';
|
|
app.renderCommandPalette();
|
|
const index = app.commandPaletteItems.findIndex((i: any) => i.type === 'new-session');
|
|
|
|
expect(app.commandPaletteItems[index]).toMatchObject({ mode: 'claude', subtitle: 'Run Claude in plex-previews' });
|
|
expect(elements.commandPaletteList.innerHTML).not.toContain('data-palette-agent-cycle');
|
|
await app.activateCommandPaletteItem(index);
|
|
expect(modeDuringRun).toBe('claude');
|
|
expect(app._runMode).toBe('claude');
|
|
});
|
|
});
|
|
|
|
/** The toolbar Run menu's entries, as renderRegistryRunOptions() and index.html draw them. */
|
|
function fakeRunMenu(modes: string[]) {
|
|
const options = modes.map((mode) => ({ dataset: { mode }, style: { display: 'PRISTINE' } }));
|
|
return {
|
|
options,
|
|
querySelectorAll: (selector: string) => (selector === '.run-mode-option[data-mode]' ? options : []),
|
|
querySelector: () => null,
|
|
prepend: vi.fn(),
|
|
};
|
|
}
|
|
const shownByRunMenu = (menu: ReturnType<typeof fakeRunMenu>) =>
|
|
menu.options.filter((option) => option.style.display === 'flex').map((option) => option.dataset.mode);
|
|
|
|
/**
|
|
* A palette with session-ui.js loaded, so the agent list goes through the Run
|
|
* menu's real availability rule. Host flags as in loadAgentPalette (no codex
|
|
* here); the active case is a Docker case with the given `docker` record.
|
|
*/
|
|
function loadDockerPalette(docker: Record<string, any>) {
|
|
const harness = loadAgentPalette(PALETTE_CATALOG, { sessionUi: true });
|
|
harness.app.cases = [
|
|
{ name: 'plex-previews' },
|
|
{ name: 'far', location: 'remote' },
|
|
{ name: 'dock', location: 'docker', docker },
|
|
];
|
|
harness.elements.quickStartCase.value = 'dock';
|
|
harness.app.selectQuickStartCase = vi.fn();
|
|
// The Run menu's live container probe is a docker exec round trip; here it
|
|
// never lands, which is also what a probe still in flight looks like.
|
|
harness.app._probeDockerCaseModes = vi.fn();
|
|
return harness;
|
|
}
|
|
|
|
const ADOPTED = { owned: false, hostId: 'local', container: 'dev-box', availableModes: ['shell', 'claude', 'codex'] };
|
|
|
|
describe('Command-K new-session agents follow the Run menu for the row case', () => {
|
|
it('offers a Docker case its container agents and starts on a container-only Run mode', async () => {
|
|
// codex is missing on the host but ships in the container, and the Run
|
|
// button is on it (picked from the Run menu, which gates on the container).
|
|
const { app, elements } = loadDockerPalette(ADOPTED);
|
|
app._runMode = 'codex';
|
|
let modeDuringRun: string | undefined;
|
|
app.run = vi.fn(async () => {
|
|
modeDuringRun = app._runMode;
|
|
});
|
|
|
|
app.openCommandPalette();
|
|
elements.commandPaletteSearch.value = 'nomatch';
|
|
app.renderCommandPalette();
|
|
const index = app.commandPaletteItems.findIndex((i: any) => i.type === 'new-session');
|
|
|
|
expect(app._commandPaletteAgentChoices('dock').map((c: any) => c.id)).toEqual(['claude', 'shell', 'codex']);
|
|
expect(app.commandPaletteItems[index]).toMatchObject({
|
|
caseName: 'dock',
|
|
mode: 'codex',
|
|
subtitle: 'Run Codex in dock',
|
|
});
|
|
expect(elements.commandPaletteList.innerHTML).toContain('run-mode-dot codex');
|
|
|
|
// The same agents the Run menu shows for this case.
|
|
const menu = fakeRunMenu(['claude', 'codex', 'opencode', 'shell']);
|
|
app._refreshRunModeAvailability(menu);
|
|
expect(shownByRunMenu(menu)).toEqual(['claude', 'codex']);
|
|
|
|
await app.activateCommandPaletteItem(index);
|
|
expect(modeDuringRun).toBe('codex');
|
|
expect(app._runMode).toBe('codex');
|
|
});
|
|
|
|
it('offers every enabled agent for an owned container nothing has probed yet, and then follows the probe', () => {
|
|
// An owned container runs the base image, which ships every CLI, and before
|
|
// its first session there is nothing to probe: like the Run menu, an unknown
|
|
// list does not gate, so a probe still in flight cannot empty the row.
|
|
const { app } = loadDockerPalette({ owned: true, hostId: 'local', container: 'codeman-dock' });
|
|
app._runMode = 'codex';
|
|
|
|
app.openCommandPalette();
|
|
|
|
expect(app._commandPaletteAgentChoices('dock').map((c: any) => c.id)).toEqual([
|
|
'claude',
|
|
'shell',
|
|
'codex',
|
|
'opencode',
|
|
]);
|
|
expect(app.commandPaletteItems.find((i: any) => i.type === 'new-session').mode).toBe('codex');
|
|
// The palette reads what the Run menu learned; it never starts a docker exec itself.
|
|
expect(app._probeDockerCaseModes).not.toHaveBeenCalled();
|
|
const menu = fakeRunMenu(['claude', 'codex', 'opencode', 'shell']);
|
|
app._refreshRunModeAvailability(menu);
|
|
expect(shownByRunMenu(menu)).toEqual(['claude', 'codex', 'opencode']);
|
|
|
|
// Once the live probe has landed, both follow it.
|
|
app._dockerCaseModes = { dock: ['shell', 'opencode'] };
|
|
expect(app._commandPaletteAgentChoices('dock').map((c: any) => c.id)).toEqual(['shell', 'opencode']);
|
|
app._refreshRunModeAvailability(menu);
|
|
expect(shownByRunMenu(menu)).toEqual(['opencode']);
|
|
});
|
|
|
|
it('keeps the host rule for local and remote cases, and follows the case the query names', () => {
|
|
const { app, elements } = loadDockerPalette(ADOPTED);
|
|
elements.quickStartCase.value = 'plex-previews';
|
|
app._runMode = 'codex'; // missing on the host
|
|
const row = () => app.commandPaletteItems.find((i: any) => i.type === 'new-session');
|
|
|
|
app.openCommandPalette();
|
|
|
|
expect(app._commandPaletteAgentChoices('plex-previews').map((c: any) => c.id)).toEqual([
|
|
'claude',
|
|
'shell',
|
|
'opencode',
|
|
]);
|
|
expect(app._commandPaletteAgentChoices('far').map((c: any) => c.id)).toEqual(['claude', 'shell', 'opencode']);
|
|
expect(row()).toMatchObject({ caseName: 'plex-previews', mode: 'claude' });
|
|
|
|
// Typing the Docker case's name moves the row there, where the container
|
|
// has the Run button's codex, so the row starts on it.
|
|
elements.commandPaletteSearch.value = 'dock';
|
|
app.renderCommandPalette();
|
|
expect(row()).toMatchObject({ caseName: 'dock', mode: 'codex' });
|
|
});
|
|
});
|
|
|
|
describe('Session Manager unified list', () => {
|
|
it('maps UnifiedSessionItem fields to the history-record shape and routes clicks by liveness', async () => {
|
|
const { app, elements } = loadPaletteHarness({
|
|
fetch: async (url: string) => {
|
|
expect(url).toBe('/api/sessions/unified?limit=200&q=api');
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
success: true,
|
|
data: {
|
|
sessions: [
|
|
{
|
|
sessionId: 'sess-alpha',
|
|
name: 'Alpha API cleanup',
|
|
workingDir: '/repo/api',
|
|
lastActivityAt: 1751000000000,
|
|
sources: ['live'],
|
|
},
|
|
{
|
|
sessionId: 'conv-uuid-1',
|
|
workingDir: '/repo/old',
|
|
sizeBytes: 2048,
|
|
firstPrompt: 'old prompt',
|
|
lastActivityAt: 1750000000000,
|
|
sources: ['history'],
|
|
},
|
|
],
|
|
total: 2,
|
|
},
|
|
}),
|
|
};
|
|
},
|
|
});
|
|
elements.sessionManagerList = { replaceChildren: vi.fn(), appendChild: vi.fn() };
|
|
app._buildHistoryItem = vi.fn(() => ({}));
|
|
app.resumeHistorySession = vi.fn();
|
|
|
|
await app._loadSessionManagerList('api');
|
|
|
|
expect(app._buildHistoryItem).toHaveBeenCalledTimes(2);
|
|
const [liveRecord, , liveOptions] = app._buildHistoryItem.mock.calls[0];
|
|
expect(liveRecord).toMatchObject({
|
|
sessionId: 'sess-alpha',
|
|
workingDir: '/repo/api',
|
|
sizeBytes: 0,
|
|
firstPrompt: 'Alpha API cleanup',
|
|
});
|
|
expect(new Date(liveRecord.lastModified).getTime()).toBe(1751000000000);
|
|
expect(liveOptions.showViewAll).toBe(false);
|
|
|
|
// Live row → switch to the session (resuming it would spawn a duplicate).
|
|
liveOptions.onActivate();
|
|
expect(app.selectSession).toHaveBeenCalledWith('sess-alpha');
|
|
expect(app.resumeHistorySession).not.toHaveBeenCalled();
|
|
|
|
// History row → resume by conversation UUID. The trailing `resumeId` is the
|
|
// CLI's own thread token, which only a non-claude transcript scanner sets;
|
|
// a Claude row carries none, so it arrives undefined here.
|
|
const [historyRecord, , historyOptions] = app._buildHistoryItem.mock.calls[1];
|
|
expect(historyRecord).toMatchObject({ sessionId: 'conv-uuid-1', sizeBytes: 2048, firstPrompt: 'old prompt' });
|
|
historyOptions.onActivate();
|
|
expect(app.resumeHistorySession).toHaveBeenCalledWith('conv-uuid-1', '/repo/old', undefined, undefined, undefined);
|
|
});
|
|
|
|
it('keeps mode, claudeSessionId and resumeId on the row record the ⋯ menu reads', async () => {
|
|
const { app, elements } = loadPaletteHarness({
|
|
fetch: async () => ({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
success: true,
|
|
data: {
|
|
sessions: [
|
|
{
|
|
sessionId: 'codex-thread-1',
|
|
mode: 'codex',
|
|
resumeId: 'codex-thread-1',
|
|
workingDir: '/repo/cx',
|
|
firstPrompt: 'codex prompt',
|
|
lastActivityAt: 1750000000000,
|
|
sources: ['history'],
|
|
},
|
|
{
|
|
sessionId: 'sess-resumed',
|
|
mode: 'claude',
|
|
claudeSessionId: 'conv-uuid-2',
|
|
workingDir: '/repo/cl',
|
|
lastActivityAt: 1749000000000,
|
|
sources: ['persisted'],
|
|
},
|
|
],
|
|
total: 2,
|
|
},
|
|
}),
|
|
}),
|
|
});
|
|
elements.sessionManagerList = { replaceChildren: vi.fn(), appendChild: vi.fn() };
|
|
app._buildHistoryItem = vi.fn(() => ({}));
|
|
app.resumeHistorySession = vi.fn();
|
|
|
|
await app._loadSessionManagerList('');
|
|
|
|
// The re-projected record is also what the row's ⋯ menu reads (mode badge,
|
|
// Resume), so these fields must survive it, not only reach onActivate.
|
|
const [codexRecord, , codexOptions] = app._buildHistoryItem.mock.calls[0];
|
|
expect(codexRecord).toMatchObject({ mode: 'codex', resumeId: 'codex-thread-1' });
|
|
codexOptions.onActivate();
|
|
expect(app.resumeHistorySession).toHaveBeenCalledWith(
|
|
'codex-thread-1',
|
|
'/repo/cx',
|
|
undefined,
|
|
'codex',
|
|
'codex-thread-1'
|
|
);
|
|
|
|
const [claudeRecord] = app._buildHistoryItem.mock.calls[1];
|
|
expect(claudeRecord).toMatchObject({ mode: 'claude', claudeSessionId: 'conv-uuid-2' });
|
|
});
|
|
|
|
it('surfaces an error message instead of an empty list when the endpoint fails', async () => {
|
|
const appended: any[] = [];
|
|
const { app, elements } = loadPaletteHarness({
|
|
fetch: async () => ({
|
|
ok: false,
|
|
status: 503,
|
|
json: async () => ({ success: false, error: 'unified list unavailable', errorCode: 'OPERATION_FAILED' }),
|
|
}),
|
|
});
|
|
elements.sessionManagerList = { replaceChildren: vi.fn(), appendChild: (el: any) => appended.push(el) };
|
|
|
|
await app._loadSessionManagerList('');
|
|
|
|
expect(appended).toHaveLength(1);
|
|
expect(appended[0].textContent).toBe('unified list unavailable');
|
|
expect(appended[0].textContent).not.toBe('No sessions found');
|
|
});
|
|
});
|
|
|
|
describe('overlay focus restoration (Escape must not strand the keyboard)', () => {
|
|
/**
|
|
* Both overlays focus their search box on open. Closing them used to leave
|
|
* focus on <body>, so after Escape every keystroke went nowhere until the
|
|
* user clicked the terminal — measured in a real browser against a shell
|
|
* session: activeElement BODY, zero onData for anything typed afterwards.
|
|
*/
|
|
function focusHarness() {
|
|
const terminalTextarea = { focus: vi.fn(), isConnected: true };
|
|
const priorElement = { focus: vi.fn(), isConnected: true, tagName: 'TEXTAREA' };
|
|
const body = { tagName: 'BODY' };
|
|
let active: any = priorElement;
|
|
// The harness builds its element map internally, so getElementById reads it
|
|
// through this binding, filled in once the harness returns.
|
|
let els: Record<string, any> = {};
|
|
const { app, elements, makeClassList } = loadPaletteHarness({
|
|
document: {
|
|
getElementById: (id: string) => els[id] ?? null,
|
|
get activeElement() {
|
|
return active;
|
|
},
|
|
body,
|
|
},
|
|
});
|
|
els = elements;
|
|
app.terminal = { focus: terminalTextarea.focus };
|
|
app.activeSessionId = 'sess-beta';
|
|
// The overlay's own focus() is what moves focus in a real browser; the
|
|
// fake document needs the same transition or the test proves nothing.
|
|
elements.commandPaletteSearch.focus = vi.fn(() => {
|
|
active = elements.commandPaletteSearch;
|
|
});
|
|
// The restore keeps a focus that already left the overlay, so the modal has
|
|
// to know its own search box is inside it, as the real DOM does.
|
|
elements.commandPaletteModal.contains = (el: any) => el === elements.commandPaletteSearch;
|
|
// Same wiring for the Session Manager. A real classList: the close guard
|
|
// reads `contains('active')`, and a stub without it reports "not open" and
|
|
// skips the restore.
|
|
const installSessionManager = () => {
|
|
const search: any = { value: '', addEventListener: vi.fn() };
|
|
search.focus = vi.fn(() => {
|
|
active = search;
|
|
});
|
|
elements.sessionManagerSearch = search;
|
|
elements.sessionManagerModal = {
|
|
classList: makeClassList(),
|
|
addEventListener: vi.fn(),
|
|
contains: (el: any) => el === search,
|
|
};
|
|
elements.sessionManagerList = { replaceChildren: vi.fn(), appendChild: vi.fn() };
|
|
app._loadSessionManagerList = vi.fn();
|
|
};
|
|
return {
|
|
app,
|
|
elements,
|
|
priorElement,
|
|
terminalTextarea,
|
|
body,
|
|
makeClassList,
|
|
installSessionManager,
|
|
setActive: (v: any) => (active = v),
|
|
};
|
|
}
|
|
|
|
it('returns focus to whatever had it when the command palette closes', () => {
|
|
const { app, priorElement } = focusHarness();
|
|
app.openCommandPalette();
|
|
expect(priorElement.focus).not.toHaveBeenCalled();
|
|
app.closeCommandPalette();
|
|
expect(priorElement.focus).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('falls back to the terminal when the prior element is gone, but only with a live session', () => {
|
|
const { app, priorElement, terminalTextarea } = focusHarness();
|
|
app.openCommandPalette();
|
|
priorElement.isConnected = false;
|
|
app.closeCommandPalette();
|
|
expect(priorElement.focus).not.toHaveBeenCalled();
|
|
expect(terminalTextarea.focus).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('never focuses the terminal from the welcome screen (a phone would pop the keyboard)', () => {
|
|
const { app, priorElement, terminalTextarea } = focusHarness();
|
|
app.activeSessionId = null;
|
|
app.openCommandPalette();
|
|
priorElement.isConnected = false;
|
|
app.closeCommandPalette();
|
|
expect(terminalTextarea.focus).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not restore focus to <body>, which is the bug itself', () => {
|
|
const { app, terminalTextarea, body, setActive } = focusHarness();
|
|
setActive(body);
|
|
app.openCommandPalette();
|
|
app.closeCommandPalette();
|
|
expect(terminalTextarea.focus).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('leaves focus alone when neither overlay was open — the path every Escape takes', () => {
|
|
// app.js's global Escape handler calls both close methods on EVERY Escape,
|
|
// in the capture phase. Nothing was saved, so an unguarded restore would fall
|
|
// through to the terminal and steal focus from split Pane B, from any text
|
|
// field, and turn the inline rename's Escape into a commit.
|
|
const { app, elements, terminalTextarea, priorElement, makeClassList } = focusHarness();
|
|
elements.sessionManagerModal = { classList: makeClassList(), addEventListener: vi.fn() };
|
|
app.closeCommandPalette();
|
|
app.closeSessionManager();
|
|
expect(terminalTextarea.focus).not.toHaveBeenCalled();
|
|
expect(priorElement.focus).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not focus the terminal on touch while the keyboard is down', () => {
|
|
const { app, terminalTextarea, body, setActive } = focusHarness();
|
|
app._shouldFocusTerminalForTabSwitch = () => false;
|
|
setActive(body);
|
|
app.openCommandPalette();
|
|
app.closeCommandPalette();
|
|
expect(terminalTextarea.focus).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('restores focus on the session manager too, not just the palette', async () => {
|
|
const { app, priorElement, installSessionManager } = focusHarness();
|
|
installSessionManager();
|
|
await app.openSessionManager();
|
|
app.closeSessionManager();
|
|
expect(priorElement.focus).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('keeps the terminal focus the row menu gave it when the session manager closes', async () => {
|
|
// "Switch to session" and "Open folder" (terminal-ui.js) call selectSession(),
|
|
// which focuses the terminal on desktop, and only THEN closeSessionManager().
|
|
// Opened from its header button, the saved focus is that button, so an
|
|
// unconditional restore pulled focus off the session the user just picked.
|
|
const { app, priorElement, terminalTextarea, installSessionManager, setActive } = focusHarness();
|
|
installSessionManager();
|
|
await app.openSessionManager();
|
|
app.selectSession = vi.fn(() => setActive(terminalTextarea));
|
|
app.selectSession('sess-alpha');
|
|
app.closeSessionManager();
|
|
expect(priorElement.focus).not.toHaveBeenCalled();
|
|
expect(terminalTextarea.focus).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('panel close helpers', () => {
|
|
it('closes panels when the mobile header helper is unavailable', () => {
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
const elements: Record<string, any> = {
|
|
monitorPanel: { classList: { remove: vi.fn() } },
|
|
subagentsPanel: { classList: { remove: vi.fn() } },
|
|
};
|
|
const context = vm.createContext({
|
|
CodemanApp,
|
|
document: {
|
|
getElementById: (id: string) => elements[id] ?? null,
|
|
},
|
|
console,
|
|
});
|
|
|
|
const settingsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/settings-ui.js'), 'utf8');
|
|
vm.runInContext(settingsUi, context, { filename: 'settings-ui.js' });
|
|
|
|
const app = new (CodemanApp as any)();
|
|
app.closeSessionOptions = vi.fn();
|
|
app.closeAppSettings = vi.fn();
|
|
app.cancelCloseSession = vi.fn();
|
|
app.closeTokenStats = vi.fn();
|
|
|
|
expect(() => app.closeAllPanels()).not.toThrow();
|
|
expect(elements.monitorPanel.classList.remove).toHaveBeenCalledWith('open');
|
|
expect(elements.subagentsPanel.classList.remove).toHaveBeenCalledWith('open');
|
|
});
|
|
});
|