mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
Tab Layout "By case" at tablet widths (600 to 767px, getDeviceType's 'tablet'): the case boxes could shrink in the tablet's one-row scrolling strip, so they squeezed and wrapped their tabs inside themselves and every tab past a box's first line was clipped under the fixed 48px header. The boxes now dissolve into the chip row as they already do on phones, which keeps the tablet's own 40px chip geometry. The rule sits in its own 600 to 767px block, not the 768px tablet block, so the desktop path at 768px and up (boxes keep their width, the strip wraps box by box) is untouched. Needs-you tile pulse: the glow animated box-shadow on the tile itself, so the whole tile (DOM-rendered terminal rows included, the whole stage when zoomed) was repainted every frame for as long as a prompt waited. The tile keeps its red border; the glow is now a static inset shadow on a .tile--needs::after overlay (inset because .tile is overflow: hidden and clips an outer one, z-index 3 above .tile-attach, pointer-events none) and only its opacity animates. The entering-and-needs animation shorthand is gone, since it would now blink the whole tile's opacity, and reduced motion keeps the static ring and hides the overlay. Welcome chips: the phone block set 36px, below the 40px that styles.css gives touch screens, and it wins on every phone, so phones got shorter chips than tablets. It now restates 40px. Tests: the tablet widths and the 768px boundary in tab-clusters, the opacity-only pulse overlay in tile-grid-motion, and the phone chip height in run-mode-ui; each fails against the previous CSS. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1585 lines
70 KiB
TypeScript
1585 lines
70 KiB
TypeScript
/**
|
|
* @fileoverview Unit tests for the Codex run-mode UI surface in session-ui.js /
|
|
* settings-ui.js / index.html. Loads the browser modules into a vm sandbox (no
|
|
* real DOM) and exercises run-mode selection + Codex quick-start wiring.
|
|
*/
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import vm from 'node:vm';
|
|
import postcss, { type AtRule } from 'postcss';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
function loadRunModeHarness() {
|
|
const elements: Record<string, any> = {};
|
|
const storage = new Map<string, string>();
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
|
|
const context = vm.createContext({
|
|
CodemanApp,
|
|
VoiceInput: {},
|
|
localStorage: {
|
|
getItem: (key: string) => storage.get(key) ?? null,
|
|
setItem: (key: string, value: string) => storage.set(key, value),
|
|
},
|
|
document: {
|
|
getElementById: (id: string) => elements[id] ?? null,
|
|
},
|
|
console,
|
|
});
|
|
|
|
const settingsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/settings-ui.js'), 'utf8');
|
|
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
|
vm.runInContext(settingsUi, context, { filename: 'settings-ui.js' });
|
|
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
|
|
|
const runModeMenu = { classList: { remove: () => {} } };
|
|
const gearBtn = { className: '' };
|
|
const runBtn = { className: '', nextElementSibling: gearBtn };
|
|
const runBtnLabel = { textContent: '' };
|
|
elements.runModeMenu = runModeMenu;
|
|
elements.runBtn = runBtn;
|
|
elements.runBtnLabel = runBtnLabel;
|
|
|
|
const app = new (CodemanApp as any)();
|
|
app.loadAppSettingsFromStorage = () => ({});
|
|
app.saveAppSettingsToStorage = () => {};
|
|
app._apiPut = () => Promise.resolve();
|
|
|
|
return { app, storage, runBtnLabel };
|
|
}
|
|
|
|
describe('run mode UI', () => {
|
|
it('updates the visible mode when selecting Claude after server sync set Codex', async () => {
|
|
const { app, storage, runBtnLabel } = loadRunModeHarness();
|
|
|
|
storage.set('codeman_runMode', 'claude');
|
|
await app.loadAppSettingsFromServer(Promise.resolve({ runMode: 'codex' }));
|
|
expect(app.runMode).toBe('codex');
|
|
expect(runBtnLabel.textContent).toBe('Run CX');
|
|
|
|
app.setRunMode('claude');
|
|
|
|
expect(app.runMode).toBe('claude');
|
|
expect(runBtnLabel.textContent).toBe('Run');
|
|
});
|
|
|
|
it('accepts Gemini mode from server sync and updates the run button label', async () => {
|
|
const { app, storage, runBtnLabel } = loadRunModeHarness();
|
|
|
|
storage.set('codeman_runMode', 'claude');
|
|
await app.loadAppSettingsFromServer(Promise.resolve({ runMode: 'gemini' }));
|
|
|
|
expect(app.runMode).toBe('gemini');
|
|
expect(runBtnLabel.textContent).toBe('Run GM');
|
|
});
|
|
|
|
it('accepts Antigravity mode from server sync and updates the run button label', async () => {
|
|
const { app, storage, runBtnLabel } = loadRunModeHarness();
|
|
|
|
storage.set('codeman_runMode', 'claude');
|
|
await app.loadAppSettingsFromServer(Promise.resolve({ runMode: 'antigravity' }));
|
|
|
|
expect(app.runMode).toBe('antigravity');
|
|
expect(runBtnLabel.textContent).toBe('Run AG');
|
|
});
|
|
|
|
it('accepts OMP mode from server sync and updates the run button label', async () => {
|
|
const { app, storage, runBtnLabel } = loadRunModeHarness();
|
|
|
|
storage.set('codeman_runMode', 'claude');
|
|
await app.loadAppSettingsFromServer(Promise.resolve({ runMode: 'omp' }));
|
|
|
|
expect(app.runMode).toBe('omp');
|
|
expect(runBtnLabel.textContent).toBe('Run OMP');
|
|
});
|
|
});
|
|
|
|
describe('Run launch synchronization', () => {
|
|
it('keeps launch progress out of an active session terminal', () => {
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
const context = vm.createContext({
|
|
CodemanApp,
|
|
localStorage: { getItem: () => null, setItem: () => {} },
|
|
document: { getElementById: () => null },
|
|
console,
|
|
});
|
|
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
|
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
|
|
|
const app = new (CodemanApp as any)();
|
|
app.activeSessionId = 'existing-session';
|
|
app.terminal = {
|
|
clear: vi.fn(),
|
|
writeln: vi.fn(),
|
|
};
|
|
app.showToast = vi.fn();
|
|
|
|
const ownsTerminal = app._beginSessionLaunchStatus('Starting Codex session', '1;32');
|
|
app._appendSessionLaunchStatus(ownsTerminal, 'Creating session');
|
|
app._reportSessionLaunchError(ownsTerminal, 'Launch failed');
|
|
|
|
expect(ownsTerminal).toBe(false);
|
|
expect(app.terminal.clear).not.toHaveBeenCalled();
|
|
expect(app.terminal.writeln).not.toHaveBeenCalled();
|
|
expect(app.showToast).toHaveBeenNthCalledWith(1, 'Starting Codex session', 'info');
|
|
expect(app.showToast).toHaveBeenNthCalledWith(2, 'Launch failed', 'error');
|
|
});
|
|
|
|
it('still renders launch progress in the terminal on the session-less home screen', () => {
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
const context = vm.createContext({
|
|
CodemanApp,
|
|
localStorage: { getItem: () => null, setItem: () => {} },
|
|
document: { getElementById: () => null },
|
|
console,
|
|
});
|
|
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
|
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
|
|
|
const app = new (CodemanApp as any)();
|
|
app.activeSessionId = null; // home screen: nothing else owns the terminal
|
|
app.terminal = { clear: vi.fn(), writeln: vi.fn() };
|
|
app.showToast = vi.fn();
|
|
|
|
const ownsTerminal = app._beginSessionLaunchStatus('Starting Codex session', '1;32');
|
|
app._appendSessionLaunchStatus(ownsTerminal, 'Creating session');
|
|
app._reportSessionLaunchError(ownsTerminal, 'Launch failed');
|
|
|
|
expect(ownsTerminal).toBe(true);
|
|
expect(app.terminal.clear).toHaveBeenCalledTimes(1);
|
|
expect(app.terminal.writeln.mock.calls.map((c: string[]) => c[0]).join('\n')).toContain('Starting Codex session');
|
|
expect(app.terminal.writeln.mock.calls.map((c: string[]) => c[0]).join('\n')).toContain('Creating session');
|
|
expect(app.terminal.writeln.mock.calls.map((c: string[]) => c[0]).join('\n')).toContain('Error: Launch failed');
|
|
expect(app.showToast).not.toHaveBeenCalled();
|
|
});
|
|
|
|
/**
|
|
* Static guard over session-ui.js itself. The helpers above can be perfectly
|
|
* correct while a run*() entry point still writes to the shared xterm
|
|
* directly, which is the actual bug: a launch started while another session
|
|
* is active wipes that session's terminal, and _cleanupPreviousSession()
|
|
* then serializes the wiped view into its restore snapshot. Asserting on the
|
|
* helpers alone cannot see that, so pin the call sites here. This also
|
|
* covers run modes added later, which is how runAntigravity was caught.
|
|
*/
|
|
it('routes every run mode through the ownership helpers, never the terminal directly', () => {
|
|
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
|
|
|
// Methods live in one Object.assign(prototype, {...}) block at a fixed
|
|
// 2-space indent, so `\n },` reliably closes the one we are inside.
|
|
//
|
|
// `(\w*)` in the param list, not `()`, and the leading `_?`: PR B2
|
|
// consolidated the eight run<Mode>() bodies into one shared
|
|
// `_runCliMode(mode)`, and the ORIGINAL `\(\)`-only pattern matched every
|
|
// one-line wrapper (`async runOpenCode() { return this._runCliMode(...) }`)
|
|
// but not `_runCliMode` itself, where the real terminal-ownership logic
|
|
// now lives — so this guard could see 8 clean one-liners and stay green
|
|
// while the actual bug shipped unseen for all eight external CLIs at
|
|
// once. Confirmed live: adding `this.terminal.clear()` to `_runCliMode`
|
|
// left this test 32/32 green under the old pattern.
|
|
const bodies = new Map<string, string>();
|
|
const header = /^ {2}async (_?run[A-Za-z]*)\(\w*\) \{$/gm;
|
|
for (let m = header.exec(src); m; m = header.exec(src)) {
|
|
const start = m.index + m[0].length;
|
|
const end = src.indexOf('\n },', start);
|
|
expect(end, `could not find the end of ${m[1]}()`).toBeGreaterThan(start);
|
|
bodies.set(m[1], src.slice(start, end));
|
|
}
|
|
|
|
// Fail loudly if the scan matched nothing: a silently empty scan would make
|
|
// every assertion below vacuously true.
|
|
expect([...bodies.keys()]).toEqual(
|
|
expect.arrayContaining([
|
|
'runClaude',
|
|
'runShell',
|
|
'_runCliMode',
|
|
'runOpenCode',
|
|
'runCodex',
|
|
'runGemini',
|
|
'runAntigravity',
|
|
'runPi',
|
|
'runGrok',
|
|
])
|
|
);
|
|
|
|
for (const [name, body] of bodies) {
|
|
expect(body, `${name}() must not clear a terminal it may not own`).not.toContain('this.terminal.clear(');
|
|
expect(body, `${name}() must not write launch status straight to the terminal`).not.toContain(
|
|
'this.terminal.writeln('
|
|
);
|
|
}
|
|
});
|
|
|
|
it('coalesces overlapping Run activations and disables the button while the request is active', async () => {
|
|
const runBtn = {
|
|
disabled: false,
|
|
setAttribute: vi.fn(),
|
|
removeAttribute: vi.fn(),
|
|
};
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
const context = vm.createContext({
|
|
CodemanApp,
|
|
localStorage: { getItem: () => null, setItem: () => {} },
|
|
document: { getElementById: (id: string) => (id === 'runBtn' ? runBtn : null) },
|
|
console,
|
|
});
|
|
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
|
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
|
|
|
const app = new (CodemanApp as any)();
|
|
app._runMinLockMs = 0;
|
|
let finishRun!: () => void;
|
|
app.runClaude = vi.fn(
|
|
() =>
|
|
new Promise<void>((resolveRun) => {
|
|
finishRun = resolveRun;
|
|
})
|
|
);
|
|
|
|
const first = app.run();
|
|
const duplicate = app.run();
|
|
|
|
expect(app.runClaude).toHaveBeenCalledTimes(1);
|
|
expect(runBtn.disabled).toBe(true);
|
|
expect(runBtn.setAttribute).toHaveBeenCalledWith('aria-busy', 'true');
|
|
|
|
finishRun();
|
|
await Promise.all([first, duplicate]);
|
|
|
|
expect(runBtn.disabled).toBe(false);
|
|
expect(runBtn.removeAttribute).toHaveBeenCalledWith('aria-busy');
|
|
});
|
|
|
|
it('renders a POST response session immediately without waiting for SSE', async () => {
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
const context = vm.createContext({
|
|
CodemanApp,
|
|
localStorage: { getItem: () => null, setItem: () => {} },
|
|
document: { getElementById: () => null },
|
|
fetch: vi.fn(),
|
|
console,
|
|
});
|
|
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
|
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
|
|
|
const app = new (CodemanApp as any)();
|
|
app.sessions = new Map();
|
|
app._onSessionCreated = vi.fn((session: any) => app.sessions.set(session.id, session));
|
|
app._renderSessionTabsImmediate = vi.fn();
|
|
const snapshot = { id: 'sess-new', name: 'w1-case', workingDir: '/tmp/case' };
|
|
|
|
await app._ensureCreatedSessionVisible(snapshot.id, snapshot);
|
|
|
|
expect(context.fetch).not.toHaveBeenCalled();
|
|
expect(app.sessions.get(snapshot.id)).toEqual(snapshot);
|
|
expect(app._renderSessionTabsImmediate).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('loads the new session when a quick-start response wins the race with SSE', async () => {
|
|
const snapshot = { id: 'sess-race', name: 'w1-remote', workingDir: '/remote/work' };
|
|
const fetchMock = vi.fn(async () => ({
|
|
json: async () => ({ success: true, data: snapshot }),
|
|
}));
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
const context = vm.createContext({
|
|
CodemanApp,
|
|
localStorage: { getItem: () => null, setItem: () => {} },
|
|
document: { getElementById: () => null },
|
|
fetch: fetchMock,
|
|
console,
|
|
});
|
|
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
|
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
|
|
|
const app = new (CodemanApp as any)();
|
|
app.sessions = new Map();
|
|
app._onSessionCreated = vi.fn((session: any) => app.sessions.set(session.id, session));
|
|
app._renderSessionTabsImmediate = vi.fn();
|
|
|
|
await app._ensureCreatedSessionVisible(snapshot.id);
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith('/api/sessions/sess-race');
|
|
expect(app.sessions.get(snapshot.id)).toEqual(snapshot);
|
|
expect(app._renderSessionTabsImmediate).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('Codex quick start settings', () => {
|
|
it('renders Codex CLI settings in their own group inside Agents & CLIs', () => {
|
|
const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8');
|
|
|
|
// The settings modal is one scrolling document: Codex is a GROUP that can be
|
|
// hidden wholesale, not a tab (see _applyCodexSettingsVisibility).
|
|
const clis = html.match(/<section class="set-section" id="settings-clis"([\s\S]*?)<\/section>/);
|
|
expect(clis?.[1]).toBeTruthy();
|
|
|
|
const codexGroup = clis![1].match(/id="appSettingsCodexGroup"([\s\S]*)$/);
|
|
expect(codexGroup?.[1]).toContain('appSettingsCodexDangerouslyBypassApprovals');
|
|
expect(codexGroup?.[1]).toContain('appSettingsCodexAnimations');
|
|
expect(codexGroup?.[1]).not.toContain('appSettingsCodexRenderMode');
|
|
|
|
// The Claude settings above it must not have absorbed the codex inputs.
|
|
const beforeCodex = clis![1].slice(0, clis![1].indexOf('id="appSettingsCodexGroup"'));
|
|
expect(beforeCodex).not.toContain('appSettingsCodexDangerouslyBypassApprovals');
|
|
expect(beforeCodex).not.toContain('appSettingsCodexAnimations');
|
|
});
|
|
|
|
describe('Codex CLI group visibility', () => {
|
|
// Both settings in the group are handed to `codex` at launch, so on an
|
|
// instance where the binary does not resolve the group is a promise nothing
|
|
// can keep. renderIndexHtml injects window.__codemanCliAvailable; this pins
|
|
// the client half. Coupled test: it drives the REAL settings-ui.js against a
|
|
// stub element, so deleting the call in openAppSettings() is what it catches.
|
|
function loadSettingsUi(codexAvailable: boolean | undefined) {
|
|
const codexTabBtn = { id: 'appSettingsCodexGroup', style: { display: 'PRISTINE' } };
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
const context: any = vm.createContext({
|
|
CodemanApp,
|
|
MobileDetection: { getDeviceType: () => 'desktop', isTouchDevice: () => false, isHandheldDevice: () => false },
|
|
localStorage: { getItem: () => null, setItem: () => {} },
|
|
document: {
|
|
getElementById: (id: string) => (id === 'appSettingsCodexGroup' ? codexTabBtn : null),
|
|
querySelector: () => null,
|
|
},
|
|
console,
|
|
});
|
|
context.window = context;
|
|
if (codexAvailable !== undefined) context.__codemanCliAvailable = { codex: codexAvailable };
|
|
const settingsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/settings-ui.js'), 'utf8');
|
|
vm.runInContext(settingsUi, context, { filename: 'settings-ui.js' });
|
|
return { app: new (CodemanApp as any)(), codexTabBtn };
|
|
}
|
|
|
|
it('hides the Codex group when the codex binary is not available', () => {
|
|
const { app, codexTabBtn } = loadSettingsUi(false);
|
|
app._applyCodexSettingsVisibility();
|
|
expect(codexTabBtn.style.display).toBe('none');
|
|
});
|
|
|
|
it('hides the Codex group when the availability flag was never injected', () => {
|
|
const { app, codexTabBtn } = loadSettingsUi(undefined);
|
|
app._applyCodexSettingsVisibility();
|
|
expect(codexTabBtn.style.display).toBe('none');
|
|
});
|
|
|
|
it('shows the Codex group when codex is available', () => {
|
|
const { app, codexTabBtn } = loadSettingsUi(true);
|
|
app._applyCodexSettingsVisibility();
|
|
expect(codexTabBtn.style.display).toBe('');
|
|
});
|
|
|
|
it('applies the gating from openAppSettings, not just in isolation', () => {
|
|
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/settings-ui.js'), 'utf8');
|
|
const open = src.slice(src.indexOf('\n openAppSettings() {'));
|
|
const body = open.slice(0, open.indexOf('\n },'));
|
|
expect(body).toContain('_applyCodexSettingsVisibility()');
|
|
});
|
|
});
|
|
|
|
describe('CLI availability gating (#200/#201)', () => {
|
|
const CATALOG = [
|
|
{ id: 'claude', label: 'Claude Code', shortBadge: 'CC', kind: 'agent', enabled: true },
|
|
{ id: 'opencode', label: 'OpenCode', shortBadge: 'OC', kind: 'agent', enabled: true },
|
|
{ id: 'codex', label: 'Codex', shortBadge: 'CX', kind: 'agent', enabled: true },
|
|
{ id: 'gemini', label: 'Gemini', shortBadge: 'GM', kind: 'agent', enabled: true },
|
|
{ id: 'antigravity', label: 'Antigravity', shortBadge: 'AG', kind: 'agent', enabled: true },
|
|
{ id: 'pi', label: 'Pi', shortBadge: 'PI', kind: 'agent', enabled: true },
|
|
{ id: 'grok', label: 'Grok', shortBadge: 'GK', kind: 'agent', enabled: true },
|
|
{ id: 'deepseek', label: 'DeepSeek', shortBadge: 'DS', kind: 'agent', enabled: true },
|
|
{ id: 'omp', label: 'OMP', shortBadge: 'OM', kind: 'agent', enabled: true },
|
|
{ id: 'custom-agent', label: 'Custom Agent', shortBadge: 'CA', kind: 'agent', enabled: true },
|
|
{ id: 'shell', label: 'Shell', shortBadge: 'SH', kind: 'shell', enabled: true },
|
|
];
|
|
|
|
function element() {
|
|
const el: any = {
|
|
style: { display: 'PRISTINE' },
|
|
dataset: {},
|
|
children: [] as any[],
|
|
setAttribute: () => {},
|
|
appendChild(child: any) {
|
|
this.children.push(child);
|
|
return child;
|
|
},
|
|
append(child: any) {
|
|
this.children.push(child);
|
|
},
|
|
replaceChildren(...children: any[]) {
|
|
this.children = children;
|
|
},
|
|
};
|
|
return el;
|
|
}
|
|
|
|
// Drives the REAL settings-ui.js + session-ui.js against stub elements, including
|
|
// a custom registry entry so a static stock-only list cannot pass this test.
|
|
function loadUi(flags: Record<string, boolean> | undefined, catalog = CATALOG) {
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
const welcomeCliActions = element();
|
|
const tunnelBtn = element();
|
|
const runModeCliOptions = element();
|
|
const modeBtns: Record<string, any> = {};
|
|
for (const cli of catalog) {
|
|
modeBtns[cli.id] = element();
|
|
modeBtns[cli.id].dataset.mode = cli.id;
|
|
}
|
|
const menu = {
|
|
querySelector: (sel: string) =>
|
|
sel === '#runModeDeepSeekInstall' || sel === '#runModeDeepSeekWeb' ? null : null,
|
|
querySelectorAll: () => Object.values(modeBtns),
|
|
};
|
|
const context: any = vm.createContext({
|
|
CodemanApp,
|
|
MobileDetection: { getDeviceType: () => 'desktop', isTouchDevice: () => false, isHandheldDevice: () => false },
|
|
localStorage: { getItem: () => null, setItem: () => {} },
|
|
document: {
|
|
getElementById: (id: string) =>
|
|
id === 'welcomeCliActions'
|
|
? welcomeCliActions
|
|
: id === 'welcomeTunnelBtn'
|
|
? tunnelBtn
|
|
: id === 'runModeCliOptions'
|
|
? runModeCliOptions
|
|
: null,
|
|
createElement: element,
|
|
createElementNS: element,
|
|
querySelector: () => null,
|
|
},
|
|
console,
|
|
});
|
|
context.window = context;
|
|
context.__codemanCliCatalog = catalog;
|
|
if (flags !== undefined) context.__codemanCliAvailable = flags;
|
|
for (const file of ['settings-ui.js', 'session-ui.js']) {
|
|
const src = readFileSync(resolve(import.meta.dirname, `../src/web/public/${file}`), 'utf8');
|
|
vm.runInContext(src, context, { filename: file });
|
|
}
|
|
return {
|
|
app: new (CodemanApp as any)(),
|
|
welcomeCliActions,
|
|
tunnelBtn,
|
|
runModeCliOptions,
|
|
modeBtns,
|
|
menu,
|
|
context,
|
|
};
|
|
}
|
|
|
|
const ALL_OFF = {
|
|
claude: false,
|
|
opencode: false,
|
|
codex: false,
|
|
gemini: false,
|
|
antigravity: false,
|
|
pi: false,
|
|
grok: false,
|
|
deepseek: false,
|
|
omp: false,
|
|
'custom-agent': false,
|
|
cloudflared: false,
|
|
};
|
|
|
|
// Every welcome launcher in DOM order: the primary button, then the chips
|
|
// inside their group. A launcher is anything carrying data-mode; the walk
|
|
// stops there, so a button's own logo spans are never mistaken for one.
|
|
function launchers(container: any): any[] {
|
|
return container.children.flatMap((c: any) =>
|
|
typeof c === 'string' ? [] : c.dataset?.mode ? [c] : launchers(c)
|
|
);
|
|
}
|
|
const offeredModes = (container: any) => launchers(container).map((btn: any) => btn.dataset.mode);
|
|
// The visible label sits in a .welcome-label span (it ellipsizes a long custom
|
|
// label) as one text node; a bare string child would be one too.
|
|
const textOf = (btn: any) =>
|
|
btn.children
|
|
.map((c: any) => (typeof c === 'string' ? c : c?.className === 'welcome-label' ? c.textContent : ''))
|
|
.join('');
|
|
|
|
it('renders only enabled and available registry entries on the welcome screen', () => {
|
|
const { app, welcomeCliActions, tunnelBtn } = loadUi({ ...ALL_OFF, claude: true, 'custom-agent': true });
|
|
app.applyWelcomeCliVisibility();
|
|
expect(offeredModes(welcomeCliActions)).toEqual(['claude', 'custom-agent', 'shell']);
|
|
// #200 originally DELETED the tunnel button and its QR outright; it is gated
|
|
// on cloudflared instead, so a box that has cloudflared keeps the feature.
|
|
expect(tunnelBtn.style.display).toBe('none');
|
|
|
|
const withTunnel = loadUi({ ...ALL_OFF, cloudflared: true });
|
|
withTunnel.app.applyWelcomeCliVisibility();
|
|
expect(withTunnel.tunnelBtn.style.display).toBe('flex');
|
|
|
|
const withPi = loadUi({ ...ALL_OFF, pi: true });
|
|
withPi.app.applyWelcomeCliVisibility();
|
|
expect(offeredModes(withPi.welcomeCliActions)).toEqual(['pi', 'shell']);
|
|
});
|
|
|
|
it('gates every run mode in the dropdown, antigravity included, and never shell', () => {
|
|
const { app, modeBtns, menu } = loadUi({ ...ALL_OFF, claude: true, antigravity: true, omp: true });
|
|
app._refreshRunModeAvailability(menu);
|
|
expect(modeBtns.claude.style.display).toBe('flex');
|
|
expect(modeBtns.antigravity.style.display).toBe('flex');
|
|
expect(modeBtns.opencode.style.display).toBe('none');
|
|
expect(modeBtns.codex.style.display).toBe('none');
|
|
expect(modeBtns.gemini.style.display).toBe('none');
|
|
expect(modeBtns.omp.style.display).toBe('flex');
|
|
// Shell needs no external CLI, and leaving it alone is what guarantees the
|
|
// menu is never empty on a box with nothing installed.
|
|
expect(modeBtns.shell.style.display).toBe('PRISTINE');
|
|
});
|
|
|
|
it('renders each enabled agent registry entry in the Run menu, including custom entries', () => {
|
|
const { app, runModeCliOptions } = loadUi({ ...ALL_OFF, claude: true, 'custom-agent': true });
|
|
app.renderRegistryRunOptions();
|
|
expect(runModeCliOptions.children.map((btn: any) => btn.dataset.mode)).toContain('custom-agent');
|
|
expect(runModeCliOptions.children.map((btn: any) => btn.dataset.mode)).not.toContain('shell');
|
|
});
|
|
|
|
it('labels the primary "Run <label>" and each chip by name, with "Run <label>" as its tooltip', () => {
|
|
// "Run Claude Code" / "Run Shell" are the strings i18n.js translates: the
|
|
// primary carries one as ONE text node (i18n matches whole nodes), and a
|
|
// chip carries one as its title and accessible name, both translated
|
|
// attributes, beside the bare CLI name it shows.
|
|
const { app, welcomeCliActions } = loadUi({ ...ALL_OFF, claude: true });
|
|
app.applyWelcomeCliVisibility();
|
|
const [primary, ...chips] = launchers(welcomeCliActions);
|
|
expect(textOf(primary)).toBe('Run Claude Code');
|
|
expect(chips.map(textOf)).toEqual(['Shell']);
|
|
expect(chips.map((chip: any) => chip.title)).toEqual(['Run Shell']);
|
|
// Each label is its own span, so a long one ellipsizes instead of overflowing.
|
|
expect(primary.children.filter((c: any) => c?.className === 'welcome-label')).toHaveLength(1);
|
|
expect(chips[0].children.filter((c: any) => c?.className === 'welcome-label')).toHaveLength(1);
|
|
});
|
|
|
|
it('promotes the first AGENT in catalog order to the primary, chosen by kind and order, never by id', () => {
|
|
const primaryOf = (flags: Record<string, boolean> | undefined, catalog = CATALOG) => {
|
|
const { app, welcomeCliActions } = loadUi(flags, catalog);
|
|
app.applyWelcomeCliVisibility();
|
|
const first = welcomeCliActions.children[0];
|
|
return first ? { mode: first.dataset.mode, className: first.className } : null;
|
|
};
|
|
expect(primaryOf(undefined)).toEqual({ mode: 'claude', className: 'welcome-primary' });
|
|
// Claude missing or disabled: the next agent takes the slot, not a hardcoded fallback.
|
|
expect(primaryOf({ ...ALL_OFF, opencode: true, codex: true })?.mode).toBe('opencode');
|
|
const noClaude = CATALOG.map((cli) => (cli.id === 'claude' ? { ...cli, enabled: false } : cli));
|
|
expect(primaryOf(undefined, noClaude)?.mode).toBe('opencode');
|
|
// A shell listed first never outranks an agent after it...
|
|
const shellFirst = [CATALOG[CATALOG.length - 1], ...CATALOG.slice(0, -1)];
|
|
expect(primaryOf({ ...ALL_OFF, codex: true }, shellFirst)?.mode).toBe('codex');
|
|
// ...and a custom agent at the head of the catalog is the primary like any other.
|
|
const customFirst = [
|
|
{ id: 'my-agent', label: 'My Agent', shortBadge: 'MA', kind: 'agent', enabled: true },
|
|
...CATALOG,
|
|
];
|
|
expect(primaryOf({ ...ALL_OFF, 'my-agent': true, claude: true }, customFirst)?.mode).toBe('my-agent');
|
|
// Only the shell left: it is the primary rather than an empty screen.
|
|
expect(primaryOf({ ...ALL_OFF })?.mode).toBe('shell');
|
|
});
|
|
|
|
it('renders the rest as chips in catalog order, carrying the CLI id only as data', () => {
|
|
const { app, welcomeCliActions } = loadUi(undefined);
|
|
app.applyWelcomeCliVisibility();
|
|
expect(welcomeCliActions.children).toHaveLength(2);
|
|
const [primary, chipRow] = welcomeCliActions.children;
|
|
expect(chipRow.className).toBe('welcome-chips');
|
|
expect(chipRow.children.map((chip: any) => chip.dataset.mode)).toEqual(
|
|
CATALOG.map((cli) => cli.id).filter((id) => id !== primary.dataset.mode)
|
|
);
|
|
for (const btn of launchers(welcomeCliActions)) {
|
|
const id = btn.dataset.mode;
|
|
// The logo is the shared Run-menu slot, so styles.css draws the brand mark
|
|
// (or the plain fallback dot of an id it has no mark for).
|
|
const logos: any[] = [];
|
|
const collect = (el: any) =>
|
|
el.children.forEach((c: any) => {
|
|
if (typeof c === 'string') return;
|
|
if (String(c.className).startsWith('run-mode-dot')) logos.push(c);
|
|
collect(c);
|
|
});
|
|
collect(btn);
|
|
expect(logos.map((l) => l.className)).toEqual([`run-mode-dot ${id}`]);
|
|
// No per-id class on the button itself, so no rule can restyle one CLI.
|
|
expect(btn.className).not.toContain(id);
|
|
expect(typeof btn.onclick).toBe('function');
|
|
}
|
|
});
|
|
|
|
it('styles the launchers from skin tokens only, with no per-CLI welcome rule left', () => {
|
|
const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
|
|
const mobileCss = readFileSync(resolve(import.meta.dirname, '../src/web/public/mobile.css'), 'utf8');
|
|
const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8');
|
|
// The old look was one gradient per CLI (.welcome-btn-codex, …) plus skin
|
|
// overrides of the same classes; any survivor would re-tint one launcher.
|
|
expect(css).not.toMatch(/\.welcome-btn\b/);
|
|
expect(mobileCss).not.toMatch(/\.welcome-btn\b/);
|
|
const rule = (selector: string) => {
|
|
const at = css.indexOf(`\n${selector} {`);
|
|
expect(at, `${selector} rule`).toBeGreaterThan(-1);
|
|
return css.slice(at, css.indexOf('}', at));
|
|
};
|
|
expect(rule('.welcome-primary')).toContain('var(--accent-grad-a)');
|
|
expect(rule('.welcome-chip')).toContain('var(--control-bg)');
|
|
expect(rule('.welcome-chip')).toContain('var(--control-border)');
|
|
// Light skins: the tunnel spinner's track follows the link's text colour (the
|
|
// shared track is translucent white), and a running tunnel reads as a pill.
|
|
expect(rule('.welcome-tunnel-link .tunnel-spinner')).toContain('currentColor');
|
|
expect(rule('.welcome-tunnel-link.active')).toContain('background:');
|
|
expect(rule('.welcome-label')).toContain('text-overflow: ellipsis');
|
|
expect(rule('.welcome-primary')).toContain('max-width: 100%');
|
|
expect(rule('.welcome-chip')).toContain('max-width: 100%');
|
|
// Hover motion stays off for anyone who asked for less of it.
|
|
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\) \{\s*\.welcome-primary,/);
|
|
// The tunnel keeps its id and handler, is no longer a launcher, and sits
|
|
// between the launchers and the QR it reveals.
|
|
expect(html).toMatch(
|
|
/class="welcome-tunnel-link" id="welcomeTunnelBtn"[^>]*onclick="app\.toggleTunnelFromWelcome\(\)"/
|
|
);
|
|
const actions = html.indexOf('id="welcomeCliActions"');
|
|
const tunnel = html.indexOf('id="welcomeTunnelBtn"');
|
|
expect(actions).toBeGreaterThan(-1);
|
|
expect(tunnel).toBeGreaterThan(actions);
|
|
expect(html.indexOf('id="welcomeQr"')).toBeGreaterThan(tunnel);
|
|
});
|
|
|
|
it('never gives a phone shorter launcher chips than a tablet', () => {
|
|
// styles.css sizes chips for touch screens under (pointer: coarse);
|
|
// mobile.css's phone block loads later at equal specificity and wins on
|
|
// every phone, so a lower height there shrank the phone's chips below the
|
|
// tablet's (36px against 40px).
|
|
const chipHeights = (file: string, media: string) => {
|
|
const heights: number[] = [];
|
|
const css = readFileSync(resolve(import.meta.dirname, `../src/web/public/${file}`), 'utf8');
|
|
postcss.parse(css).walkRules('.welcome-chip', (rule) => {
|
|
if (rule.parent?.type !== 'atrule' || (rule.parent as AtRule).params !== media) return;
|
|
rule.walkDecls('height', (d) => {
|
|
heights.push(parseFloat(d.value));
|
|
});
|
|
});
|
|
return heights;
|
|
};
|
|
const touch = chipHeights('styles.css', '(pointer: coarse)');
|
|
const phone = chipHeights('mobile.css', '(max-width: 599px)');
|
|
expect(touch).toEqual([40]);
|
|
expect(phone.length).toBeGreaterThan(0);
|
|
for (const h of phone) expect(h).toBeGreaterThanOrEqual(Math.max(...touch));
|
|
});
|
|
|
|
it('falls back to the first ENABLED agent when the chosen run mode is disabled, never a hardcoded claude', () => {
|
|
const catalog = CATALOG.map((cli) =>
|
|
cli.id === 'claude' || cli.id === 'codex' ? { ...cli, enabled: false } : cli
|
|
);
|
|
const { app } = loadUi(undefined, catalog);
|
|
app.runMode = 'codex';
|
|
expect(app.runMode).toBe('opencode');
|
|
app.runMode = 'claude';
|
|
expect(app.runMode).toBe('opencode');
|
|
app.runMode = 'gemini';
|
|
expect(app.runMode).toBe('gemini');
|
|
});
|
|
|
|
it('builds the run-mode menu from the registry rather than static markup', () => {
|
|
const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8');
|
|
expect(html).toContain('id="runModeCliOptions"');
|
|
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
|
expect(src).toContain('renderRegistryRunOptions()');
|
|
expect(src).not.toContain('data-mode="codex"');
|
|
});
|
|
|
|
it('keeps Claude-only session options for claude even though it is a registry agent', () => {
|
|
const catalog = CATALOG.map((cli) => ({ ...cli, external: cli.id !== 'claude' && cli.id !== 'shell' }));
|
|
const { context } = loadUi(undefined, catalog);
|
|
expect(context.isExternalCliSession('claude')).toBe(false);
|
|
expect(context.isExternalCliSession('shell')).toBe(false);
|
|
expect(context.isExternalCliSession('codex')).toBe(true);
|
|
expect(context.isExternalCliSession('custom-agent')).toBe(true);
|
|
});
|
|
|
|
it('keeps the served external flag when App Settings resyncs the catalog from /api/clis', () => {
|
|
const catalog = CATALOG.map((cli) => ({ ...cli, external: cli.id !== 'claude' && cli.id !== 'shell' }));
|
|
const { app, context } = loadUi(undefined, catalog);
|
|
// /api/clis rows: no capabilities, so no `external`.
|
|
app._cliList = CATALOG.map((cli) => ({ ...cli, installed: true }));
|
|
app.runMode = 'claude';
|
|
app._syncCliLaunchCatalog();
|
|
expect(context.isExternalCliSession('claude')).toBe(false);
|
|
expect(context.isExternalCliSession('codex')).toBe(true);
|
|
});
|
|
|
|
it('gates Session Options on isExternalCliSession, not the launch-path check', () => {
|
|
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
|
const open = src.slice(src.indexOf('\n openSessionOptions('));
|
|
const body = open.slice(0, open.indexOf('\n },'));
|
|
expect(body).toContain('isExternalCliSession(session.mode)');
|
|
expect(body).not.toContain('isExternalCliRunMode(');
|
|
});
|
|
|
|
it('shows everything when the flags were never injected', () => {
|
|
// A cached page from a build without the injection, or a solo popup. Hiding
|
|
// every run button on a doubt would leave a working install nothing to click.
|
|
const { app, welcomeCliActions, modeBtns, menu } = loadUi(undefined);
|
|
app.applyWelcomeCliVisibility();
|
|
app._refreshRunModeAvailability(menu);
|
|
expect(offeredModes(welcomeCliActions)).toContain('claude');
|
|
expect(modeBtns.gemini.style.display).toBe('flex');
|
|
});
|
|
});
|
|
|
|
it('passes global Codex settings into quick-start config for new sessions', async () => {
|
|
const elements: Record<string, any> = {
|
|
quickStartCase: { value: 'codex-case' },
|
|
tabCount: { value: '1' },
|
|
};
|
|
const requests: Array<{ url: string; body?: any }> = [];
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
|
|
const context = vm.createContext({
|
|
CodemanApp,
|
|
localStorage: {
|
|
getItem: () => null,
|
|
setItem: () => {},
|
|
},
|
|
document: {
|
|
getElementById: (id: string) => elements[id] ?? null,
|
|
},
|
|
// Mock responses use the real wire shape: the global preSerialization hook in
|
|
// server.ts wraps route payloads into the { success, data } envelope.
|
|
fetch: async (url: string, init?: { body?: string }) => {
|
|
requests.push({ url, body: init?.body ? JSON.parse(init.body) : undefined });
|
|
if (url === '/api/codex/status') return { json: async () => ({ success: true, data: { available: true } }) };
|
|
if (url === '/api/quick-start') return { json: async () => ({ success: true, data: { sessionId: 'sess-1' } }) };
|
|
if (url === '/api/sessions/sess-1')
|
|
return { json: async () => ({ success: true, data: { id: 'sess-1', name: 'w1-codex-case' } }) };
|
|
throw new Error(`unexpected fetch: ${url}`);
|
|
},
|
|
console,
|
|
});
|
|
|
|
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
|
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
|
|
|
const app = new (CodemanApp as any)();
|
|
app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {} };
|
|
app.loadAppSettingsFromStorage = () => ({
|
|
codexDangerouslyBypassApprovals: true,
|
|
codexAnimationsEnabled: false,
|
|
});
|
|
app.getCaseSettings = () => ({});
|
|
app.buildEnvOverrides = () => ({});
|
|
app.sessions = new Map();
|
|
app._onSessionCreated = (session: any) => app.sessions.set(session.id, session);
|
|
app._renderSessionTabsImmediate = vi.fn();
|
|
const selected: string[] = [];
|
|
app.selectSession = async (id: string) => {
|
|
selected.push(id);
|
|
};
|
|
|
|
await app.runCodex();
|
|
|
|
expect(requests.find((req) => req.url === '/api/quick-start')?.body).toMatchObject({
|
|
caseName: 'codex-case',
|
|
mode: 'codex',
|
|
// tabs follow the w<n>-<case> naming convention (quick-start would otherwise auto-name codeman-<id>)
|
|
sessionName: 'w1-codex-case',
|
|
codexConfig: { dangerouslyBypassApprovals: true, animations: false, renderMode: 'hybrid' },
|
|
});
|
|
expect(selected).toEqual(['sess-1']);
|
|
});
|
|
});
|
|
|
|
describe('case selector refresh', () => {
|
|
it('sorts case picker options alphabetically and filters by case or host label', () => {
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
const context = vm.createContext({
|
|
CodemanApp,
|
|
localStorage: { getItem: () => null, setItem: () => {} },
|
|
document: { getElementById: () => null },
|
|
console,
|
|
});
|
|
|
|
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
|
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
|
|
|
const app = new (CodemanApp as any)();
|
|
const cases = [
|
|
{ name: 'zeta' },
|
|
{ name: 'moneytrove', location: 'remote', remote: { hostId: 'mac-mini', path: '/Users/saqeb/moneytrove' } },
|
|
{ name: 'Alpha' },
|
|
{ name: 'plex-previews' },
|
|
];
|
|
|
|
const options = app.buildCasePickerOptions(cases);
|
|
|
|
expect(options.map((option: any) => option.name)).toEqual([
|
|
'Alpha',
|
|
'moneytrove',
|
|
'plex-previews',
|
|
'testcase',
|
|
'zeta',
|
|
]);
|
|
expect(options.find((option: any) => option.name === 'moneytrove')?.label).toBe('moneytrove @ mac-mini');
|
|
expect(app.filterCasePickerOptions(options, 'MAC').map((option: any) => option.name)).toEqual(['moneytrove']);
|
|
expect(app.filterCasePickerOptions(options, 'plex').map((option: any) => option.name)).toEqual(['plex-previews']);
|
|
});
|
|
|
|
it('labels dockerized cases with a short "(docker)" tag (or the custom host id)', () => {
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
const context = vm.createContext({
|
|
CodemanApp,
|
|
localStorage: { getItem: () => null, setItem: () => {} },
|
|
document: { getElementById: () => null },
|
|
console,
|
|
});
|
|
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
|
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
|
const app = new (CodemanApp as any)();
|
|
|
|
const label = (c: any) => app.formatCasePickerLabel(c);
|
|
// default one-click host, the 'local' Docker-tab default, and per-case override
|
|
// hosts all collapse to the short "(docker)" tag.
|
|
expect(
|
|
label({ name: 'sandbox', location: 'docker', docker: { hostId: 'default', container: 'codeman-case-sandbox' } })
|
|
).toBe('sandbox (docker)');
|
|
expect(label({ name: 'sandbox', location: 'docker', docker: { hostId: 'local' } })).toBe('sandbox (docker)');
|
|
expect(label({ name: 'sandbox', location: 'docker', docker: { hostId: 'q-sandbox' } })).toBe('sandbox (docker)');
|
|
// a user-named docker host shows its id
|
|
expect(label({ name: 'ml', location: 'docker', docker: { hostId: 'gpu-box' } })).toBe('ml (gpu-box)');
|
|
});
|
|
|
|
it('launches the highlighted case with the current run mode when pressing Enter in the picker', () => {
|
|
const elements: Record<string, any> = {};
|
|
const listeners: Record<string, (event: any) => void> = {};
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
|
|
elements.quickStartCase = {
|
|
value: 'Alpha',
|
|
dataset: {},
|
|
};
|
|
elements.quickStartCaseSearch = {
|
|
value: 'mon',
|
|
dataset: {},
|
|
setAttribute: vi.fn(),
|
|
removeAttribute: vi.fn(),
|
|
addEventListener: vi.fn((event: string, handler: (event: any) => void) => {
|
|
listeners[event] = handler;
|
|
}),
|
|
select: vi.fn(),
|
|
};
|
|
elements.quickStartCaseList = {
|
|
innerHTML: '',
|
|
classList: { add: vi.fn(), remove: vi.fn() },
|
|
addEventListener: vi.fn(),
|
|
};
|
|
elements.quickStartCasePicker = {
|
|
contains: () => true,
|
|
};
|
|
|
|
const context = vm.createContext({
|
|
CodemanApp,
|
|
localStorage: { getItem: () => null, setItem: () => {} },
|
|
document: {
|
|
getElementById: (id: string) => elements[id] ?? null,
|
|
addEventListener: vi.fn(),
|
|
},
|
|
console,
|
|
escapeHtml: (s: string) => s,
|
|
});
|
|
|
|
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
|
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
|
|
|
const app = new (CodemanApp as any)();
|
|
app.cases = [
|
|
{ name: 'Alpha' },
|
|
{ name: 'moneytrove', location: 'remote', remote: { hostId: 'mac-mini', path: '/Users/saqeb/moneytrove' } },
|
|
{ name: 'zeta' },
|
|
];
|
|
app.updateDirDisplayForCase = vi.fn();
|
|
app.updateMobileCaseLabel = vi.fn();
|
|
app.saveLastUsedCase = vi.fn();
|
|
app.run = vi.fn(async () => {});
|
|
|
|
app.setupQuickStartCasePicker();
|
|
listeners.keydown({ key: 'Enter', preventDefault: vi.fn() });
|
|
|
|
expect(elements.quickStartCase.value).toBe('moneytrove');
|
|
expect(app.run).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('ends the picker with "New or link a case…" and "Case settings…" rows that replaced the toolbar buttons', () => {
|
|
const elements: Record<string, any> = {};
|
|
const listeners: Record<string, (event: any) => void> = {};
|
|
let listClick: ((event: any) => void) | null = null;
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
elements.quickStartCase = { value: 'Alpha', dataset: {} };
|
|
elements.quickStartCaseSearch = {
|
|
value: '',
|
|
dataset: {},
|
|
setAttribute: vi.fn(),
|
|
removeAttribute: vi.fn(),
|
|
addEventListener: vi.fn((event: string, handler: (event: any) => void) => {
|
|
listeners[event] = handler;
|
|
}),
|
|
select: vi.fn(),
|
|
blur: vi.fn(),
|
|
};
|
|
elements.quickStartCaseList = {
|
|
innerHTML: '',
|
|
classList: { add: vi.fn(), remove: vi.fn() },
|
|
addEventListener: vi.fn((event: string, handler: (event: any) => void) => {
|
|
if (event === 'click') listClick = handler;
|
|
}),
|
|
};
|
|
elements.quickStartCasePicker = { contains: () => true };
|
|
const context = vm.createContext({
|
|
CodemanApp,
|
|
localStorage: { getItem: () => null, setItem: () => {} },
|
|
document: { getElementById: (id: string) => elements[id] ?? null, addEventListener: vi.fn() },
|
|
console,
|
|
escapeHtml: (s: string) => s,
|
|
setInterval: () => 1,
|
|
clearInterval: () => {},
|
|
fetch: () => new Promise(() => {}),
|
|
});
|
|
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
|
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
|
const app = new (CodemanApp as any)();
|
|
app.cases = [{ name: 'Alpha' }, { name: 'zeta' }];
|
|
app.updateDirDisplayForCase = vi.fn();
|
|
app.updateMobileCaseLabel = vi.fn();
|
|
app.saveLastUsedCase = vi.fn();
|
|
app.updateCasePickerInput = vi.fn();
|
|
app.run = vi.fn(async () => {});
|
|
app.showCreateCaseModal = vi.fn();
|
|
app.toggleCaseSettings = vi.fn();
|
|
app.setupQuickStartCasePicker();
|
|
|
|
// Both rows render after the last case, also when nothing matches.
|
|
app.openCasePicker('');
|
|
const html = elements.quickStartCaseList.innerHTML;
|
|
const lastCase = html.lastIndexOf('data-case="');
|
|
expect(html.indexOf('data-case-action="add"')).toBeGreaterThan(lastCase);
|
|
expect(html.indexOf('data-case-action="settings"')).toBeGreaterThan(html.indexOf('data-case-action="add"'));
|
|
app.openCasePicker('no-such-case');
|
|
expect(elements.quickStartCaseList.innerHTML).toContain('No cases match');
|
|
expect(elements.quickStartCaseList.innerHTML).toContain('data-case-action="add"');
|
|
|
|
// The arrow keys walk past the cases (Alpha, testcase, zeta) onto the rows; Enter runs one.
|
|
app.openCasePicker('');
|
|
for (let i = 0; i < 3; i++) listeners.keydown({ key: 'ArrowDown', preventDefault: vi.fn() });
|
|
expect(elements.quickStartCaseList.innerHTML).toMatch(
|
|
/class="case-combobox-action active"[^>]*data-case-action="add"/
|
|
);
|
|
listeners.keydown({ key: 'Enter', preventDefault: vi.fn() });
|
|
expect(app.showCreateCaseModal).toHaveBeenCalledTimes(1);
|
|
expect(app.run).not.toHaveBeenCalled();
|
|
|
|
// A click on the settings row opens the case settings popover.
|
|
app.openCasePicker('');
|
|
const settingsRow = { dataset: { caseAction: 'settings' } };
|
|
listClick!({ target: { closest: (sel: string) => (sel === '.case-combobox-action' ? settingsRow : null) } });
|
|
expect(app.toggleCaseSettings).toHaveBeenCalledTimes(1);
|
|
|
|
// The toolbar buttons themselves are gone.
|
|
const html2 = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8');
|
|
expect(html2).not.toContain('class="btn-case-add"');
|
|
expect(html2).not.toContain('class="btn-case-settings"');
|
|
});
|
|
|
|
it('creates remote shell sessions by caseName instead of remote display path', async () => {
|
|
const elements: Record<string, any> = {
|
|
quickStartCase: { value: 'gpu-work' },
|
|
// The toolbar's one instance stepper, shared by Run and Run Shell since
|
|
// the second (#shellCount) group was removed.
|
|
tabCount: { value: '1' },
|
|
};
|
|
const requests: Array<{ url: string; body?: any }> = [];
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
|
|
const context = vm.createContext({
|
|
CodemanApp,
|
|
localStorage: {
|
|
getItem: () => null,
|
|
setItem: () => {},
|
|
},
|
|
document: {
|
|
getElementById: (id: string) => elements[id] ?? null,
|
|
},
|
|
fetch: async (url: string, init?: { body?: string }) => {
|
|
requests.push({ url, body: init?.body ? JSON.parse(init.body) : undefined });
|
|
if (url === '/api/cases/gpu-work') {
|
|
return {
|
|
json: async () => ({
|
|
success: true,
|
|
data: {
|
|
name: 'gpu-work',
|
|
path: 'ubuntu@10.0.0.42:/home/ubuntu/work',
|
|
location: 'remote',
|
|
remote: { hostId: 'gpu-box', path: '/home/ubuntu/work' },
|
|
},
|
|
}),
|
|
};
|
|
}
|
|
if (url === '/api/quick-start') {
|
|
return { json: async () => ({ success: true, data: { sessionId: 'sess-1' } }) };
|
|
}
|
|
throw new Error(`unexpected fetch: ${url}`);
|
|
},
|
|
console,
|
|
});
|
|
|
|
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
|
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
|
|
|
const app = new (CodemanApp as any)();
|
|
app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {} };
|
|
app.sessions = new Map();
|
|
app.cases = [{ name: 'gpu-work', path: 'ubuntu@10.0.0.42:/home/ubuntu/work', location: 'remote' }];
|
|
app.getTerminalDimensions = () => null;
|
|
app.selectSession = async () => {};
|
|
|
|
await app.runShell();
|
|
|
|
// Remote cases must ride /api/quick-start (which resolves the remote case and
|
|
// launches over ssh) — POST /api/sessions stat-validates workingDir locally and
|
|
// its schema has no caseName, so the remote display path must never reach it.
|
|
expect(requests.find((req) => req.url === '/api/quick-start')?.body).toMatchObject({
|
|
caseName: 'gpu-work',
|
|
mode: 'shell',
|
|
});
|
|
expect(requests.find((req) => req.url === '/api/quick-start')?.body).not.toHaveProperty('workingDir');
|
|
expect(requests.some((req) => req.url === '/api/sessions')).toBe(false);
|
|
});
|
|
|
|
it('removes a deleted selected case from the dropdown and blurs the native picker', async () => {
|
|
const elements: Record<string, any> = {};
|
|
const requests: Array<{ url: string; method: string; body?: any }> = [];
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
const quickStartCase = {
|
|
value: 'deleted-case',
|
|
innerHTML: '<option value="deleted-case">deleted-case</option><option value="kept-case">kept-case</option>',
|
|
dataset: {},
|
|
blur: vi.fn(),
|
|
addEventListener: vi.fn(),
|
|
};
|
|
|
|
elements.quickStartCase = quickStartCase;
|
|
elements.caseManageList = { innerHTML: '' };
|
|
elements.mobileCaseName = { textContent: '' };
|
|
elements.dirDisplay = { textContent: '' };
|
|
elements.dirInput = { value: '' };
|
|
|
|
const context = vm.createContext({
|
|
CodemanApp,
|
|
MobileDetection: { getDeviceType: () => 'desktop' },
|
|
localStorage: {
|
|
getItem: () => null,
|
|
setItem: () => {},
|
|
},
|
|
document: {
|
|
getElementById: (id: string) => elements[id] ?? null,
|
|
},
|
|
confirm: () => true,
|
|
fetch: async (url: string, init?: { method?: string; body?: string }) => {
|
|
requests.push({ url, method: init?.method ?? 'GET', body: init?.body ? JSON.parse(init.body) : undefined });
|
|
if (url === '/api/cases/deleted-case')
|
|
return { json: async () => ({ success: true, data: { name: 'deleted-case' } }) };
|
|
// The server's preSerialization hook wraps bare payloads as { success, data },
|
|
// so the frontend reads `.data` off every JSON response — mirror that here.
|
|
if (url === '/api/settings')
|
|
return { ok: true, json: async () => ({ success: true, data: { lastUsedCase: 'deleted-case' } }) };
|
|
if (url === '/api/cases')
|
|
return { json: async () => ({ success: true, data: [{ name: 'kept-case', path: '/tmp/kept-case' }] }) };
|
|
if (url === '/api/cases/kept-case')
|
|
return { json: async () => ({ success: true, data: { path: '/tmp/kept-case' } }) };
|
|
if (url === '/api/settings' && init?.method === 'PUT') return { json: async () => ({ success: true }) };
|
|
throw new Error(`unexpected fetch: ${url}`);
|
|
},
|
|
console,
|
|
escapeHtml: (s: string) => s,
|
|
});
|
|
|
|
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
|
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
|
|
|
const app = new (CodemanApp as any)();
|
|
app.cases = [
|
|
{ name: 'deleted-case', path: '/tmp/deleted-case' },
|
|
{ name: 'kept-case', path: '/tmp/kept-case' },
|
|
];
|
|
app.showToast = vi.fn();
|
|
|
|
// deleteCase re-renders the case-manage list, whose path label goes through
|
|
// _shortenHomePath. That method lives in terminal-ui.js, which this harness
|
|
// does not load (the real app always has it: load order 7 before 12).
|
|
app._shortenHomePath = (p: string) => p;
|
|
|
|
await app.deleteCase('deleted-case');
|
|
|
|
expect(quickStartCase.blur).toHaveBeenCalled();
|
|
expect(quickStartCase.innerHTML).not.toContain('deleted-case');
|
|
expect(quickStartCase.innerHTML).toContain('kept-case');
|
|
expect(elements.mobileCaseName.textContent).toBe('kept-case');
|
|
expect(requests).toContainEqual({
|
|
url: '/api/settings',
|
|
method: 'PUT',
|
|
body: { lastUsedCase: 'kept-case' },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('toolbar instance count', () => {
|
|
// Run Shell used to carry its own `#shellCount` stepper next to the Run one.
|
|
// It was removed (#428), so every launch path reads #tabCount through
|
|
// _readTabCount(), and an absent stepper (phones and tablets hide the group)
|
|
// has to read as 1, not throw.
|
|
function loadCounter(elements: Record<string, any>) {
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
const context = vm.createContext({
|
|
CodemanApp,
|
|
localStorage: { getItem: () => null, setItem: () => {} },
|
|
document: { getElementById: (id: string) => elements[id] ?? null },
|
|
console,
|
|
});
|
|
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
|
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
|
return new (CodemanApp as any)();
|
|
}
|
|
|
|
it('reads the shared stepper and falls back to 1 when it is absent', () => {
|
|
expect(loadCounter({ tabCount: { value: '3' } })._readTabCount()).toBe(3);
|
|
expect(loadCounter({})._readTabCount()).toBe(1);
|
|
expect(loadCounter({ tabCount: { value: '' } })._readTabCount()).toBe(1);
|
|
expect(loadCounter({ tabCount: { value: '0' } })._readTabCount()).toBe(1);
|
|
expect(loadCounter({ tabCount: { value: '99' } })._readTabCount()).toBe(20);
|
|
});
|
|
|
|
it('no longer exposes the removed shell stepper handlers', () => {
|
|
const app = loadCounter({ tabCount: { value: '1' } });
|
|
expect(app.incrementShellCount).toBeUndefined();
|
|
expect(app.decrementShellCount).toBeUndefined();
|
|
});
|
|
|
|
it('ships one stepper in the toolbar markup, not two', () => {
|
|
const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8');
|
|
expect(html).not.toContain('id="shellCount"');
|
|
expect(html.match(/class="tab-count-group"/g)).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('Gemini quick start', () => {
|
|
// Regression guard for the ApiResponse-envelope unwrap in runGemini(): the
|
|
// status check must read `.data.available` and the quick-start response must
|
|
// read `.data.sessionId`. Reading the raw shape (pre-fix) silently bails on
|
|
// the status check and never selects the new tab — exactly the two blockers
|
|
// caught in PR #134 review.
|
|
it('drives runGemini() through the {success,data} envelope and selects the new session', async () => {
|
|
const elements: Record<string, any> = {
|
|
quickStartCase: { value: 'gemini-case' },
|
|
tabCount: { value: '1' },
|
|
};
|
|
const requests: Array<{ url: string; body?: any }> = [];
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
|
|
const context = vm.createContext({
|
|
CodemanApp,
|
|
localStorage: { getItem: () => null, setItem: () => {} },
|
|
document: { getElementById: (id: string) => elements[id] ?? null },
|
|
// Mock responses use the real wire shape: the server.ts preSerialization
|
|
// hook wraps raw route payloads into the { success, data } envelope.
|
|
fetch: async (url: string, init?: { body?: string }) => {
|
|
requests.push({ url, body: init?.body ? JSON.parse(init.body) : undefined });
|
|
if (url === '/api/gemini/status') return { json: async () => ({ success: true, data: { available: true } }) };
|
|
if (url === '/api/quick-start')
|
|
return { json: async () => ({ success: true, data: { sessionId: 'sess-gm' } }) };
|
|
if (url === '/api/sessions/sess-gm')
|
|
return { json: async () => ({ success: true, data: { id: 'sess-gm', name: 'w1-gemini-case' } }) };
|
|
throw new Error(`unexpected fetch: ${url}`);
|
|
},
|
|
console,
|
|
});
|
|
|
|
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
|
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
|
|
|
const app = new (CodemanApp as any)();
|
|
app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {} };
|
|
app.loadAppSettingsFromStorage = () => ({});
|
|
app.getCaseSettings = () => ({});
|
|
app.buildEnvOverrides = () => ({});
|
|
app.sessions = new Map();
|
|
app._onSessionCreated = (session: any) => app.sessions.set(session.id, session);
|
|
app._renderSessionTabsImmediate = vi.fn();
|
|
const selected: string[] = [];
|
|
app.selectSession = async (id: string) => {
|
|
selected.push(id);
|
|
};
|
|
|
|
await app.runGemini();
|
|
|
|
expect(requests.find((req) => req.url === '/api/quick-start')?.body).toMatchObject({
|
|
caseName: 'gemini-case',
|
|
mode: 'gemini',
|
|
geminiConfig: { approvalMode: 'yolo' },
|
|
});
|
|
expect(selected).toEqual(['sess-gm']);
|
|
});
|
|
});
|
|
|
|
describe('Antigravity quick start', () => {
|
|
// Same envelope-unwrap regression guard as the Gemini block above, for runAntigravity().
|
|
it('drives runAntigravity() through the {success,data} envelope and selects the new session', async () => {
|
|
const elements: Record<string, any> = {
|
|
quickStartCase: { value: 'ag-case' },
|
|
tabCount: { value: '1' },
|
|
};
|
|
const requests: Array<{ url: string; body?: any }> = [];
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
|
|
const context = vm.createContext({
|
|
CodemanApp,
|
|
localStorage: { getItem: () => null, setItem: () => {} },
|
|
document: { getElementById: (id: string) => elements[id] ?? null },
|
|
fetch: async (url: string, init?: { body?: string }) => {
|
|
requests.push({ url, body: init?.body ? JSON.parse(init.body) : undefined });
|
|
if (url === '/api/antigravity/status')
|
|
return { json: async () => ({ success: true, data: { available: true } }) };
|
|
if (url === '/api/quick-start')
|
|
return { json: async () => ({ success: true, data: { sessionId: 'sess-ag' } }) };
|
|
if (url === '/api/sessions/sess-ag')
|
|
return { json: async () => ({ success: true, data: { id: 'sess-ag', name: 'w1-ag-case' } }) };
|
|
throw new Error(`unexpected fetch: ${url}`);
|
|
},
|
|
console,
|
|
});
|
|
|
|
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
|
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
|
|
|
const app = new (CodemanApp as any)();
|
|
app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {} };
|
|
app.loadAppSettingsFromStorage = () => ({});
|
|
app.getCaseSettings = () => ({});
|
|
app.buildEnvOverrides = () => ({});
|
|
app.sessions = new Map();
|
|
app._onSessionCreated = (session: any) => app.sessions.set(session.id, session);
|
|
app._renderSessionTabsImmediate = vi.fn();
|
|
const selected: string[] = [];
|
|
app.selectSession = async (id: string) => {
|
|
selected.push(id);
|
|
};
|
|
|
|
await app.runAntigravity();
|
|
|
|
expect(requests.find((req) => req.url === '/api/quick-start')?.body).toMatchObject({
|
|
caseName: 'ag-case',
|
|
mode: 'antigravity',
|
|
antigravityConfig: { dangerouslySkipPermissions: true },
|
|
});
|
|
expect(selected).toEqual(['sess-ag']);
|
|
});
|
|
});
|
|
|
|
describe('Pi quick start', () => {
|
|
// Same envelope-unwrap regression guard as the blocks above, for runPi(), plus the
|
|
// rule that makes pi different: it must send NO piConfig. Pi has no permission
|
|
// prompts, and `approveProjectTrust` would opt the session into EXECUTING
|
|
// repo-supplied TypeScript — never something a Run button decides silently.
|
|
it('drives runPi() through the {success,data} envelope and sends no piConfig', async () => {
|
|
const elements: Record<string, any> = {
|
|
quickStartCase: { value: 'pi-case' },
|
|
tabCount: { value: '1' },
|
|
};
|
|
const requests: Array<{ url: string; body?: any }> = [];
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
|
|
const context = vm.createContext({
|
|
CodemanApp,
|
|
localStorage: { getItem: () => null, setItem: () => {} },
|
|
document: { getElementById: (id: string) => elements[id] ?? null },
|
|
fetch: async (url: string, init?: { body?: string }) => {
|
|
requests.push({ url, body: init?.body ? JSON.parse(init.body) : undefined });
|
|
if (url === '/api/pi/status')
|
|
return {
|
|
json: async () => ({ success: true, data: { available: true, path: '/usr/local/bin', version: '0.84.1' } }),
|
|
};
|
|
if (url === '/api/quick-start')
|
|
return { json: async () => ({ success: true, data: { sessionId: 'sess-pi' } }) };
|
|
if (url === '/api/sessions/sess-pi')
|
|
return { json: async () => ({ success: true, data: { id: 'sess-pi', name: 'w1-pi-case' } }) };
|
|
throw new Error(`unexpected fetch: ${url}`);
|
|
},
|
|
console,
|
|
});
|
|
|
|
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
|
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
|
|
|
const app = new (CodemanApp as any)();
|
|
app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {} };
|
|
app.loadAppSettingsFromStorage = () => ({});
|
|
app.getCaseSettings = () => ({});
|
|
app.buildEnvOverrides = () => ({});
|
|
app.sessions = new Map();
|
|
app._onSessionCreated = (session: any) => app.sessions.set(session.id, session);
|
|
app._renderSessionTabsImmediate = vi.fn();
|
|
const selected: string[] = [];
|
|
app.selectSession = async (id: string) => {
|
|
selected.push(id);
|
|
};
|
|
|
|
await app.runPi();
|
|
|
|
const body = requests.find((req) => req.url === '/api/quick-start')?.body;
|
|
expect(body).toMatchObject({ caseName: 'pi-case', mode: 'pi' });
|
|
expect(body).not.toHaveProperty('piConfig');
|
|
expect(selected).toEqual(['sess-pi']);
|
|
});
|
|
|
|
it('reports the install hint when the CLI is missing and starts nothing', async () => {
|
|
const elements: Record<string, any> = { quickStartCase: { value: 'pi-case' } };
|
|
const requests: string[] = [];
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
const context = vm.createContext({
|
|
CodemanApp,
|
|
localStorage: { getItem: () => null, setItem: () => {} },
|
|
document: { getElementById: (id: string) => elements[id] ?? null },
|
|
fetch: async (url: string) => {
|
|
requests.push(url);
|
|
if (url === '/api/pi/status')
|
|
return { json: async () => ({ success: true, data: { available: false, path: null, version: null } }) };
|
|
throw new Error(`unexpected fetch: ${url}`);
|
|
},
|
|
console,
|
|
});
|
|
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
|
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
|
|
|
const app = new (CodemanApp as any)();
|
|
app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {} };
|
|
const errors: string[] = [];
|
|
app._reportSessionLaunchError = (_owns: boolean, msg: string) => errors.push(msg);
|
|
|
|
await app.runPi();
|
|
|
|
expect(requests).toEqual(['/api/pi/status']);
|
|
expect(errors[0]).toContain('@earendil-works/pi-coding-agent');
|
|
});
|
|
});
|
|
|
|
describe('Grok quick start', () => {
|
|
// Same envelope-unwrap regression guard as the blocks above, for runGrok(),
|
|
// plus the rule that makes grok the OPPOSITE of pi: the Run button DOES send
|
|
// `grokConfig: { alwaysApprove: true }` (grok's bypassPermissions mode), the
|
|
// same product decision as runAntigravity's dangerouslySkipPermissions and
|
|
// claude's --dangerously-skip-permissions. The multi-user clamp strips it
|
|
// server-side for non-granted owners.
|
|
it('drives runGrok() through the {success,data} envelope and sends alwaysApprove', async () => {
|
|
const elements: Record<string, any> = {
|
|
quickStartCase: { value: 'grok-case' },
|
|
tabCount: { value: '1' },
|
|
};
|
|
const requests: Array<{ url: string; body?: any }> = [];
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
|
|
const context = vm.createContext({
|
|
CodemanApp,
|
|
localStorage: { getItem: () => null, setItem: () => {} },
|
|
document: { getElementById: (id: string) => elements[id] ?? null },
|
|
fetch: async (url: string, init?: { body?: string }) => {
|
|
requests.push({ url, body: init?.body ? JSON.parse(init.body) : undefined });
|
|
if (url === '/api/grok/status')
|
|
return {
|
|
json: async () => ({
|
|
success: true,
|
|
data: { available: true, path: '/home/user/.grok/bin', version: '1.0.5' },
|
|
}),
|
|
};
|
|
if (url === '/api/quick-start')
|
|
return { json: async () => ({ success: true, data: { sessionId: 'sess-gk' } }) };
|
|
if (url === '/api/sessions/sess-gk')
|
|
return { json: async () => ({ success: true, data: { id: 'sess-gk', name: 'w1-grok-case' } }) };
|
|
throw new Error(`unexpected fetch: ${url}`);
|
|
},
|
|
console,
|
|
});
|
|
|
|
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
|
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
|
|
|
const app = new (CodemanApp as any)();
|
|
app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {} };
|
|
app.loadAppSettingsFromStorage = () => ({});
|
|
app.getCaseSettings = () => ({});
|
|
app.buildEnvOverrides = () => ({});
|
|
app.sessions = new Map();
|
|
app._onSessionCreated = (session: any) => app.sessions.set(session.id, session);
|
|
app._renderSessionTabsImmediate = vi.fn();
|
|
const selected: string[] = [];
|
|
app.selectSession = async (id: string) => {
|
|
selected.push(id);
|
|
};
|
|
|
|
await app.runGrok();
|
|
|
|
const body = requests.find((req) => req.url === '/api/quick-start')?.body;
|
|
expect(body).toMatchObject({
|
|
caseName: 'grok-case',
|
|
mode: 'grok',
|
|
grokConfig: { alwaysApprove: true },
|
|
});
|
|
expect(selected).toEqual(['sess-gk']);
|
|
});
|
|
|
|
it('reports the install hint when the CLI is missing and starts nothing', async () => {
|
|
const elements: Record<string, any> = { quickStartCase: { value: 'grok-case' } };
|
|
const requests: string[] = [];
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
const context = vm.createContext({
|
|
CodemanApp,
|
|
localStorage: { getItem: () => null, setItem: () => {} },
|
|
document: { getElementById: (id: string) => elements[id] ?? null },
|
|
fetch: async (url: string) => {
|
|
requests.push(url);
|
|
if (url === '/api/grok/status')
|
|
return { json: async () => ({ success: true, data: { available: false, path: null, version: null } }) };
|
|
throw new Error(`unexpected fetch: ${url}`);
|
|
},
|
|
console,
|
|
});
|
|
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
|
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
|
|
|
const app = new (CodemanApp as any)();
|
|
app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {} };
|
|
const errors: string[] = [];
|
|
app._reportSessionLaunchError = (_owns: boolean, msg: string) => errors.push(msg);
|
|
|
|
await app.runGrok();
|
|
|
|
expect(requests).toEqual(['/api/grok/status']);
|
|
expect(errors[0]).toContain('https://x.ai/cli/install.sh');
|
|
});
|
|
|
|
// The whole point of the shared _launchQuickStartInstances() helper. Before it,
|
|
// every non-Claude run*() hardcoded exactly one quick-start call, so the
|
|
// "Instance count" stepper next to the Run button silently did nothing on all
|
|
// eight of them: no error, no hint, just the wrong number of sessions. The five
|
|
// fixture edits that came with the change stub tabCount at '1', so they pass
|
|
// identically with and without it; this is the one that does not.
|
|
it('launches tabCount sessions with sequential w<n>-<case> names and selects the first', async () => {
|
|
const elements: Record<string, any> = {
|
|
quickStartCase: { value: 'grok-case' },
|
|
tabCount: { value: '3' },
|
|
};
|
|
const requests: Array<{ url: string; body?: any }> = [];
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
let created = 0;
|
|
|
|
const context = vm.createContext({
|
|
CodemanApp,
|
|
localStorage: { getItem: () => null, setItem: () => {} },
|
|
document: { getElementById: (id: string) => elements[id] ?? null },
|
|
fetch: async (url: string, init?: { body?: string }) => {
|
|
const body = init?.body ? JSON.parse(init.body) : undefined;
|
|
requests.push({ url, body });
|
|
if (url === '/api/grok/status')
|
|
return {
|
|
json: async () => ({
|
|
success: true,
|
|
data: { available: true, path: '/home/user/.grok/bin', version: '1.0.5' },
|
|
}),
|
|
};
|
|
if (url === '/api/quick-start') {
|
|
const id = `sess-gk-${created++}`;
|
|
return {
|
|
json: async () => ({ success: true, data: { sessionId: id, session: { id, name: body.sessionName } } }),
|
|
};
|
|
}
|
|
throw new Error(`unexpected fetch: ${url}`);
|
|
},
|
|
console,
|
|
});
|
|
|
|
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
|
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
|
|
|
const app = new (CodemanApp as any)();
|
|
app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {} };
|
|
app.loadAppSettingsFromStorage = () => ({});
|
|
app.getCaseSettings = () => ({});
|
|
app.buildEnvOverrides = () => ({});
|
|
app.sessions = new Map();
|
|
app._onSessionCreated = (session: any) => app.sessions.set(session.id, session);
|
|
app._renderSessionTabsImmediate = () => {};
|
|
const selected: string[] = [];
|
|
app.selectSession = async (id: string) => {
|
|
selected.push(id);
|
|
};
|
|
|
|
await app.runGrok();
|
|
|
|
const names = requests.filter((r) => r.url === '/api/quick-start').map((r) => r.body.sessionName);
|
|
expect(names).toEqual(['w1-grok-case', 'w2-grok-case', 'w3-grok-case']);
|
|
expect(selected).toEqual(['sess-gk-0']);
|
|
});
|
|
});
|
|
|
|
describe('case lookup before a local launch', () => {
|
|
function loadLaunchHarness(caseAnswer: Record<string, unknown>) {
|
|
const elements: Record<string, any> = {
|
|
quickStartCase: { value: 'nas-case' },
|
|
tabCount: { value: '1' },
|
|
};
|
|
const requests: Array<{ url: string; method?: string }> = [];
|
|
const written: string[] = [];
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
const context = vm.createContext({
|
|
CodemanApp,
|
|
localStorage: { getItem: () => null, setItem: () => {} },
|
|
document: { getElementById: (id: string) => elements[id] ?? null },
|
|
fetch: async (url: string, init?: { method?: string }) => {
|
|
requests.push({ url, method: init?.method });
|
|
if (url === '/api/cases/nas-case') return { json: async () => caseAnswer };
|
|
if (url === '/api/cases' && init?.method === 'POST') {
|
|
return {
|
|
json: async () => ({
|
|
success: true,
|
|
data: { case: { name: 'nas-case', path: '/home/u/codeman-cases/nas-case' } },
|
|
}),
|
|
};
|
|
}
|
|
// Anything past the case lookup is out of scope here: stop the launch.
|
|
throw new Error(`stop: ${url}`);
|
|
},
|
|
console,
|
|
});
|
|
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
|
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
|
const app = new (CodemanApp as any)();
|
|
app.terminal = { clear: () => {}, writeln: (line: string) => written.push(line), focus: () => {} };
|
|
app.sessions = new Map();
|
|
app.cases = [];
|
|
app.getTerminalDimensions = () => null;
|
|
app._readTabCount = () => 1;
|
|
app.loadAppSettingsFromStorage = () => ({});
|
|
app.getCaseSettings = () => ({});
|
|
return { app, requests, written };
|
|
}
|
|
|
|
const unreachable = { success: false, error: 'Case folder is not responding', errorCode: 'OPERATION_FAILED' };
|
|
const missing = { success: false, error: 'Case not found', errorCode: 'NOT_FOUND' };
|
|
|
|
for (const launcher of ['runClaude', 'runShell'] as const) {
|
|
it(`${launcher} never creates a case when the lookup could not tell whether it exists`, async () => {
|
|
const { app, requests, written } = loadLaunchHarness(unreachable);
|
|
await app[launcher]();
|
|
expect(requests.some((r) => r.url === '/api/cases' && r.method === 'POST')).toBe(false);
|
|
expect(written.join('\n')).toContain('Case folder is not responding');
|
|
});
|
|
|
|
it(`${launcher} creates the case when the lookup says it does not exist`, async () => {
|
|
const { app, requests } = loadLaunchHarness(missing);
|
|
await app[launcher]();
|
|
expect(requests.some((r) => r.url === '/api/cases' && r.method === 'POST')).toBe(true);
|
|
});
|
|
}
|
|
});
|